diff --git a/adm/style/js/acp_knowledgebase.js b/adm/style/js/acp_knowledgebase.js
index f9beb5d..1e35827 100644
--- a/adm/style/js/acp_knowledgebase.js
+++ b/adm/style/js/acp_knowledgebase.js
@@ -10,4 +10,15 @@
$icon.attr('class', '');
}
});
+ // The introduction's text and Markdown options show only while it is
+ // switched on. They are in the page from the start, so the setting still
+ // works without JavaScript.
+ let $introEnabled = $('#intro_enabled');
+ let $introOptions = $('#intro_options');
+ if ($introEnabled.length && $introOptions.length) {
+ $introOptions.toggle($introEnabled.prop('checked'));
+ $introEnabled.on('change', function() {
+ $introOptions.toggle(this.checked);
+ });
+ }
})(jQuery);
diff --git a/controller/acp_controller.php b/controller/acp_controller.php
index 05b63bb..bd78cbc 100644
--- a/controller/acp_controller.php
+++ b/controller/acp_controller.php
@@ -288,7 +288,6 @@ public function settings(): void
'ADVANCED_FORM' => $announce_forum_id ? '' : 'none',
'HIERARCHICAL_INDEX_ON' => $this->config['kb_hierarchical_index'] ? 'checked="checked"' : '',
'INTRO_ON' => $this->intro && $this->intro->is_enabled() ? 'checked="checked"' : '',
- 'INTRO_DISPLAY' => $this->intro && $this->intro->is_enabled() ? '' : 'none',
'INTRO_TEXT' => $this->intro ? $this->intro->for_edit() : '',
'INTRO_MARKDOWN_ON' => $this->intro && $this->intro->is_markdown() ? 'checked="checked"' : '',
'ALT_STYLE_ON' => $this->config['kb_alt_style_enabled'] ? 'checked="checked"' : '',
@@ -3067,7 +3066,7 @@ protected function kb_acl_group_raw_data($group_id = false, $opts = false, $cate
$sql_ary[] = 'SELECT a.group_id, a.category_id, a.auth_setting, a.auth_option_id, ao.auth_option
FROM ' . $this->kb_groups_table . ' a, ' . $this->options_table . ' ao
WHERE a.auth_option_id = ao.auth_option_id ' .
- (($sql_group) ? 'AND a.' . $sql_group : '') .
+ (($sql_group) ? 'AND a.' . $sql_group : '') . ' ' .
$sql_category . ' ' .
$sql_opts . '
ORDER BY a.category_id, ao.auth_option';
@@ -3099,11 +3098,11 @@ protected function build_auth_option_statement($key, $auth_options, &$sql_opts):
{
if (str_contains($auth_options, '%'))
{
- $sql_opts = 'AND ' . $key . $this->db->sql_like_expression(str_replace('%', $this->db->get_any_char(), $auth_options));
+ $sql_opts = 'AND ' . $key . ' ' . $this->db->sql_like_expression(str_replace('%', $this->db->get_any_char(), $auth_options));
}
else
{
- $sql_opts = 'AND ' . $key . ' = ' . $this->db->sql_escape($auth_options);
+ $sql_opts = 'AND ' . $key . " = '" . $this->db->sql_escape($auth_options) . "'";
}
}
else
@@ -3162,7 +3161,7 @@ protected function kb_acl_user_raw_data($user_id = false, $opts = false, $catego
// Grab user settings - non-role specific...
$sql_ary[] = 'SELECT a.user_id, a.category_id, a.auth_setting, a.auth_option_id, ao.auth_option
FROM ' . $this->kb_users_table . ' a, ' . $this->options_table . ' ao
- WHERE a.auth_option_id = ao.auth_option_id ' . (($sql_user) ? 'AND a.' . $sql_user : '') .
+ WHERE a.auth_option_id = ao.auth_option_id ' . (($sql_user) ? 'AND a.' . $sql_user : '') . ' ' .
$sql_category . ' ' . $sql_opts . '
ORDER BY a.category_id, ao.auth_option';
diff --git a/docs/testing.md b/docs/testing.md
index 7105d8b..8a96bc4 100644
--- a/docs/testing.md
+++ b/docs/testing.md
@@ -1593,8 +1593,8 @@ dropdown, the copy-link boxes, the article text box, custom-field dropdowns, the
action menus, and in the ACP the category picker, the new-category box and the file-size unit menu.
Each is now labelled, and the Teams block of the ACP configuration page uses valid definition-list
markup. What remains is prosilver's own markup (header menus, footer links, the pagination list,
-plupload's hidden file input), the inactive tabs' contrast, which comes from prosilver's tab colours
-and differs per style, and small touch targets in the article lists and queue rows.
+plupload's hidden file input). The inactive tabs' contrast and the small touch targets in the
+article lists and queue rows were fixed afterwards (next section).
phpbb-release-tools gained a Knowledge Base feature check (`check_knowledgebase` in
`feature_checks.py`): the ACP adds a category and refuses its form without a token and a move link
@@ -1611,3 +1611,58 @@ the current development build's code (`313eac3`, 40 migrations) on SQLite and My
to 43 and passed the sweep and the feature check. The PHPUnit suite passes on phpBB 3.3.x and
master with the MySQL and PostgreSQL fixtures: 578 tests, none skipped. EPV and phpcs are clean on
both branches.
+
+## Tab contrast, touch targets, labels and forms without JavaScript - 11/12/2026
+
+Follow-ups from the maintenance pass:
+
+- The inactive tabs showed prosilver's grey-blue text (`#536482`) on its tab colour (`#BACCD9`),
+ 3.7:1 for small bold text that needs 4.5:1. They now use the board's dark text colour
+ (`#28313F`), about 8.8:1 on the same background. A style with dark tabs can change it in an
+ alternative stylesheet.
+- Article title links in the lists were 13 to 17px high, and the queue's buttons and menus 19 to 21px.
+ All are now at least 24px (WCAG 2.2, 2.5.8).
+- Three fields on the posting form (post as, also list in, Markdown) and the ACP field type menu had
+ two labels: the row's own `label for` and another wrapped around the field. The wrapping one is
+ gone.
+- The ACP introduction settings were hidden by the server and shown only by a script, so they could
+ not be reached without JavaScript. They are now in the page from the start, and the script hides
+ them while the introduction is off. The custom-field filter and the "drafts only" box submit on
+ change; they now get a Go button when JavaScript is off.
+- `.gitignore` is left out of release archives.
+
+`tests/browser/kb_form_access.js` checks each of these on a seeded board, as the administrator,
+with and without JavaScript:
+
+```bash
+KB_TEST_URL=http://localhost:8241 node /path/to/playwright-skill/run.js tests/browser/kb_form_access.js
+```
+
+On the code before the fix, 7 of its 9 checks fail; the two that pass check that the introduction
+settings still follow the switch with JavaScript on. Claude ran it and `kb_page_sweep.js` on fresh
+seed-forum SQLite boards for phpBB 3.3.19 and 4.0.0-a3-dev (9 of 9, and 26 of 26 pages, on both).
+A phpBB 3.3 board seeded by seed-forum turns clean URLs on, which PHP's built-in web server cannot
+route; turn `enable_mod_rewrite` off on such a board before running the browser checks through it.
+
+## The ACP permission trace - 11/12/2026
+
+A validation report from another phpBB developer's tool listed two missing language keys,
+`KB_TRACE_GROUP_NEVER_TOTAL_NEVER` and its `_LOCAL` form: the trace shows them when a group is set
+to NEVER and the total is already NEVER. They are added, worded as phpBB's own trace.
+
+Opening the trace page to test them showed that it could not open at all:
+
+- `build_auth_option_statement()` put a single permission name into SQL without quotes, unlike
+ phpBB's own version of the function. The name comes from the trace page's address, so a crafted
+ link opened by an administrator could run SQL of its choosing. The name is now a quoted string. Its
+ `LIKE` branch also lacked the space before `LIKE`.
+- The group and user queries joined `group_id IN (...)` and `user_id = N` to the next condition
+ without a space (`2AND`).
+
+`tests/kb_acp_auth_option_statement_test.php` runs the option filter against a SQLite table: a
+single name, a name with SQL in it, a `LIKE` pattern and a list. Three of its four tests fail on
+the code before the fix. `tests/browser/kb_page_sweep.js` now also opens the trace for the
+administrator in the first category, which fails with an SQL error on the old code. On the fixed code,
+with two of the administrator's groups set to NEVER, the trace shows every row in words. Claude ran
+the sweep (27 of 27) and `kb_form_access.js` (9 of 9) on the phpBB 3.3.19 and 4.0.0-a3-dev SQLite
+boards.
diff --git a/language/en/acp_knowledgebase.php b/language/en/acp_knowledgebase.php
index f69c1d1..d314ae1 100644
--- a/language/en/acp_knowledgebase.php
+++ b/language/en/acp_knowledgebase.php
@@ -225,6 +225,7 @@
'LOG_LIBRARY_PERMISSION_ADD' => 'Adding or changing user/group access to category %1s %2s',
'LOG_LIBRARY_CONFIG' => 'Reconfigured the Knowledge Base',
+'KB_TRACE_GROUP_NEVER_TOTAL_NEVER_LOCAL' => 'This group’s permission for this category is set to NEVER like the total result so the old result is kept.',
'KB_TRACE_GROUP_NEVER_TOTAL_NO_LOCAL' => 'This group’s permission for this category is set to NEVER which becomes the new total value because it wasn’t set yet (set to NO).',
'KB_TRACE_GROUP_NEVER_TOTAL_YES_LOCAL' => 'This group’s permission for this category is set to NEVER which overwrites the total YES to a NEVER for this user.',
'KB_TRACE_GROUP_NO_LOCAL' => 'The permission is NO for this group within this category so the old total value is kept.',
@@ -234,6 +235,7 @@
// Same as the _LOCAL versions above, but for the global (non-category) trace view -
// controller/acp_controller.php picks between the two sets based on whether a category_id is present.
+'KB_TRACE_GROUP_NEVER_TOTAL_NEVER' => 'This group’s permission is set to NEVER like the total result so the old result is kept.',
'KB_TRACE_GROUP_NEVER_TOTAL_NO' => 'This group’s permission is set to NEVER which becomes the new total value because it wasn’t set yet (set to NO).',
'KB_TRACE_GROUP_NEVER_TOTAL_YES' => 'This group’s permission is set to NEVER which overwrites the total YES to a NEVER for this user.',
'KB_TRACE_GROUP_NO' => 'The permission is NO for this group so the old total value is kept.',
diff --git a/styles/all/template/kb_cat_body.html b/styles/all/template/kb_cat_body.html
index d84c6dc..c10c5e6 100644
--- a/styles/all/template/kb_cat_body.html
+++ b/styles/all/template/kb_cat_body.html
@@ -71,6 +71,7 @@
{% INCLUDEJS '@phpbbmodders_knowledgebase/js/markdown_toggle.js' %}
diff --git a/styles/all/theme/phpbbmodders_knowledgebase.css b/styles/all/theme/phpbbmodders_knowledgebase.css
index 4fdb9c1..e85339c 100644
--- a/styles/all/theme/phpbbmodders_knowledgebase.css
+++ b/styles/all/theme/phpbbmodders_knowledgebase.css
@@ -291,6 +291,25 @@ table.kb-index-table .kb-index-author {
overflow-wrap: break-word;
}
+/*
+ * Touch targets at least 24px high (WCAG 2.2, 2.5.8): the article title links
+ * and the queue's buttons and menus sat closer together than that on a phone.
+ */
+table.kb-index-table td:first-child > a {
+ display: inline-block;
+ min-height: 24px;
+ line-height: 24px;
+}
+
+table.kb-index-table .button2,
+table.kb-index-table select {
+ display: inline-block;
+ box-sizing: border-box;
+ min-height: 24px;
+ min-width: 24px;
+ vertical-align: middle;
+}
+
/*
* Headings in article text. Markdown articles can use all six levels, which
* a forum post never does: prosilver paints an h1 white and large because it
@@ -366,6 +385,17 @@ table.kb-index-table .kb-index-author {
font-size: 0.95em;
}
+/*
+ * prosilver's grey-blue tab text is too faint for these small labels on its
+ * tab colour (3.7:1). The board's dark text colour keeps the style's tab
+ * background and passes WCAG AA (4.5:1). An alternative stylesheet can change
+ * it for a style with dark tabs.
+ */
+.tabs.kb-tabs .kb-tab:not(.kb-tab-active) > a,
+.tabs.kb-tabs .kb-tab:not(.kb-tab-active) > a:hover {
+ color: #28313f;
+}
+
.tabs.kb-tabs .kb-tab-active > a,
.tabs.kb-tabs .kb-tab-active > a:hover {
background: transparent;
diff --git a/tests/browser/kb_form_access.js b/tests/browser/kb_form_access.js
new file mode 100644
index 0000000..6c42fef
--- /dev/null
+++ b/tests/browser/kb_form_access.js
@@ -0,0 +1,146 @@
+// Check the Knowledge Base forms work for people without JavaScript and
+// carry one label per field, as the seeded administrator:
+// - the ACP introduction settings: with JavaScript, the text box shows only
+// while the introduction is ticked; without it, the text box is always there;
+// - the custom-field filter and the "drafts only" box have a Go button when
+// JavaScript is off (both normally submit on change);
+// - no field on the add-article form, nor the ACP field type menu, has more than one label;
+// - the inactive tabs' text passes WCAG AA contrast (4.5:1) on its background;
+// - article title links and the queue's buttons and menus are at least 24px
+// high on a phone.
+// Read-only: it submits nothing but the login forms.
+//
+// KB_TEST_URL is the board's base URL, without a trailing slash; routes go
+// through app.php, so the board needs no clean-URL rewriting.
+const {chromium} = require('playwright');
+
+const base = process.env.KB_TEST_URL;
+if (!/^http:\/\/(localhost|127\.0\.0\.1):\d+$/.test(base || '')) throw new Error('Set KB_TEST_URL to a disposable local board, for example http://localhost:8171');
+const password = 'KbTest1234!';
+const ready = {waitUntil: 'domcontentloaded'};
+const results = [];
+
+function record(label, ok, detail = '') {
+ results.push({label, ok, detail});
+}
+
+// phpBB refuses a form submitted within two seconds of it being made
+async function waitForFormAge(page) {
+ await page.waitForFunction(() => {
+ const field = document.querySelector('[name="creation_time"]');
+ return field && Date.now() / 1000 > Number(field.value) + 2;
+ });
+}
+
+// Log in to the board, then unlock the ACP, in a fresh browser context
+async function adminPage(browser, javaScriptEnabled) {
+ const context = await browser.newContext({javaScriptEnabled, viewport: {width: 1280, height: 900}});
+ const page = await context.newPage();
+ page.setDefaultTimeout(120000);
+ await page.goto(`${base}/ucp.php?mode=login`, ready);
+ await page.locator('#username').fill('admin');
+ await page.locator('#password').fill(password);
+ if (javaScriptEnabled) await waitForFormAge(page); else await page.waitForTimeout(2500);
+ await Promise.all([page.waitForNavigation(ready), page.locator('[name="login"][type="submit"]').click()]);
+ return page;
+}
+
+async function openAcp(page, path) {
+ const sid = (await page.context().cookies()).find(item => item.name.endsWith('_sid'));
+ await page.goto(`${base}/adm/index.php?${path}&sid=${sid ? sid.value : ''}`, ready);
+ if (await page.locator('input[type="password"]').count()) {
+ await page.locator('input[type="password"]').fill(password);
+ await page.waitForTimeout(2500);
+ await Promise.all([page.waitForNavigation(ready), page.locator('[name="login"][type="submit"]').click()]);
+ const now = (await page.context().cookies()).find(item => item.name.endsWith('_sid'));
+ await page.goto(`${base}/adm/index.php?${path}&sid=${now ? now.value : ''}`, ready);
+ }
+}
+
+// Fields that more than one