From a1ee90f327b8ea730a5e2855c1ce0a6ec447157e Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sat, 17 Oct 2026 15:55:18 -0400 Subject: [PATCH] Keep shared admin configuration forms within the viewport --- .github/workflows/ci.yml | 44 ++++++++++++++++++ docs/form-usability.md | 37 +++++++++++++++ internal/ui_dist/index.html | 37 +++++++++++---- test/form-usability-browser.cjs | 81 +++++++++++++++++++++++++++++++++ 4 files changed, 191 insertions(+), 8 deletions(-) create mode 100644 docs/form-usability.md create mode 100644 test/form-usability-browser.cjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 78adf7f..161286a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,11 +12,49 @@ env: GOPRIVATE: github.com/GoCodeAlone/* jobs: + forms: + name: Shared forms browser QA + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + - name: Verify exact tested source + env: + EXPECTED_TEST_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + run: test "$(git rev-parse HEAD)" = "$EXPECTED_TEST_SHA" + - uses: actions/setup-node@v4 + with: + node-version: '22' + - name: Install isolated browser tooling + run: | + npm install --prefix "$RUNNER_TEMP/admin-ui" --no-audit --no-fund --save-exact playwright@1.61.1 + "$RUNNER_TEMP/admin-ui/node_modules/.bin/playwright" install --with-deps chromium + - name: Exercise actual configuration and login forms + env: + CMS_PLAYWRIGHT_MODULE: ${{ runner.temp }}/admin-ui/package.json + CMS_UI_ARTIFACTS: ${{ runner.temp }}/admin-form-results + run: node test/form-usability-browser.cjs + - uses: actions/upload-artifact@v4 + if: always() + with: + name: admin-form-browser-results + path: ${{ runner.temp }}/admin-form-results + retention-days: 7 lint: name: Lint runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + - name: Verify exact tested source + env: + EXPECTED_TEST_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + run: test "$(git rev-parse HEAD)" = "$EXPECTED_TEST_SHA" - uses: actions/setup-node@v4 with: @@ -63,6 +101,12 @@ jobs: goarch: arm64 steps: - uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + - name: Verify exact tested source + env: + EXPECTED_TEST_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + run: test "$(git rev-parse HEAD)" = "$EXPECTED_TEST_SHA" - uses: actions/setup-go@v5 with: diff --git a/docs/form-usability.md b/docs/form-usability.md new file mode 100644 index 0000000..3bf11cb --- /dev/null +++ b/docs/form-usability.md @@ -0,0 +1,37 @@ +# Shared configuration and login form usability + +The configuration grid's260px minimum and unbounded label/select/error intrinsic +widths caused viewport overflow. Real browser baseline at320px measured1480px +document width with a long label and4196px after an unbroken server error. +Tracks now shrink to available width, controls can shrink, and help/results wrap. +Normal and error states both measure320px after the repair. Desktop1440px, +tablet768px and375px also fit. Login required/email validation remains native. + +Controls have visible borders and focus,44px text/action targets, and16px mobile +input text. Descriptions are associated with each control; configuration results +and sign-in errors are announced. Session/bearer behavior, endpoint resolution, +validation snapshots, secret clearing and disabled-state restoration are unchanged. + +Run with an existing Playwright installation and browser: + +```sh +CMS_PLAYWRIGHT_MODULE=/path/to/playwright/package.json \ + CMS_BROWSER_CHANNEL=msedge CMS_UI_ARTIFACTS=/tmp/admin-forms \ + node test/form-usability-browser.cjs +node --test test/*.test.cjs +``` + +The browser harness serves the actual shell with synthetic loopback configuration +APIs. Session mode is injected only into its fixture HTML; no deployed settings, +credentials, mail or hosted owner session are accessed. It checks1440/768/375/320px +geometry, long selected options/labels/help/errors, keyboard/native validation, +programmatic descriptions, synthetic validate/apply, disabled state, secret +clearing and no bearer emission in session mode. `--measure-only` can record +baseline screenshots and measurements without requiring repaired geometry. + +CI runs the browser harness and existing checks against the exact PR head and +retains browser artifacts for7days. Package release, host dependency adoption, +review deployment and actual hosted full-app acceptance are subsequent steps; +this source patch performs none of them. Separate contributed tools such as +identity, authorization and onboarding have their own form implementations and +are not represented as covered by this generic shell fixture. diff --git a/internal/ui_dist/index.html b/internal/ui_dist/index.html index d2e768e..d4dc1fe 100644 --- a/internal/ui_dist/index.html +++ b/internal/ui_dist/index.html @@ -12,11 +12,16 @@ --ink: #18202f; --muted: #647086; --line: #d9dee8; + --control-border: #768697; --accent: #0f766e; --accent-ink: #ffffff; --warn: #9a3412; } * { box-sizing: border-box; } + button:focus-visible, a:focus-visible, input:focus-visible, select:focus-visible { + outline: 3px solid #2563b8; + outline-offset: 3px; + } body { margin: 0; min-height: 100vh; @@ -181,6 +186,7 @@ gap: 16px; } .config-group { + min-width: 0; border: 1px solid var(--line); border-radius: 8px; padding: 14px; @@ -197,12 +203,14 @@ } .config-controls { display: grid; - grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + grid-template-columns: repeat(auto-fit, minmax(min(260px, 100%), 1fr)); gap: 12px; } .config-control { display: grid; + min-width: 0; gap: 6px; + overflow-wrap: anywhere; } .config-control label { color: var(--ink); @@ -216,8 +224,9 @@ .config-control input, .config-control select { width: 100%; - min-height: 38px; - border: 1px solid var(--line); + min-width: 0; + min-height: 44px; + border: 1px solid var(--control-border); border-radius: 6px; padding: 8px 10px; color: var(--ink); @@ -230,11 +239,14 @@ justify-self: start; } .config-result { + min-width: 0; + max-width: 100%; border: 1px solid var(--line); border-radius: 8px; padding: 12px; background: #f8fafc; white-space: pre-wrap; + overflow-wrap: anywhere; font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; color: var(--muted); } @@ -254,8 +266,9 @@ } .field input { width: 100%; - min-height: 40px; - border: 1px solid var(--line); + min-width: 0; + min-height: 44px; + border: 1px solid var(--control-border); border-radius: 6px; padding: 8px 10px; color: var(--ink); @@ -264,7 +277,7 @@ } .primary-button, .secondary-button { - min-height: 36px; + min-height: 44px; border-radius: 6px; padding: 8px 12px; font: inherit; @@ -292,6 +305,7 @@ aside { position: static; } .grid { grid-template-columns: 1fr; } header { align-items: flex-start; flex-direction: column; } + .field input, .config-control input, .config-control select { font-size: 16px; } } @@ -328,7 +342,7 @@

Sign In

- +
@@ -699,7 +713,12 @@

Management pages

wrapper.appendChild(input); const help = document.createElement('small'); - help.appendChild(document.createTextNode(control.disabled_reason || control.help_text || control.description || '')); + const helpText = control.disabled_reason || control.help_text || control.description || ''; + help.appendChild(document.createTextNode(helpText)); + if (helpText) { + help.id = `${id}-help`; + input.setAttribute('aria-describedby', help.id); + } wrapper.appendChild(help); controls.appendChild(wrapper); }); @@ -762,6 +781,8 @@

Management pages

const result = document.createElement('pre'); result.className = 'config-result'; + result.setAttribute('role', 'status'); + result.setAttribute('aria-live', 'polite'); result.textContent = 'Loaded from plugin configuration contract.'; form.appendChild(result); diff --git a/test/form-usability-browser.cjs b/test/form-usability-browser.cjs new file mode 100644 index 0000000..ef6d675 --- /dev/null +++ b/test/form-usability-browser.cjs @@ -0,0 +1,81 @@ +// Isolated real admin shell with synthetic configuration APIs. Reuse Playwright. +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const http = require('node:http'); +const path = require('node:path'); +const { createRequire } = require('node:module'); +async function main() { + if (!process.env.CMS_PLAYWRIGHT_MODULE) throw new Error('CMS_PLAYWRIGHT_MODULE must point to existing Playwright'); + const { chromium } = createRequire(path.resolve(process.env.CMS_PLAYWRIGHT_MODULE))('playwright'); + const html = fs.readFileSync(process.env.ADMIN_UI_HTML || path.resolve(__dirname, '../internal/ui_dist/index.html'), 'utf8'); + const artifacts = process.env.CMS_UI_ARTIFACTS, measureOnly = process.argv.includes('--measure-only'); + if (artifacts) fs.mkdirSync(artifacts, { recursive:true }); + const calls = [], measurements = [], external = [], errors = []; + let failValidation = false; + const surface = { id:'fixture', title:'Fixture configuration', path:'/api/admin/fixture', render_mode:'config-form', metadata:{ describe_path:'/api/admin/fixture', validate_path:'/api/admin/fixture/validate', apply_path:'/api/admin/fixture/apply' } }; + const description = { effective_config:{ name:'Fixture tenant', kind:'review', locked:'Disabled value', enabled:true }, groups:[{ key:'general', label:'Shared configuration', description:'Synthetic fixture, no real services or settings.', controls:[ + { key:'name', label:'Site label', help_text:'A visible editorial label.' }, + { key:'kind', label:'Destination', options:[{ value:'review', label:'A long review destination label '.repeat(4) }] }, + { key:'enabled', label:'Enabled', input_type:'toggle' }, + { key:'secret', label:'Provider secret', input_type:'secret', secret:true, help_text:'Leave blank to keep the configured secret.' }, + { key:'locked', label:'Disabled setting', enabled:false, disabled_reason:'Only an authorized owner can change this value.' }, + { key:'long', label:'LongLabel'.repeat(12), help_text:'LongHelp'.repeat(24) }, + ] }] }; + const server = http.createServer(async (req,res) => { + const chunks=[]; for await (const c of req) chunks.push(c); + const body = chunks.length ? JSON.parse(Buffer.concat(chunks)) : null; + calls.push({ method:req.method, path:req.url, body, authorization:!!req.headers.authorization }); + res.setHeader('Content-Type', 'application/json'); + if (req.url === '/api/admin/contributions') return res.end(JSON.stringify({ contributions:[surface] })); + if (req.url === surface.path) return res.end(JSON.stringify(description)); + if (req.url.endsWith('/validate')) { + if (failValidation) { res.statusCode=422; return res.end(JSON.stringify({ error:'LongServerError'.repeat(30) })); } + return res.end(JSON.stringify({ valid:true })); + } + if (req.url.endsWith('/apply')) return res.end(JSON.stringify({ applied:true })); + if (req.url === '/api/admin/auth/login') { res.statusCode=401; return res.end('{}'); } + res.setHeader('Content-Type','text/html'); + return res.end(req.url === '/login-fixture' ? html : html.replace('data-admin-shell-version="1"', 'data-admin-shell-version="1" data-auth-mode="session"')); + }); + await new Promise(resolve => server.listen(0,'127.0.0.1',resolve)); + const origin = 'http://127.0.0.1:' + server.address().port; + const browser = await chromium.launch({ headless:true, ...(process.env.CMS_BROWSER_CHANNEL ? { channel:process.env.CMS_BROWSER_CHANNEL } : {}) }); + try { + const context = await browser.newContext({ viewport:{ width:1440,height:1100 }, serviceWorkers:'block' }); + await context.route('**/*',route => { if(new URL(route.request().url()).origin !== origin) { external.push(route.request().url()); return route.abort(); } return route.continue(); }); + const page = await context.newPage(); page.setDefaultTimeout(10000); page.on('pageerror',e=>errors.push(e.message)); + async function open() { await page.goto(origin); await page.getByRole('button',{name:'Fixture configuration',exact:true}).click(); await page.locator('.config-form').waitFor(); } + async function capture(label) { + const measurement=await page.evaluate(()=>({viewport:innerWidth,document:document.documentElement.scrollWidth,controls:[...document.querySelectorAll('.panel.active input,.panel.active select,.panel.active button')].map(el=>{const r=el.getBoundingClientRect();return{id:el.id,width:r.width,right:r.right,x:r.x}}),groups:[...document.querySelectorAll('.config-controls')].map(el=>({width:el.clientWidth,scroll:el.scrollWidth}))})); + measurements.push({label,...measurement}); + if(!measureOnly) { assert.ok(measurement.document <= measurement.viewport+1,label+' document fits');for(const c of measurement.controls)assert.ok(c.x>=0&&c.right<=measurement.viewport+1,label+' control fits '+c.id);for(const g of measurement.groups)assert.ok(g.scroll<=g.width+1,label+' group fits'); } + if(artifacts)await page.screenshot({path:path.join(artifacts,label+'.png'),fullPage:true}); + } + for(const width of [1440,768,375,320]) { + await page.setViewportSize({width,height:1100});await open();await capture('config-'+width); + failValidation=true;await page.getByRole('button',{name:'Validate changes',exact:true}).click();await page.locator('.config-result').filter({hasText:'LongServerError'}).waitFor();await capture('config-error-'+width);failValidation=false; + await page.goto(origin+'/login-fixture');await page.locator('#login-form').waitFor();await capture('login-'+width); + } + if(!measureOnly) { + await open(); + for(const input of await page.locator('.config-control input,.config-control select').all()) { + const association=await input.evaluate(el=>({name:el.labels?.[0]?.textContent,help:el.getAttribute('aria-describedby'),description:document.getElementById(el.getAttribute('aria-describedby'))?.textContent})); + assert.ok(association.name);if(await input.getAttribute('title'))assert.ok(association.help&&association.description); + } + await page.getByLabel('Site label',{exact:true}).fill('Edited local label');await page.getByLabel('Site label',{exact:true}).press('Tab'); + await page.getByLabel('Provider secret',{exact:true}).fill('fixture-only-secret');await page.getByLabel('Provider secret',{exact:true}).press('Tab'); + await page.getByRole('button',{name:'Validate changes',exact:true}).click();await page.locator('.config-result').filter({hasText:'"valid": true'}).waitFor(); + await page.getByRole('button',{name:'Apply changes',exact:true}).click();await page.locator('.config-result').filter({hasText:'"applied": true'}).waitFor(); + assert.equal(await page.getByLabel('Provider secret',{exact:true}).inputValue(),'');assert.equal(await page.getByLabel('Disabled setting',{exact:true}).isDisabled(),true); + const applied=calls.findLast(c=>c.path.endsWith('/apply'));assert.equal(applied.body.desired_config.name,'Edited local label');assert.equal(applied.body.desired_config.secret,'fixture-only-secret'); + assert.equal(calls.some(c=>c.authorization),false,'session fixture never emits bearer credentials'); + await page.goto(origin+'/login-fixture');await page.getByRole('button',{name:'Sign in',exact:true}).click();assert.equal(await page.locator('#admin-email').evaluate(el=>document.activeElement===el),true); + const n=calls.filter(c=>c.path.endsWith('/auth/login')).length;await page.getByLabel('Email',{exact:true}).fill('invalid');await page.getByLabel('Password',{exact:true}).fill('fixture-only');await page.getByRole('button',{name:'Sign in',exact:true}).click();assert.equal(calls.filter(c=>c.path.endsWith('/auth/login')).length,n); + await page.getByLabel('Email',{exact:true}).fill('fixture@example.test');await page.getByRole('button',{name:'Sign in',exact:true}).click();await page.locator('#login-error').filter({hasText:'Sign-in failed.'}).waitFor(); + } + assert.deepEqual(external,[]);assert.deepEqual(errors,[]); + const result={pass:true,measureOnly,browser:browser.version(),boundary:'actual upstream shell; synthetic loopback APIs; session-mode injection confined to fixture; no live auth/config writes',measurements,checks:['configuration and login desktop/tablet/375px/320px geometry','long labels/options/help/server errors',...(measureOnly?[]:['accessible control descriptions','synthetic validate/apply and secret clearing','disabled state retained','session bearer isolation','native required/email validation and login error'])]}; + if(artifacts)fs.writeFileSync(path.join(artifacts,'results.json'),JSON.stringify(result,null,2)+'\n');console.log(JSON.stringify(result)); + } finally {await browser.close();await new Promise(resolve=>server.close(resolve));} +} +main().catch(e=>{console.error(e);process.exitCode=1});