From 932b98b6d3434986175e120c4369bc98c982cc3b Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sat, 17 Oct 2026 15:55:14 -0400 Subject: [PATCH 1/2] Make shared CMS forms usable across editor widths --- .github/workflows/ci.yml | 30 +++ README.md | 13 ++ adminui/static/admin.css | 35 +++- adminui/static/admin.js | 12 +- adminui/static/index.html | 9 +- adminui/static/platform.html | 6 +- adminui/testdata/form-usability.cjs | 189 ++++++++++++++++++ .../2026-10-17-shared-admin-form-usability.md | 123 ++++++++++++ 8 files changed, 399 insertions(+), 18 deletions(-) create mode 100644 adminui/testdata/form-usability.cjs create mode 100644 docs/plans/2026-10-17-shared-admin-form-usability.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c5b19a1..101c279 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,6 +9,36 @@ permissions: contents: read jobs: + forms: + name: Shared forms browser QA + runs-on: ubuntu-latest + timeout-minutes: 10 + 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/cms-ui" --no-audit --no-fund --save-exact playwright@1.61.1 + "$RUNNER_TEMP/cms-ui/node_modules/.bin/playwright" install --with-deps chromium + - name: Exercise actual editor and platform forms + env: + CMS_PLAYWRIGHT_MODULE: ${{ runner.temp }}/cms-ui/package.json + CMS_UI_ARTIFACTS: ${{ runner.temp }}/cms-form-results + run: node adminui/testdata/form-usability.cjs + - uses: actions/upload-artifact@v4 + if: always() + with: + name: cms-form-browser-results + path: ${{ runner.temp }}/cms-form-results + retention-days: 7 test: runs-on: ubuntu-latest services: diff --git a/README.md b/README.md index ed1cb2e..b0661da 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,19 @@ The module path must resolve an installed Playwright package; optionally set and a test cookie auth hook. It does not exercise production authorization or PostgreSQL restart durability. +The shared form layout/interaction regression needs no Go build or live service: + +```sh +CMS_PLAYWRIGHT_MODULE=/path/to/playwright/package.json \ + CMS_UI_ARTIFACTS=/tmp/cms-forms node adminui/testdata/form-usability.cjs +``` + +It drives the actual editor/platform UI at desktop, tablet and narrow mobile +widths against synthetic loopback APIs, including raw source resize/preservation, +draft/save/reload/conflict behavior, required fields, dates and site creation. +Optionally set `CMS_BROWSER_CHANNEL=msedge` to reuse an installed browser. +Hosted CI runs this harness on the exact source head and retains screenshots. + > ⚠️ **Experimental** — This plugin compiles and passes its unit tests but has not been validated in any active GoCodeAlone-internal production deployment. Use with caution. Please [open an issue](https://github.com/GoCodeAlone/workflow-plugin-cms/issues/new) if you adopt it so we can promote it to **verified** status. [![License](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) diff --git a/adminui/static/admin.css b/adminui/static/admin.css index ef0fe02..59168a5 100644 --- a/adminui/static/admin.css +++ b/adminui/static/admin.css @@ -1,5 +1,8 @@ :root { --control-border: #768697; } * { box-sizing: border-box; } +button { min-height:44px; padding:.6rem .9rem; border:1px solid var(--control-border); border-radius:4px; background:white; color:#192638; font:inherit; cursor:pointer; } +button:disabled { cursor:default; } +input,select,textarea { min-width:0; } body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; margin: 0; @@ -128,7 +131,7 @@ form > button:hover { background: #155bb5; } border-color: #1a73e8; } -button.danger { background: #dc3545; } +button.danger { background: #b42332; color:white; border-color:#b42332; } button.danger:hover { background: #b02a37; } button.link { @@ -149,10 +152,20 @@ button.link { color: white; border-radius: 4px; box-shadow: 0 2px 8px rgba(0,0,0,0.2); - max-width: 24rem; -} -#toast.error { background: #dc3545; } + max-width: min(32rem, calc(100% - 2rem)); + max-height:calc(100vh - 2rem); + overflow:auto; + display:flex; + align-items:flex-start; + gap:1rem; + overflow-wrap:anywhere; + z-index:10; +} +#toast.error { background: #b42332; } #toast.ok { background: #198754; } +#toast > span { min-width:0; } +#toast button { flex:none; color:inherit; background:transparent; border-color:currentColor; padding:.35rem .55rem; } +#toast button:focus-visible { outline-color:white; } details { margin-top: 1rem; } details summary { cursor: pointer; padding: 0.5rem 0; } @@ -164,9 +177,10 @@ details summary { cursor: pointer; padding: 0.5rem 0; } background: white; } -[data-event-tool] { display:grid; gap:.6rem; padding:1rem; border:1px solid var(--control-border); } +[data-event-tool] { display:grid; min-width:0; gap:.6rem; padding:1rem; border:1px solid var(--control-border); } [data-event-tool][hidden] { display:none; } -[data-event-tool] label { display:grid; gap:.2rem; } +[data-event-tool] label { display:grid; grid-template-columns:minmax(0,1fr); gap:.4rem; } +[data-event-tool] label > input,[data-event-tool] label > select { grid-column:auto; min-height:44px; } [data-event-tool] input { font:inherit; padding:.5rem; width:100%; } button:focus-visible, a:focus-visible { outline:3px solid #154f9d; outline-offset:3px; } @media(max-width:640px) { body{padding:.6rem} section{padding:.8rem} form{display:block} form label{display:block;margin:.8rem 0} form .form-row{display:block} table{font-size:.85rem} th,td{padding:.4rem} header{gap:.5rem;flex-wrap:wrap} } @@ -187,19 +201,22 @@ h1 { font-size:1.6rem; } h2 { margin-top:0; font-size:1.45rem; } h3 { margin-top #page-tabs a,.page-tabs a { padding:.65rem .85rem; border-radius:6px; text-decoration:none; color:#3c526d; font-weight:550; } #page-tabs a[aria-current],.page-tabs a[aria-current] { background:#e7eefb; color:#174985; } #page-editor-form { display:block; margin:0; } -.settings-pane { max-width:640px; display:block; } +.settings-pane { width:100%; max-width:640px; display:block; } .settings-pane label { display:block; font-weight:550; margin:1rem 0; } -.settings-pane label input,.settings-pane label select { display:block; margin-top:.4rem; padding:.65rem; } +.settings-pane label input,.settings-pane label select { display:block; margin-top:.4rem; padding:.65rem; min-height:44px; } +.field-help { font-size:.875rem; font-weight:400; color:#4c6076; line-height:1.5; margin:.5rem 0 1rem; } .content-layout { display:grid; grid-template-columns:210px minmax(0,1fr); gap:1.5rem; } #section-tabs { display:flex; flex-direction:column; gap:.25rem; border-right:1px solid #dce2ea; padding-right:.8rem; } #section-tabs a { padding:.65rem .75rem; border-radius:6px; text-decoration:none; line-height:1.4; overflow-wrap:anywhere; } #section-tabs a[aria-current] { background:#e7eefb; color:#174985; font-weight:600; } .content-editor { min-width:0; }.content-editor .form-row { display:block; }.content-editor .rich-editor { display:grid; } +#section-tabs[hidden] + .content-editor { grid-column:1 / -1; } .editor-surface { min-height:20rem; border-color:var(--control-border); padding:1rem; line-height:1.6; } .editor-surface section { padding:0; box-shadow:none; border:0; margin:0; background:none; } .editor-surface img { max-height:330px; object-fit:contain; } -[data-editor-source] { width:100%; min-height:24rem; font:13px/1.55 ui-monospace,monospace; padding:1rem; border:1px solid var(--control-border); border-radius:6px; } +[data-editor-source] { width:100%; max-width:100%; min-height:24rem; height:clamp(24rem,60vh,48rem); resize:vertical; overflow:auto; font:14px/1.55 ui-monospace,monospace; padding:1rem; border:1px solid var(--control-border); border-radius:6px; } [data-source-notice] { font-size:.875rem; color:#664300; background:#fff5db; padding:.8rem; border-radius:6px; } .table-wrap { overflow-x:auto; } th,td { padding:.8rem; }.sr-only { position:absolute; width:1px; height:1px; overflow:hidden; clip:rect(0,0,0,0); } #page-preview { width:100%; min-height:650px; border:1px solid #dce2ea; margin-top:1rem; background:white; } @media(max-width:700px) { body { padding:1rem; }.view { padding:1rem; }.editor-heading,.view-heading { align-items:flex-start; flex-direction:column; }.content-layout { grid-template-columns:1fr; }#section-tabs { flex-direction:row; flex-wrap:wrap; border-right:0; border-bottom:1px solid #dce2ea; padding:0 0 .8rem; }#section-tabs a { font-size:.85rem; }.editor-surface { min-height:16rem; }#page-preview { min-height:550px; } } +@media(max-width:700px) { input,select,textarea,[data-editor-source] { font-size:16px; } } diff --git a/adminui/static/admin.js b/adminui/static/admin.js index 7499f7f..da25028 100644 --- a/adminui/static/admin.js +++ b/adminui/static/admin.js @@ -70,7 +70,15 @@ function pagePayload(form) { } function dateTimeLocalToISO(value){if(!value)return "";const d=new Date(value);return Number.isNaN(d.getTime())?"":d.toISOString()} function isoToDateTimeLocal(value){if(!value)return "";const d=new Date(value);if(Number.isNaN(d.getTime()))return "";const pad=n=>String(n).padStart(2,"0");return d.getFullYear()+"-"+pad(d.getMonth()+1)+"-"+pad(d.getDate())+"T"+pad(d.getHours())+":"+pad(d.getMinutes())} -function toast(msg,kind){const t=$("#toast");t.textContent=msg;t.className=kind||"";t.hidden=false;setTimeout(()=>{t.hidden=true},3500)} +let toastTimer; +function toast(msg,kind){ + const t=$("#toast"),previousFocus=document.activeElement;clearTimeout(toastTimer); + const message=document.createElement("span");message.textContent=msg; + const dismiss=document.createElement("button");dismiss.type="button";dismiss.textContent="Dismiss";dismiss.setAttribute("aria-label","Dismiss notification"); + dismiss.onclick=()=>{const focused=t.contains(document.activeElement);t.hidden=true;clearTimeout(toastTimer);if(focused&&previousFocus?.isConnected)previousFocus.focus()}; + t.replaceChildren(message,dismiss);t.className=kind||"";t.hidden=false; + if(kind!=="error")toastTimer=setTimeout(()=>{if(!t.contains(document.activeElement))t.hidden=true},5000); +} function fail(err){console.error(err);toast(err.message,"error")} function escapeHTML(s){return String(s||"").replace(/&/g,"&").replace(//g,">").replace(/"/g,""").replace(/'/g,"'")} function canonicalID(s){return /^[1-9][0-9]*$/.test(s)&&Number.isSafeInteger(Number(s))?Number(s):0} @@ -151,7 +159,7 @@ async function showPagePane(route,generation){ $$("[data-page-pane]",form).forEach(el=>el.hidden=el.dataset.pagePane!==(route.view==="html"?"content":route.view)); if(route.view==="content"||route.view==="html"){ const isHTML=route.view==="html",advanced=editor.dataset.advanced==="true"; - source.hidden=!isHTML;surface.hidden=isHTML;$(".editor-toolbar",editor).hidden=isHTML||advanced||!(route.pid==="new"?pagePermissions.create:pagePermissions.edit); + source.hidden=!isHTML;$(":scope > #page-source-help",editor).hidden=!isHTML;surface.hidden=isHTML;$(".editor-toolbar",editor).hidden=isHTML||advanced||!(route.pid==="new"?pagePermissions.create:pagePermissions.edit); surface.contentEditable=String(!advanced&&(route.pid==="new"?pagePermissions.create:pagePermissions.edit)); const sections=$("#section-tabs");sections.hidden=isHTML;sections.replaceChildren(); const children=[...surface.children],plain=[...surface.childNodes].some(n=>n.nodeType===Node.TEXT_NODE&&n.textContent.trim()); diff --git a/adminui/static/index.html b/adminui/static/index.html index 46bee4b..eb7af73 100644 --- a/adminui/static/index.html +++ b/adminui/static/index.html @@ -9,10 +9,10 @@
- +

+ diff --git a/adminui/static/platform.html b/adminui/static/platform.html index e626b2a..7d3c95b 100644 --- a/adminui/static/platform.html +++ b/adminui/static/platform.html @@ -2,6 +2,6 @@ Multisite administration

Platform workspace

Multisite administration

Tenants

Manage site registration and domains. Page content is edited in the separate Site editor.

TenantSlugTheme
- - -
\ No newline at end of file + + + diff --git a/adminui/testdata/form-usability.cjs b/adminui/testdata/form-usability.cjs new file mode 100644 index 0000000..b1a32a4 --- /dev/null +++ b/adminui/testdata/form-usability.cjs @@ -0,0 +1,189 @@ +// Real embedded UI, synthetic loopback API only. No Go build or live credentials. +// CMS_PLAYWRIGHT_MODULE=/existing/package.json CMS_BROWSER_CHANNEL=msedge \ +// CMS_UI_ARTIFACTS=/tmp/cms-ui node adminui/testdata/form-usability.cjs +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 is required; reuse an installed Playwright'); + const { chromium } = createRequire(path.resolve(process.env.CMS_PLAYWRIGHT_MODULE))('playwright'); + const staticDir = process.env.CMS_UI_STATIC_DIR || path.resolve(__dirname, '../static'); + const artifacts = process.env.CMS_UI_ARTIFACTS; + const measureOnly = process.argv.includes('--measure-only'); + if (artifacts) fs.mkdirSync(artifacts, { recursive: true }); + const originalBody = "

Voice & music

Source 'quotes' and spacing.

\n

Calendar

"; + const original = { ID: 2, Version: 7, Title: 'A long page title for practical editorial work', Path: '/voice', Status: 'published', TemplateID: 'studio', PublishAt: '2026-11-02T15:00:00Z', UnpublishAt: null, BodyHTML: originalBody, BodyBlocks: { type: 'retained-fixture' } }; + let stored = structuredClone(original), readOnly = false, conflict = false, failPlatform = false; + const requests = [], errors = [], external = [], measurements = []; + const tenants = [{ ID: 1, Slug: 'fixture', Label: 'Fixture site with a long editorial label', ThemeID: 'default' }]; + let domains = [{ ID: 3, Host: 'long-review-hostname.example.test', SubsiteLabel: 'Review', Kind: 'preview' }]; + const server = http.createServer(async (req, res) => { + try { + const url = new URL(req.url, 'http://127.0.0.1'); + const json = (code, body) => { res.writeHead(code, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(body)); }; + if (url.pathname.startsWith('/api/')) { + const chunks = []; for await (const chunk of req) chunks.push(chunk); + const body = chunks.length ? JSON.parse(Buffer.concat(chunks)) : null; + requests.push({ method: req.method, path: url.pathname, body }); + if (req.method === 'GET' && url.pathname.endsWith('/permissions')) return json(200, { create: !readOnly, edit: !readOnly, delete: !readOnly }); + if (req.method === 'GET' && url.pathname.endsWith('/templates')) return json(200, { templates: ['studio', 'classical', 'metal'] }); + if (url.pathname === '/api/v1/admin/tenants') { + if (req.method === 'GET') return json(200, { tenants }); + if (failPlatform) return json(422, { error: 'The tenant identifier is already in use. Choose another identifier and try again.' }); + const tenant = { ID: 9, Slug: body.slug, Label: body.label, ThemeID: body.theme_id }; tenants.push(tenant); return json(201, tenant); + } + if (url.pathname.endsWith('/domains')) { + if (req.method === 'GET') return json(200, { domains }); + if (failPlatform) return json(422, { error: 'This hostname is already registered to another fixture site.' }); + const domain = { ID: 4, Host: body.host, SubsiteLabel: body.subsite_label, Kind: body.kind }; domains.push(domain); return json(201, domain); + } + if (/\/domains\/\d+$/.test(url.pathname) && req.method === 'DELETE') { domains = domains.filter(d => d.ID !== Number(url.pathname.split('/').pop())); res.writeHead(204); return res.end(); } + if (url.pathname.endsWith('/preview')) { res.writeHead(200, { 'Content-Type': 'text/html' }); return res.end('Fixture preview' + body.body_html); } + if (url.pathname === '/api/v1/admin/reload') return json(200, { ok: true }); + if (url.pathname === '/api/v1/admin/tenants/1/pages' && req.method === 'GET') return json(200, { pages: [stored] }); + if (req.method === 'POST' && url.pathname.endsWith('/pages')) { + stored = { ID: 2, Version: 1, ...Object.fromEntries(Object.entries(body).map(([k,v]) => [({title:'Title',path:'Path',status:'Status',template_id:'TemplateID',body_html:'BodyHTML',body_blocks:'BodyBlocks',publish_at:'PublishAt',unpublish_at:'UnpublishAt'})[k],v])) }; return json(201, stored); + } + if (url.pathname === '/api/v1/admin/tenants/1/pages/2') { + if (req.method === 'GET') return json(200, stored); + if (req.method === 'PUT') { + if (conflict || body.expected_version !== stored.Version) return json(409, { error: 'version_conflict' }); + stored = { ...stored, Version: stored.Version + 1, Title: body.title, Path: body.path, Status: body.status, TemplateID: body.template_id, BodyHTML: body.body_html, BodyBlocks: body.body_blocks, PublishAt: body.publish_at, UnpublishAt: body.unpublish_at }; + return json(200, stored); + } + } + return json(404, { error: 'Unknown fixture API' }); + } + if (url.pathname === '/embedded') { + res.writeHead(200, { 'Content-Type': 'text/html' }); + return res.end('Embedded fixture'); + } + const base = url.pathname.startsWith('/platform') ? '/platform' : '/editor'; + const name = path.basename(url.pathname); + const asset = ['admin.css', 'admin.js', 'purify.min.js'].includes(name) ? name : base === '/platform' ? 'platform.html' : 'index.html'; + res.writeHead(200, { 'Content-Type': asset.endsWith('.css') ? 'text/css' : asset.endsWith('.js') ? 'text/javascript' : 'text/html' }); + res.end(fs.readFileSync(path.join(staticDir, asset), 'utf8').replaceAll('{{.BasePath}}', base)); + } catch (error) { res.writeHead(500); res.end(error.message); } + }); + 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 } : {}) }); + const checks = []; + try { + const context = await browser.newContext({ viewport: { width: 1440, height: 1000 }, timezoneId: 'America/New_York', 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)); + page.on('dialog', d => d.type() === 'beforeunload' ? d.accept() : d.dismiss()); + const go = async route => { await page.goto(origin + route); await page.locator('main > .view:not([hidden]) h2').first().waitFor(); await page.waitForFunction(() => document.querySelector('#route-error').hidden); }; + const pane = async name => { await page.locator('#page-tabs').getByRole('link', { name, exact: true }).click(); await page.locator(`[data-page-pane="${name === 'HTML' ? 'content' : name.toLowerCase()}"]`).waitFor(); }; + const save = async status => { + const response = page.waitForResponse(r => r.url().endsWith('/pages/2') && r.request().method() === 'PUT'); + await page.getByRole('button', { name: 'Save page', exact: true }).click(); + assert.equal((await response).status(), status); + await page.waitForFunction(() => !document.querySelector('#btn-save-page').disabled); + }; + const capture = async (label, root = page) => { + const m = await root.evaluate(() => { + const rect = el => { const r = el.getBoundingClientRect(); return { x:r.x, y:r.y, width:r.width, height:r.height, right:r.right }; }; + const source = document.querySelector('[data-editor-source]'); + const visible = el => !!(el.offsetWidth || el.offsetHeight || el.getClientRects().length); + return { viewport: innerWidth, document: document.documentElement.scrollWidth, source: source && visible(source) ? rect(source) : null, content: source && visible(source) ? rect(document.querySelector('.content-layout')) : null, controls: [...document.querySelectorAll('input:not([type=hidden]),select,textarea,button')].filter(visible).map(el => ({ name:el.name || el.dataset.eventField || el.textContent.trim(), localScroll: !!el.closest('.table-wrap'), ...rect(el) })) }; + }); + m.label = label; measurements.push(m); + if (!measureOnly) { + assert.ok(m.document <= m.viewport + 1, label + ' has no document overflow: ' + JSON.stringify(m)); + if (m.source) { assert.ok(m.source.width >= m.content.width - 2, label + ' source fills available content'); assert.ok(m.source.height >= 320, label + ' source has working height'); } + for (const control of m.controls.filter(c => !c.localScroll)) assert.ok(control.right <= m.viewport + 1 && control.x >= 0, label + ' control fits: ' + control.name); + } + if (artifacts && root === page) await page.screenshot({ path: path.join(artifacts, label + '.png'), fullPage: true }); + return m; + }; + for (const width of [1440, 768, 375, 320]) { + await page.setViewportSize({ width, height: 1000 }); + await go('/editor/tenants/1/pages/2/html'); await capture('html-' + width); + await pane('Publishing'); await capture('publishing-' + width); + await pane('Appearance'); await capture('appearance-' + width); + await pane('Content'); await page.getByRole('button', { name: 'Event', exact: true }).click(); await capture('event-' + width); + await go('/platform/tenants/new'); await capture('tenant-' + width); + await go('/platform/tenants/1/domains'); await capture('domain-' + width); + await go('/platform/cache'); await capture('cache-' + width); + await page.goto(origin + '/embedded'); const frame = page.frameLocator('iframe'); await frame.locator('[data-editor-source]').waitFor(); + await capture('embedded-' + width, page.frames()[1]); + } + checks.push('all CMS forms: desktop/tablet/375px/320px and embedded HTML geometry'); + if (!measureOnly) { + await page.setViewportSize({ width: 1440, height: 1000 }); + await go('/editor/tenants/1/pages/2/html'); + const source = page.getByRole('textbox', { name: 'Page HTML source', exact: true }); + await source.scrollIntoViewIfNeeded(); + const before = await source.boundingBox(); + await page.mouse.move(before.x + before.width - 3, before.y + before.height - 3); await page.mouse.down(); await page.mouse.move(before.x + before.width - 3, before.y + before.height + 120, { steps: 6 }); await page.mouse.up(); + assert.ok((await source.boundingBox()).height > before.height + 50, 'native vertical resize grows source'); + await source.focus(); await page.keyboard.press('ControlOrMeta+a'); + assert.equal(await source.evaluate(el => el.selectionEnd - el.selectionStart), originalBody.length); + assert.equal(await source.evaluate(el => getComputedStyle(el).outlineStyle), 'solid'); + await save(200); assert.deepEqual(stored.BodyBlocks, original.BodyBlocks); assert.equal(stored.BodyHTML, originalBody); + const advanced = '\n
\n\n' + originalBody + '\n'; + await source.fill(advanced); await pane('Appearance'); await pane('Publishing'); await pane('HTML'); assert.equal(await source.inputValue(), advanced); + await pane('Content'); assert.equal(await page.locator('[data-editor-surface]').getAttribute('contenteditable'), 'false'); assert.equal(await page.evaluate(() => window.fixtureUnsafe), undefined); + await pane('HTML'); await save(200); assert.equal(stored.BodyHTML, advanced); assert.equal(stored.BodyBlocks, null); assert.equal(stored.Path, original.Path); assert.equal(stored.TemplateID, original.TemplateID); assert.equal(Date.parse(stored.PublishAt), Date.parse(original.PublishAt)); + await page.reload(); await source.waitFor(); assert.equal(await source.inputValue(), advanced); + const draft = advanced + '\n

Unsaved draft

'; await source.fill(draft); conflict = true; await save(409); + assert.equal(await source.inputValue(), draft); assert.match(await page.locator('#save-state').innerText(), /Unsaved/); + assert.match(await page.locator('#toast').innerText(), /draft is still here/); + await page.waitForTimeout(3700); assert.equal(await page.locator('#toast').isVisible(), true, 'errors remain until dismissed'); + await page.locator('#btn-save-page').focus(); await page.keyboard.press('Tab'); + for(let i=0;i<40&&!(await page.getByRole('button', { name: 'Dismiss notification' }).evaluate(el=>el===document.activeElement));i++)await page.keyboard.press('Tab'); + assert.deepEqual(await page.getByRole('button', { name: 'Dismiss notification' }).evaluate(el => ({ focused:el===document.activeElement,visible:el.matches(':focus-visible'),color:getComputedStyle(el).outlineColor,style:getComputedStyle(el).outlineStyle,width:getComputedStyle(el).outlineWidth })), {focused:true,visible:true,color:'rgb(255, 255, 255)',style:'solid',width:'3px'}); + await page.getByRole('button', { name: 'Dismiss notification' }).click(); assert.equal(await page.locator('#toast').isVisible(), false); + await pane('Publishing'); await page.locator('input[name=title]').fill(''); const count = requests.length; await page.getByRole('button', { name: 'Save page', exact: true }).click(); + assert.equal(await page.locator('input[name=title]').evaluate(el => el === document.activeElement && !el.validity.valid), true); assert.equal(requests.length, count); + conflict = false; stored = structuredClone(original); + await go('/editor/tenants/1/pages/2/publishing'); + assert.match(await page.locator('#schedule-zone').innerText(), /America\/New_York/); + assert.equal(await page.locator('[name=publish_at]').inputValue(), '2026-11-02T10:00'); + await page.locator('[name=publish_at]').fill('2026-11-03T10:30'); await save(200); assert.equal(stored.PublishAt, '2026-11-03T15:30:00.000Z'); + await pane('Preview'); assert.equal(await page.locator('#page-preview').getAttribute('sandbox'), 'allow-same-origin'); + assert.equal(await page.locator('#page-preview').getAttribute('referrerpolicy'), 'no-referrer'); + await pane('HTML'); await source.fill('Draft retained on cancelled navigation'); + await page.getByRole('link', { name: 'All pages', exact: true }).click(); assert.equal(await source.inputValue(), 'Draft retained on cancelled navigation'); assert.match(page.url(), /\/html$/); + readOnly = true; await go('/editor/tenants/1/pages/2/html'); assert.equal(await source.getAttribute('readonly'), ''); assert.equal(await page.locator('#btn-save-page').isVisible(), false); + assert.equal(await page.locator('#new-tenant-form').count(), 0); assert.equal(requests.some(r => r.method === 'POST' && (r.path.endsWith('/domains') || r.path === '/api/v1/admin/tenants')), false); + readOnly = false; + await go('/editor/tenants/1/pages/new/publishing'); + await page.locator('[name=title]').fill('New fixture page'); await page.locator('[name=path]').fill('/new-fixture'); + await pane('HTML'); await source.fill('

New draft page

'); + const created = page.waitForResponse(r => r.request().method() === 'POST' && r.url().endsWith('/pages')); + await page.getByRole('button', { name: 'Save page', exact: true }).click(); assert.equal((await created).status(), 201); + await page.waitForURL('**/pages/2/content'); await pane('HTML'); await page.reload(); await source.waitFor(); + assert.equal(await source.inputValue(), '

New draft page

'); assert.equal(stored.Status, 'draft'); assert.equal(stored.Path, '/new-fixture'); + await pane('Content'); await page.getByRole('button', { name: 'Event', exact: true }).click(); + const event = page.locator('[data-event-tool]'); await event.getByRole('button', { name: 'Apply event to page' }).click(); await page.locator('#toast.error').waitFor(); + const values = { id:'fixture-show',title:'Synthetic event',project:'Fixture ensemble',persona:'classical',start:'2026-11-03T19:00:00-05:00',end:'2026-11-03T21:00:00-05:00',timezone:'America/New_York',venue:'Fixture hall',city:'Fixture city',status:'confirmed',source:'https://example.test/source',verified:'2026-10-17' }; + for (const [key,value] of Object.entries(values)) await event.locator('[data-event-field="'+key+'"]').fill(value); + await event.getByRole('button', { name: 'Apply event to page' }).click(); + await page.locator('#section-tabs').getByRole('link', { name: 'Synthetic event', exact: true }).click(); await page.locator('[data-event-id=fixture-show]').waitFor(); + await save(200); assert.match(stored.BodyHTML, /data-event-timezone="America\/New_York"/); assert.match(stored.BodyHTML, /2026-11-03T19:00:00-05:00/); + await go('/platform/tenants/new'); const tenant = page.locator('#new-tenant-form'); await tenant.getByRole('button', { name: 'Create tenant' }).click(); assert.equal(await tenant.locator('[name=slug]').evaluate(el => el === document.activeElement), true); + await tenant.locator('[name=slug]').fill('new-fixture'); await tenant.locator('[name=label]').fill('New fixture tenant'); failPlatform = true; + await tenant.getByRole('button', { name: 'Create tenant' }).click(); await page.locator('#toast.error').waitFor(); assert.equal(await tenant.locator('[name=slug]').inputValue(), 'new-fixture'); failPlatform = false; + await tenant.getByRole('button', { name: 'Create tenant' }).click(); await page.locator('#tenants-section').waitFor(); assert.equal(tenants.at(-1).Slug, 'new-fixture'); + await go('/platform/tenants/1/domains'); const domain = page.locator('#new-domain-form'); await domain.locator('[name=host]').fill('new-review.example.test'); await domain.locator('[name=kind]').selectOption('preview'); await domain.getByRole('button', { name: 'Add domain' }).click(); await page.getByRole('cell', { name: 'new-review.example.test', exact: true }).waitFor(); assert.equal(await domain.locator('[name=host]').inputValue(), ''); + assert.equal(await page.locator('[data-editor-source]').count(), 0); + await page.setViewportSize({ width: 320, height: 1000 }); failPlatform = true; await domain.locator('[name=host]').fill('duplicate.example.test'); await domain.getByRole('button', { name: 'Add domain' }).click(); await page.locator('#toast.error').waitFor(); await capture('error-320'); + checks.push('native textarea resize, keyboard selection/focus', 'exact advanced HTML and structured-body preservation', 'save/reload, unsaved-navigation guard, CAS conflict draft retention', 'persistent dismissible errors and native required-field focus', 'browser-local publishing dates roundtrip to UTC', 'preview sandbox unchanged', 'read-only and editor/platform separation', 'new page create/reload and event validation/offset preservation', 'tenant and domain synthetic create/error recovery'); + } + assert.deepEqual(external, []); assert.deepEqual(errors, []); + const result = { pass: true, measureOnly, browser: browser.version(), boundary: 'actual static CMS UI; synthetic loopback memory API; fresh isolated browser profile; no live auth or writes', checks, measurements, requests: requests.map(({method,path}) => ({method,path})) }; + if (artifacts) fs.writeFileSync(path.join(artifacts, 'results.json'), JSON.stringify(result, null, 2) + '\n'); + console.log(JSON.stringify({ ...result, measurements: measurements.map(m => ({ label:m.label, viewport:m.viewport, document:m.document, source:m.source, content:m.content })), requests: requests.length })); + } finally { await browser.close(); await new Promise(resolve => server.close(resolve)); } +} +main().catch(error => { console.error(error); process.exitCode = 1; }); diff --git a/docs/plans/2026-10-17-shared-admin-form-usability.md b/docs/plans/2026-10-17-shared-admin-form-usability.md new file mode 100644 index 0000000..4b06c15 --- /dev/null +++ b/docs/plans/2026-10-17-shared-admin-form-usability.md @@ -0,0 +1,123 @@ +# Shared CMS/admin form usability — authorized scope addition + +Status: active source/UI work. Authorized by Jon via parent delegation at +2026-10-17 19:37UTC, Sentinel_00962535c3d48191a20df19f113ce3a2: +HTML source input is too small; review each site-editor and multisite-admin form +for formatting and usability. This scope addition requires no new approval for +local reversible fixes. It does not authorize deployment, migration/archive, +credential/access changes or production content writes. + +Own branch: `fix/shared-admin-form-usability-20261017`, isolated worktree +`cms-form-usability-20261017`, current upstream CMS main base +`2d606115f359e088e3600e791920bcc173f4b37b`. Existing source checkouts, active locked +plans and Tina's accepted saved pages remain untouched. No AGENTS.md, SKILL.md or +active plan-lock file was found in this new checkout. This new plan adds scope +without rewriting a prior frozen plan or evidence manifest. + +## Inventory and measurements first + +Audit every rendered form/field group owned by shared CMS Site editor and +Multisite administration, including Content/HTML, Appearance, Publishing, +preview controls, tenant creation/settings, domains/subsites and page actions. +Also inventory shared admin-shell configuration and contributed admin forms used +by multisite; attribute each finding to its upstream owner. A companion upstream +admin patch is appropriate only where the measured issue belongs there. Do not +create a Tina-specific fork or silently broaden into an unrelated redesign. + +Known source entry points are `adminui/static/index.html`, `platform.html`, +`admin.css`, `admin.js` and the embedded Go handler/router contracts. The current +CSS has a global two-column form grid, `label{display:contents}`, nested editor +panes, a1280px body cap and a source textarea already declaring24rem minimum +height. Measure the actual rendered box in both embedded and fullscreen layouts +before assigning the small-editor cause; increasing a CSS number alone is not +acceptance. + +Check labels/accessible names, control and textarea sizes, long values/help text, +errors and focus placement, spacing/actions, tab order, zoom and mobile overflow. +Record a bounded checklist with pass/finding/owner/fix for every audited form. + +## Implementation bounds + +Make the HTML source editor use the available content width and a useful +viewport-appropriate working height, with vertical resizing and a readable code +font. Preserve exact source text, native editing/selection/keyboard behavior, +unsaved drafts, pane navigation and save/reload semantics. Keep resize handles +and overflow usable on narrow screens and in the admin iframe. + +Repair measured shared form layout/label/help/error/control/action issues with +reusable component styles and narrowly scoped DOM behavior. Retain site-only +versus platform routes, permission visibility, RBAC/tenant checks, CAS/history, +strict field decoding, sanitization/preview sandbox, schedules/timezone values and +source-preserving advanced HTML handling. Do not weaken security to fix layout. +No live account, page, form message or provider mutation belongs in these tests. + +## Acceptance and verification + +- Desktop and320/375/768px mobile/tablet layouts have no unintended horizontal + page overflow. Text inputs, selects, labels/help/errors and actions fit their + actual containers; wide data tables have intentional local scrolling. +- HTML source has a substantial usable viewport, fills its content column, and + can be vertically resized. Long/multiline HTML, keyboard selection and pane + switching preserve the complete value; save/reload preserves unrelated fields. +- Required/error states are readable, associated with the right controls, and + keyboard reachable. Focus remains visible; ordinary/error control contrast is + measured where applicable. Long labels/values do not overlap other controls. +- Site editor still cannot expose or call platform-only controls. Invalid saves, + CAS conflicts and server errors retain the draft and existing security behavior. +- Add focused regressions for measured bugs using computed geometry and actual + interactions, not tests that merely mirror CSS declarations. Reuse current + test/browser tooling and synthetic fixtures, with no duplicated dependency cache. +- Run real isolated desktop/mobile Chromium browser QA locally against the real + embedded HTML/CSS/JS and disclosed fixture APIs. Test embedded/fullscreen, + resize, keyboard/error/unsaved/save/reload paths. Capture and inspect before/ + after screenshots. Never attach to the owner's active Edge context. +- Keep heavy Go/Docker builds off this Mac. Run appropriate Go/integration and + exact-head checks on the existing authorized hosted runner path when needed. + Open draft upstream repair PR(s) with independent review and exact-head evidence. + Source acceptance is separate from any later package/release/deployment. + +## Implemented result and audit + +| Form / controls | Finding and shared repair | Verification | +| --- | --- | --- | +| Content / HTML | Hidden section navigation left the editor in the210px grid column. Span the full grid; readable14px code (16px mobile),60vh working height, native vertical resize, described help. | Actual1440px source210→1158px;768px210→646px. Native drag resize, selection, pane switching, exact advanced source and structured-body preservation pass. | +| Event tool | Global grid-column placement created implicit columns inside labels; some320px inputs were73–103px wide. Stack labeled controls at full available width. | Desktop/mobile geometry; invalid event, source/offset/timezone values, event section navigation and save pass. | +| Publishing | Explain required path and optional dates; associate timezone help with controls. | Native invalid field focus; New York browser timezone→UTC roundtrip and metadata retention pass. | +| Appearance | Explain optional template and body-only empty selection. | Native labeled input, pane navigation and preserved template pass. | +| Tenant creation | Explicit required/optional fields and associated identifier help; shared44px actions. | Native required focus, synthetic error retains fields, successful create/navigation pass. | +| Domain creation / table actions | Explain hostname and optional subsite; shared sized actions. Keep deliberate table-local scrolling. | Synthetic create/error/reset;320/375px layout and bounded errors pass. | +| Cache / preview / delete actions | Shared44px buttons and readable destructive action colors. Preview sandbox unchanged. | Geometry, keyboard focus, preview sandbox/referrer policy pass. No live action invoked. | +| Notifications | Errors vanished after3.5s; earlier timers could hide newer notices. Clear prior timer; persist errors with keyboard-accessible dismissal, bounded wrapped/scrollable text, white focus on colored background. | CAS409 draft retained; keyboard :focus-visible solid3px white outline; persistent/dismissed errors and320px error geometry pass. | +| Shared admin configuration / login | Separate upstream admin shell had260px minimum tracks, intrinsic long-label/select widths, and error text expanding entire form; missing programmatic help association. | Companion isolated `workflow-plugin-admin` branch fixes shrinking/wrapping, control borders/focus, help/status semantics. Actual config/login forms pass at1440/768/375/320px with long options/labels/help/server errors; synthetic validate/apply, secret clearing, disabled state and session bearer isolation pass. | + +Both browser harnesses execute shipped UI against explicit synthetic loopback +memory APIs in fresh headless profiles; they do not exercise hosted authorization, +PostgreSQL durability, the owner's browser, or real mail. CMS iframe measurements +use a disclosed minimal wrapper, not a claim of hosted shell QA. Desktop/mobile +screenshots were inspected. No new local dependency cache or heavy Go/Docker build +was used. Hosted exact-head browser jobs retain screenshots/results for7days. +The existing Go/integration checks remain required before merge. Contributed +My Profile/Admins, authorization, onboarding and preview-agent tools have separate +upstream implementations; this patch's actual-browser coverage is the two named +CMS workspaces and generic shell configuration/login forms, not every contributed +tool or a hosted full-app acceptance run. + +Local evidence outside the source checkout: +`verification/cms-forms-before-20261017`, `cms-forms-after-20261017`, +`admin-forms-before-20261017`, `admin-forms-after-20261017` contain screenshots and +computed geometry. Reproduce using the documented standalone browser commands; +`--measure-only` records a baseline without repair assertions. Do not overwrite +older task evidence or accepted review content. + +## Rollout boundary and added contact scope + +Source review/PR acceptance is separate from a later reviewed CMS/admin package +release, host dependency adoption, isolated review deployment and hosted browser +acceptance. No release, deployment, CMS migration/archive, credential change or +production content publication is part of this patch. + +Jon subsequently supplied `tina@langevin.me` and required an authorized-admin- +editable per-site recipient. Implement contact delivery separately from these +layout repairs, with explicit review/production routing and verified transport. +The existing review form remains a no-send demo until that integration is +configured and deployed. Never use the admin identity as an inferred recipient. From 4f48d108966f62d6ba32b6c48adb646eee725066 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sat, 17 Oct 2026 16:13:38 -0400 Subject: [PATCH 2/2] Keep contact recipient out of shared UI documentation --- docs/plans/2026-10-17-shared-admin-form-usability.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/plans/2026-10-17-shared-admin-form-usability.md b/docs/plans/2026-10-17-shared-admin-form-usability.md index 4b06c15..68bcd53 100644 --- a/docs/plans/2026-10-17-shared-admin-form-usability.md +++ b/docs/plans/2026-10-17-shared-admin-form-usability.md @@ -116,7 +116,7 @@ release, host dependency adoption, isolated review deployment and hosted browser acceptance. No release, deployment, CMS migration/archive, credential change or production content publication is part of this patch. -Jon subsequently supplied `tina@langevin.me` and required an authorized-admin- +Jon subsequently supplied the recipient privately and required an authorized-admin- editable per-site recipient. Implement contact delivery separately from these layout repairs, with explicit review/production routing and verified transport. The existing review form remains a no-send demo until that integration is