diff --git a/.github/workflows/website-checks.yml b/.github/workflows/website-checks.yml index f246c183ca..ffe7a29b31 100644 --- a/.github/workflows/website-checks.yml +++ b/.github/workflows/website-checks.yml @@ -103,6 +103,12 @@ jobs: run: npm run check:explorer-languages - name: Catalog generators run: npm run test:sync-skills && npm run test:sync-crons + # The tiers, the touch minimum and the QA matrix come from + # specs/ui/viewport.t27 through the compiler; the two generated files + # must be the ones the vendored spec produces, and the generator must + # still refuse a spec whose own tests do not hold. + - name: Viewport spec + run: npm run check:viewport && npm run test:viewport-spec - name: Build run: npx vite build @@ -144,6 +150,15 @@ jobs: CHROME_PATH: ${{ steps.chrome.outputs.chrome-path }} run: npm run check:render -- --no-build + # The five explorers and the spec explorer at every size in the spec's + # matrix: one pane on a phone, two above, the deep-linked card open and + # on screen, 44px controls where a finger is the pointer, one scroller + # per pane, nothing sideways. Skips itself without Chrome. + - name: Explorer viewport contract + env: + CHROME_PATH: ${{ steps.chrome.outputs.chrome-path }} + run: npm run check:explorer-viewport -- --no-build + # A51: four PRs merged, every check green, and the live site changed # nothing -- this app deploys from a second repository that holds build # output only, copied there by hand. Every check above runs against the diff --git a/apps/website/package.json b/apps/website/package.json index 5cf3d0b301..59e74c43aa 100644 --- a/apps/website/package.json +++ b/apps/website/package.json @@ -14,8 +14,8 @@ "check:spec-catalog": "node --experimental-strip-types qa/spec-catalog-contract.mjs", "core": "node scripts/spec-core.mjs", "check:shared-core": "node --experimental-strip-types qa/shared-spec-core.mjs", - "prebuild": "npm run core -- index && node scripts/skills-core.mjs index && node scripts/agents-from-specs.mjs && node scripts/docs-from-specs.mjs", - "prebuild:ci": "npm run core -- index && node scripts/skills-core.mjs index && node scripts/agents-from-specs.mjs && node scripts/docs-from-specs.mjs", + "prebuild": "npm run core -- index && node scripts/skills-core.mjs index && node scripts/agents-from-specs.mjs && node scripts/docs-from-specs.mjs && node scripts/viewport-from-spec.mjs", + "prebuild:ci": "npm run core -- index && node scripts/skills-core.mjs index && node scripts/agents-from-specs.mjs && node scripts/docs-from-specs.mjs && node scripts/viewport-from-spec.mjs", "dev": "vite", "build": "vite build", "build:ci": "vite build", @@ -69,7 +69,10 @@ "check:tools": "node --experimental-strip-types qa/tools-spec-contract.mjs", "test:agents-specs": "node --test scripts/agents-from-specs.test.mjs", "check:docs": "node --experimental-strip-types qa/docs-spec-contract.mjs", - "test:docs-specs": "node --test scripts/docs-from-specs.test.mjs" + "test:docs-specs": "node --test scripts/docs-from-specs.test.mjs", + "check:viewport": "node scripts/viewport-from-spec.mjs --check", + "test:viewport-spec": "node --test scripts/viewport-from-spec.test.mjs", + "check:explorer-viewport": "node qa/explorer-viewport-contract.mjs" }, "dependencies": { "@babylonjs/core": "^9.25.0", diff --git a/apps/website/public/t27/files/specs/ui/viewport.t27 b/apps/website/public/t27/files/specs/ui/viewport.t27 new file mode 100644 index 0000000000..f94f6b8525 --- /dev/null +++ b/apps/website/public/t27/files/specs/ui/viewport.t27 @@ -0,0 +1,186 @@ +// SPDX-License-Identifier: Apache-2.0 +; specs/ui/viewport.t27 -- the one viewport contract of the t27.ai site (breakpoints, touch targets, QA matrix) +; Source of truth for how the site's explorers (#/specs, #/skills, #/crons, #/agents, #/tools, +; #/functions) lay themselves out at a given viewport. The site (gHashTag/trinity, apps/website) +; vendors this file byte-identical under public/t27/files/specs/ui/ and generates +; src/lib/viewport.generated.ts and src/styles/viewport.generated.css from it with the real +; compiler (scripts/viewport-from-spec.mjs); nothing below is parsed with a regex and no +; breakpoint is typed twice. The generator also evaluates the test blocks below against the +; declared constants, because the analyzer's typecheck.ok is necessary, not sufficient +; (it stays true for `assert 1 > 2`). ASCII only (L3), English only (LANG-EN). + +; SOURCE (measured on live t27.ai, bundle index-B4FDqqXk.js, 2026-09-09 ~18:40 UTC, cloud +; Chromium, DPR 1; screenshots kept with the round-3 report): +; 390x844 #/tools?tool=mcp%2Fgitbutler only the 62-item list renders, the card +; tool_mcp_gitbutler is not in the viewport, document scrollHeight 924 > 844. +; Reproduced on a build of trinity main c881e7f: the hash written after mount is +; ignored (useHashParams reads the hash once), so the list pane stays and the +; document scrolls; a fresh navigation to the same deep link does open the card. +; 390x844 #/queen rail is a horizontal strip, 12 tiles of 44x74, about 6 visible; +; 7 interactive targets below 40 px; .queen27-hud-command.is-compact +; scrollWidth 612 > clientWidth 344. +; 1024x768 #/queen the rail of 12 does not fit: PROJECT hidden under COLLAPSE. +; 1920x1080 #/docs text column left-aligned, nested scrollers (Docs 2, Tools 3). +; Queen.css carries 36 media queries at 640/760/900/901/1100; the explorers use 760/1100 +; in TSX; qa/queen-viewport-contract.mjs checks 1280 only. There was no single source. +; The Queen rail and HUD are described here (RAIL_*) but are not changed by this spec's +; first consumer (P0 = explorers); a QA contract may report them as warnings only. +; phi^2 + 1/phi^2 = 3 | TRINITY + +module ui_viewport; + +pub const KIND : str = "viewport"; +pub const ID : str = "ui/viewport"; +pub const NAME : str = "Site viewport contract"; +; The generated artifacts the site derives from this file (relative to apps/website). +pub const GENERATED : [2]str = ["src/lib/viewport.generated.ts", "src/styles/viewport.generated.css"]; + +; --- Tiers ----------------------------------------------------------------------------- +; Four tiers by CSS viewport width (window.innerWidth), inclusive upper bounds: +; phone : width <= PHONE_MAX +; tablet : PHONE_MAX < width <= TABLET_MAX +; desktop : TABLET_MAX < width <= DESKTOP_MAX +; wide : width > DESKTOP_MAX +pub const TIERS : [4]str = ["phone", "tablet", "desktop", "wide"]; +pub const PHONE_MAX : u16 = 600; +pub const TABLET_MAX : u16 = 1024; +pub const DESKTOP_MAX : u16 = 1600; +; A pointer that cannot hover (touch) is read from the pointer:coarse media feature. +pub const COARSE_POINTER_QUERY : str = "(pointer: coarse)"; +; Below this width the explorer header drops its subtitle and note (compact chrome). Not a +; tier: it is the 1100 the explorers' TSX already used, kept so the desktop tier renders the +; same at 1025-1099 as before this spec; it sits strictly inside the desktop tier. +pub const HEADER_CHROME_MAX : u16 = 1100; + +; --- Panes ----------------------------------------------------------------------------- +; phone: master-detail, list pane OR card pane, never both; a `?=` deep link opens the +; card pane directly and the card carries a control back to the list. +; tablet: list and card side by side, the list's filters folded into one collapsible row. +; desktop and wide: unchanged three-pane layout (header, ladder, list + card). +pub const PHONE_PANES : u8 = 1; +pub const TABLET_PANES : u8 = 2; +pub const DESKTOP_PANES : u8 = 2; +; Exactly one vertical scroller per visible pane; the document itself never scrolls. +pub const ONE_SCROLLER_PER_PANE : bool = true; +pub const DOCUMENT_SCROLLS : bool = false; + +; --- Targets --------------------------------------------------------------------------- +; Smallest interactive box (CSS px, each side) on phone and tablet; WCAG 2.5.5 uses 44. +pub const TOUCH_TARGET_MIN_PX : u8 = 44; +; The pane-switch control on a phone card ("<- list") must meet the same minimum. +pub const BACK_CONTROL_MIN_PX : u8 = 44; + +; --- QA matrix ------------------------------------------------------------------------- +; The six sizes every viewport contract iterates; widths and heights are the same six +; pairs written twice so an integer test can index them. "WxH" in CSS px. +pub const VIEWPORTS : [6]str = ["390x844", "600x900", "768x1024", "1024x768", "1280x800", "1920x1080"]; +pub const VIEWPORT_WIDTHS : [6]u16 = [390, 600, 768, 1024, 1280, 1920]; +pub const VIEWPORT_HEIGHTS : [6]u16 = [844, 900, 1024, 768, 800, 1080]; +; The tier each matrix entry falls into, by the bounds above (checked by a test below). +pub const VIEWPORT_TIERS : [6]str = ["phone", "phone", "tablet", "tablet", "desktop", "wide"]; +; The width the desktop layout must stay pixel-identical at across a change (before/after). +pub const DESKTOP_REFERENCE_WIDTH : u16 = 1280; + +; --- Queen rail capacity (described, not changed here) --------------------------------- +; Numbers read from apps/website/src/pages/Queen.css of trinity main c881e7f and from the +; measurement above; each carries where it comes from. +; RAIL_TILES 12 src/components/queenHud.ts:35 (HUD_KEYS, twelve views) +; RAIL_TILE_W_COMPACT 44 Queen.css:5311-5312 (.is-compact .queen27-hud-cmd flex 0 0 44px, min-width 44px) +; RAIL_TILE_GAP_COMPACT 4 Queen.css:5302 (.is-compact gap: 4px) +; RAIL_TILE_H_COMPACT 74 measured at 390x844 (the CSS fixes no height; padding at Queen.css:5315) +; RAIL_CLIENT_W_PHONE 344 measured clientWidth of the compact rail at 390x844 +; RAIL_TILE_H_COLUMN 48 Queen.css:3997-3998 (the rail's own budget comment: "six tile rows at ~48 px") +; RAIL_TILE_GAP_COLUMN 6 Queen.css:3868 (gap: 6px) +; RAIL_CHROME_H 148 Queen.css:4277 (hud-top height 64px) + Queen.css:4397 (hud-bottom height 84px) +; Capacity by viewport height in the one-column rail: (height - RAIL_CHROME_H) / RAIL_ROW_H_COLUMN, +; integer division. It predicts 11 tiles at 768 px, fewer than the 12 the rail carries, which +; is the 1024x768 observation above. Tag: model (a CSS budget plus one consistent observation), +; not a measurement of every height. +pub const RAIL_TILES : u8 = 12; +pub const RAIL_TILE_W_COMPACT : u8 = 44; +pub const RAIL_TILE_GAP_COMPACT : u8 = 4; +pub const RAIL_TILE_H_COMPACT : u8 = 74; +pub const RAIL_CLIENT_W_PHONE : u16 = 344; +pub const RAIL_TILE_H_COLUMN : u8 = 48; +pub const RAIL_TILE_GAP_COLUMN : u8 = 6; +pub const RAIL_CHROME_H : u16 = 148; +; One rail row: tile plus gap, for each mode (checked against the parts by a test). +pub const RAIL_ROW_H_COLUMN : u8 = 54; +pub const RAIL_ROW_W_COMPACT : u8 = 48; +; Tiles that must be visible without scrolling the rail, on every size in the matrix. +pub const RAIL_MIN_VISIBLE : u8 = 6; +; One entry per VIEWPORT_HEIGHTS, from the formula above. +pub const RAIL_CAPACITY_BY_HEIGHT : [6]u8 = [12, 13, 16, 11, 12, 17]; + +; --- Tests (typechecked by the compiler, evaluated by the site generator) --------------- + +test breakpoints_are_monotone { + assert PHONE_MAX < TABLET_MAX; + assert TABLET_MAX < DESKTOP_MAX; + assert HEADER_CHROME_MAX > TABLET_MAX; + assert HEADER_CHROME_MAX <= DESKTOP_MAX; +} + +test touch_target_is_at_least_44 { + assert TOUCH_TARGET_MIN_PX >= 44; + assert BACK_CONTROL_MIN_PX >= TOUCH_TARGET_MIN_PX; +} + +test matrix_covers_every_tier { + assert VIEWPORT_WIDTHS[0] <= PHONE_MAX; + assert VIEWPORT_WIDTHS[1] <= PHONE_MAX; + assert VIEWPORT_WIDTHS[2] > PHONE_MAX; + assert VIEWPORT_WIDTHS[2] <= TABLET_MAX; + assert VIEWPORT_WIDTHS[3] <= TABLET_MAX; + assert VIEWPORT_WIDTHS[4] > TABLET_MAX; + assert VIEWPORT_WIDTHS[4] <= DESKTOP_MAX; + assert VIEWPORT_WIDTHS[5] > DESKTOP_MAX; + assert VIEWPORT_TIERS[0] == "phone"; + assert VIEWPORT_TIERS[1] == "phone"; + assert VIEWPORT_TIERS[2] == "tablet"; + assert VIEWPORT_TIERS[3] == "tablet"; + assert VIEWPORT_TIERS[4] == "desktop"; + assert VIEWPORT_TIERS[5] == "wide"; + assert DESKTOP_REFERENCE_WIDTH == VIEWPORT_WIDTHS[4]; +} + +test matrix_is_the_six_quoted_sizes { + assert VIEWPORTS[0] == "390x844"; + assert VIEWPORTS[1] == "600x900"; + assert VIEWPORTS[2] == "768x1024"; + assert VIEWPORTS[3] == "1024x768"; + assert VIEWPORTS[4] == "1280x800"; + assert VIEWPORTS[5] == "1920x1080"; +} + +test phone_shows_one_pane { + assert PHONE_PANES == 1; + assert TABLET_PANES == 2; + assert ONE_SCROLLER_PER_PANE == true; + assert DOCUMENT_SCROLLS == false; +} + +test rail_capacity_follows_the_css_budget { + assert RAIL_ROW_H_COLUMN == RAIL_TILE_H_COLUMN + RAIL_TILE_GAP_COLUMN; + assert RAIL_ROW_W_COMPACT == RAIL_TILE_W_COMPACT + RAIL_TILE_GAP_COMPACT; + // capacity * row fits the budget, one more row does not: integer division, per height + assert RAIL_CAPACITY_BY_HEIGHT[0] * RAIL_ROW_H_COLUMN <= VIEWPORT_HEIGHTS[0] - RAIL_CHROME_H; + assert RAIL_CAPACITY_BY_HEIGHT[0] * RAIL_ROW_H_COLUMN + RAIL_ROW_H_COLUMN > VIEWPORT_HEIGHTS[0] - RAIL_CHROME_H; + assert RAIL_CAPACITY_BY_HEIGHT[1] * RAIL_ROW_H_COLUMN <= VIEWPORT_HEIGHTS[1] - RAIL_CHROME_H; + assert RAIL_CAPACITY_BY_HEIGHT[1] * RAIL_ROW_H_COLUMN + RAIL_ROW_H_COLUMN > VIEWPORT_HEIGHTS[1] - RAIL_CHROME_H; + assert RAIL_CAPACITY_BY_HEIGHT[2] * RAIL_ROW_H_COLUMN <= VIEWPORT_HEIGHTS[2] - RAIL_CHROME_H; + assert RAIL_CAPACITY_BY_HEIGHT[2] * RAIL_ROW_H_COLUMN + RAIL_ROW_H_COLUMN > VIEWPORT_HEIGHTS[2] - RAIL_CHROME_H; + assert RAIL_CAPACITY_BY_HEIGHT[3] * RAIL_ROW_H_COLUMN <= VIEWPORT_HEIGHTS[3] - RAIL_CHROME_H; + assert RAIL_CAPACITY_BY_HEIGHT[3] * RAIL_ROW_H_COLUMN + RAIL_ROW_H_COLUMN > VIEWPORT_HEIGHTS[3] - RAIL_CHROME_H; + assert RAIL_CAPACITY_BY_HEIGHT[4] * RAIL_ROW_H_COLUMN <= VIEWPORT_HEIGHTS[4] - RAIL_CHROME_H; + assert RAIL_CAPACITY_BY_HEIGHT[4] * RAIL_ROW_H_COLUMN + RAIL_ROW_H_COLUMN > VIEWPORT_HEIGHTS[4] - RAIL_CHROME_H; + assert RAIL_CAPACITY_BY_HEIGHT[5] * RAIL_ROW_H_COLUMN <= VIEWPORT_HEIGHTS[5] - RAIL_CHROME_H; + assert RAIL_CAPACITY_BY_HEIGHT[5] * RAIL_ROW_H_COLUMN + RAIL_ROW_H_COLUMN > VIEWPORT_HEIGHTS[5] - RAIL_CHROME_H; + // the one-column rail cannot hold all twelve at 768 px: this is the 1024x768 observation + assert RAIL_CAPACITY_BY_HEIGHT[3] < RAIL_TILES; + // every size in the matrix still shows the minimum + assert RAIL_CAPACITY_BY_HEIGHT[3] >= RAIL_MIN_VISIBLE; + // the compact strip on a phone shows the minimum without scrolling, but not all twelve + assert RAIL_MIN_VISIBLE * RAIL_ROW_W_COMPACT <= RAIL_CLIENT_W_PHONE; + assert RAIL_TILES * RAIL_ROW_W_COMPACT > RAIL_CLIENT_W_PHONE; +} diff --git a/apps/website/qa/explorer-viewport-contract.mjs b/apps/website/qa/explorer-viewport-contract.mjs new file mode 100644 index 0000000000..a61fc0fe06 --- /dev/null +++ b/apps/website/qa/explorer-viewport-contract.mjs @@ -0,0 +1,295 @@ +// Do the explorers keep the viewport contract, or does the CSS just claim to? +// +// The contract is specs/ui/viewport.t27 (vendored under public/t27/files), read +// here through `node scripts/viewport-from-spec.mjs --json` so the matrix and +// the thresholds are the spec's numbers and nothing is typed twice. The built +// site is opened in real Chrome at every size of VIEWPORTS, for the five +// catalog explorers and the spec explorer, each through its deep link, and the +// live DOM is asserted against: +// +// - the document does not scroll sideways (scrollWidth <= clientWidth), and +// no pane is wider than the viewport or scrolls sideways inside itself; +// - phone and tablet: the document does not scroll at all (DOCUMENT_SCROLLS +// = false) -- the panes are the scrollers; +// - the deep-linked card is open and its top edge is inside the viewport; +// - phone: exactly one pane (list OR card, never both); tablet and up: both; +// - phone and tablet: every control in the explorer chrome is at least +// TOUCH_TARGET_MIN_PX on each side. Inline links inside prose are exempt +// (WCAG 2.5.8 exempts them the same way): a control is a button, select, +// input, [role=button], or an anchor that is not display:inline; +// - phone and tablet: at most one live vertical scroller per visible pane +// (ONE_SCROLLER_PER_PANE). Desktop and wide are frozen by the P0 brief +// (pixel-identical at 1280), so their nested scrollers and 80px document +// scroll are reported as warnings, not failures. +// +// Queen (#/queen) is P1 and is not opened here; qa/queen-viewport-contract.mjs +// keeps its own rules. +// +// npm run check:explorer-viewport build, then check +// npm run check:explorer-viewport -- --no-build reuse dist/ +// +// The harness is qa/queen-viewport-contract.mjs's: serve dist over node:http, +// launch the installed Chrome with --headless=new, speak CDP over Node's +// WebSocket. No Chrome: exit 0 with "skipping", as the render check does. +// Screenshots land in /tmp/explorer-viewport-shots/ (or $SHOTS_DIR). +// +// The guard: a probe that matches nothing reports a broken selector, never a +// clean page. Zero controls, no explorer root or no card is a failure. +import { execFileSync, execSync, spawn } from 'node:child_process'; +import { createServer } from 'node:http'; +import { readFileSync, existsSync, mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { join, extname, normalize } from 'node:path'; +import { tmpdir } from 'node:os'; + +const ROOT = new URL('..', import.meta.url).pathname; +const DIST = join(ROOT, 'dist'); +const SHOTS = process.env.SHOTS_DIR || '/tmp/explorer-viewport-shots'; +const SETTLE_MS = Number(process.env.SETTLE_MS || 4000); + +const CHROMES = [ + process.env.CHROME_PATH, + '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + '/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary', + '/Applications/Chromium.app/Contents/MacOS/Chromium', + '/usr/bin/google-chrome', '/usr/bin/chromium-browser', '/usr/bin/chromium', +].filter(Boolean); +const CHROME = CHROMES.find(p => existsSync(p)); +if (!CHROME) { + console.log(' no Chrome found — skipping the explorer viewport check. Set CHROME_PATH to force it.'); + process.exit(0); +} + +// ── The contract, from the spec ── +const SPEC = JSON.parse(execFileSync(process.execPath, [join(ROOT, 'scripts/viewport-from-spec.mjs'), '--json'], { cwd: ROOT, encoding: 'utf8' })); +const tierOf = w => (w <= SPEC.PHONE_MAX ? 'phone' : w <= SPEC.TABLET_MAX ? 'tablet' : w <= SPEC.DESKTOP_MAX ? 'desktop' : 'wide'); +const PANES = { phone: SPEC.PHONE_PANES, tablet: SPEC.TABLET_PANES, desktop: SPEC.DESKTOP_PANES, wide: SPEC.DESKTOP_PANES }; +const SIZES = SPEC.VIEWPORTS.map(s => s.split('x').map(Number)); +if (SIZES.length !== 6) { console.error(` VIEWPORTS has ${SIZES.length} sizes, expected 6`); process.exit(1); } + +// One deep link per explorer. The ids are entries every catalog build has +// carried since the explorers shipped; a missing one fails loudly below (no +// card), it does not pass quietly. +const EXPLORERS = [ + { name: 'tools', route: '#/tools?tool=mcp%2Fgitbutler', card: 'mcp/gitbutler' }, + { name: 'skills', route: '#/skills?skill=t27%2Ftri-pipeline', card: 't27/tri-pipeline' }, + { name: 'crons', route: '#/crons?cron=github-actions%2Ft27%2Fpr-dashboard', card: 'github-actions/t27/pr-dashboard' }, + { name: 'agents', route: '#/agents?agent=D', card: 't27/D' }, + { name: 'functions', route: '#/functions?function=content-detailed-script-generate', card: 'content-detailed-script-generate' }, + // The spec explorer marks no card; its open detail is the code view. + { name: 'specs', route: '#/specs?spec=specs%2Ftutorial%2F02_functions.t27', card: null, detail: '.spec-x main' }, +]; + +if (!process.argv.includes('--no-build')) { + console.log(' building…'); + execSync('npx vite build', { cwd: ROOT, stdio: ['ignore', 'ignore', 'inherit'] }); +} +if (!existsSync(join(DIST, 'index.html'))) { + console.error(' dist/index.html missing — nothing to open.'); + process.exit(1); +} +mkdirSync(SHOTS, { recursive: true }); + +// ── Serve dist the way Pages serves it ── +const MIME = { + '.html': 'text/html', '.js': 'text/javascript', '.mjs': 'text/javascript', + '.css': 'text/css', '.json': 'application/json', '.svg': 'image/svg+xml', + '.png': 'image/png', '.jpg': 'image/jpeg', '.webp': 'image/webp', '.md': 'text/markdown', + '.ico': 'image/x-icon', '.wasm': 'application/wasm', '.woff2': 'font/woff2', '.t27': 'text/plain', +}; +const server = createServer((req, res) => { + const path = normalize(decodeURIComponent(req.url.split('?')[0])).replace(/^(\.\.[/\\])+/, ''); + let file = join(DIST, path); + if (!file.startsWith(DIST) || !existsSync(file) || path === '/') file = join(DIST, 'index.html'); + try { + res.writeHead(200, { 'Content-Type': MIME[extname(file)] || 'application/octet-stream' }); + res.end(readFileSync(file)); + } catch { res.writeHead(404).end(); } +}); +await new Promise(r => server.listen(0, '127.0.0.1', r)); +const ORIGIN = `http://127.0.0.1:${server.address().port}`; + +// ── Chrome over CDP ── +const profile = mkdtempSync(join(tmpdir(), 'explorer-viewport-')); +const chrome = spawn(CHROME, [ + '--headless=new', + '--remote-debugging-port=0', + `--user-data-dir=${profile}`, + '--no-first-run', '--no-default-browser-check', '--disable-extensions', + '--disable-background-networking', '--disable-sync', '--mute-audio', + '--window-size=1920,1080', + ...(process.platform === 'linux' ? ['--no-sandbox', '--disable-dev-shm-usage'] : []), + '--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader', + 'about:blank', +], { stdio: ['ignore', 'ignore', 'pipe'] }); + +const browserWs = await new Promise((resolve, reject) => { + const t = setTimeout(() => reject(new Error('Chrome never announced a debugging port')), 30000); + let buf = ''; + chrome.stderr.on('data', d => { + buf += d; + const m = buf.match(/DevTools listening on (ws:\/\/\S+)/); + if (m) { clearTimeout(t); resolve(m[1]); } + }); + chrome.on('exit', c => { clearTimeout(t); reject(new Error(`Chrome exited (${c}) before listening`)); }); +}); + +const ws = new WebSocket(browserWs); +await new Promise((res, rej) => { ws.onopen = res; ws.onerror = () => rej(new Error('CDP socket refused')); }); +let nextId = 0; +const pending = new Map(); +const listeners = []; +ws.onmessage = ev => { + const msg = JSON.parse(ev.data); + if (msg.id !== undefined && pending.has(msg.id)) { + const { resolve, reject } = pending.get(msg.id); + pending.delete(msg.id); + msg.error ? reject(new Error(`${msg.error.message}`)) : resolve(msg.result); + } else if (msg.method) listeners.forEach(f => f(msg)); +}; +const send = (method, params = {}, sessionId) => new Promise((resolve, reject) => { + const id = ++nextId; + pending.set(id, { resolve, reject }); + ws.send(JSON.stringify({ id, method, params, ...(sessionId ? { sessionId } : {}) })); +}); + +const { targetId } = await send('Target.createTarget', { url: 'about:blank' }); +const { sessionId } = await send('Target.attachToTarget', { targetId, flatten: true }); +const call = (m, p) => send(m, p, sessionId); + +const errors = []; +listeners.push(msg => { + if (msg.sessionId !== sessionId) return; + if (msg.method === 'Runtime.exceptionThrown') { + const d = msg.params.exceptionDetails; + errors.push(d.exception?.description || d.text || 'uncaught exception'); + } +}); +await call('Runtime.enable'); +await call('Page.enable'); + +const evaluate = async expr => { + const r = await call('Runtime.evaluate', { expression: expr, awaitPromise: true, returnByValue: true }); + if (r.exceptionDetails) throw new Error(`probe failed: ${r.exceptionDetails.text} ${r.exceptionDetails.exception?.description ?? ''}`); + return r.result.value; +}; +const sleep = ms => new Promise(r => setTimeout(r, ms)); +const cleanup = () => { + try { chrome.kill(); } catch { /* gone */ } + server.close(); + // Chrome may still be writing its profile as it dies; a leftover temp dir is + // not a contract failure. + try { rmSync(profile, { recursive: true, force: true }); } catch { /* ENOTEMPTY race */ } +}; + +// Runs inside the page. Everything measured, nothing inferred from class names. +const PROBE = `(() => { + const root = document.querySelector('.spec-x'); + if (!root) return { root: false }; + const de = document.documentElement; + const vw = window.innerWidth, vh = window.innerHeight; + const controls = []; + for (const el of root.querySelectorAll('button, a, select, input, textarea, [role=button]')) { + const r = el.getBoundingClientRect(); + if (r.width === 0 || r.height === 0) continue; + const cs = getComputedStyle(el); + if (el.tagName === 'A' && cs.display === 'inline') continue; + controls.push({ tag: el.tagName.toLowerCase(), w: Math.round(r.width), h: Math.round(r.height), text: (el.getAttribute('aria-label') || el.textContent || '').trim().slice(0, 40) }); + } + const scrollers = []; + for (const el of root.querySelectorAll('*')) { + const cs = getComputedStyle(el); + // A textarea scrolls its own text; it is a control, not a pane. + if (el.tagName === 'TEXTAREA') continue; + if ((cs.overflowY === 'auto' || cs.overflowY === 'scroll') && el.scrollHeight > el.clientHeight + 1) { + scrollers.push({ tag: el.tagName.toLowerCase(), cls: String(el.className).slice(0, 40), h: el.clientHeight, sh: el.scrollHeight }); + } + } + // A pane wider than the viewport, or scrolling sideways inside itself, is the + // same defect the document check looks for one level down: the card's right + // edge is off screen while document.scrollWidth stays clean. + const wide = []; + for (const el of root.querySelectorAll('.spec-x-pane, aside, main')) { + const r = el.getBoundingClientRect(); + if (r.width === 0) continue; + if (r.right > vw + 1 || el.scrollWidth > el.clientWidth + 1) wide.push({ tag: el.tagName.toLowerCase(), cls: String(el.className).slice(0, 40), right: Math.round(r.right), sw: el.scrollWidth, cw: el.clientWidth }); + } + const asides = [...root.querySelectorAll('aside')].filter(a => a.getBoundingClientRect().width > 0); + const mains = [...root.querySelectorAll('main')].filter(m => m.getBoundingClientRect().width > 0); + const card = root.querySelector('[data-card]'); + const cr = card ? card.getBoundingClientRect() : null; + const back = root.querySelector('.spec-x-back'); + const br = back ? back.getBoundingClientRect() : null; + return { + root: true, tier: root.dataset.tier || null, + doc: { sw: de.scrollWidth, cw: de.clientWidth, sh: de.scrollHeight, ch: de.clientHeight, vw, vh }, + controls, scrollers, wide, panes: asides.length + mains.length, asides: asides.length, mains: mains.length, + card: card ? card.dataset.card : null, + cardRect: cr ? { top: Math.round(cr.top), left: Math.round(cr.left), right: Math.round(cr.right), bottom: Math.round(cr.bottom) } : null, + back: br ? { w: Math.round(br.width), h: Math.round(br.height) } : null, + }; +})()`; + +let failures = 0; +let warnings = 0; +console.log(` spec ${SPEC.specSha.slice(0, 12)} tiers phone<=${SPEC.PHONE_MAX} tablet<=${SPEC.TABLET_MAX} desktop<=${SPEC.DESKTOP_MAX} target>=${SPEC.TOUCH_TARGET_MIN_PX}px matrix ${SPEC.VIEWPORTS.join(' ')}`); +for (const [w, h] of SIZES) { + const tier = tierOf(w); + const compact = tier === 'phone' || tier === 'tablet'; + await call('Emulation.setDeviceMetricsOverride', { width: w, height: h, deviceScaleFactor: 1, mobile: compact }); + for (const x of EXPLORERS) { + await call('Page.navigate', { url: `${ORIGIN}/?lang=en${x.route}` }); + await sleep(SETTLE_MS); + let r; + try { r = await evaluate(PROBE); } catch (e) { failures++; console.log(` ${w}x${h} ${x.name.padEnd(9)} FAIL ${e.message}`); continue; } + const fail = []; + const warn = []; + if (!r.root) { failures++; console.log(` ${w}x${h} ${x.name.padEnd(9)} FAIL no .spec-x root rendered`); continue; } + if (r.tier !== tier) fail.push(`data-tier=${r.tier} expected ${tier}`); + if (r.doc.sw > r.doc.cw) fail.push(`document scrolls sideways: scrollWidth ${r.doc.sw} > clientWidth ${r.doc.cw}`); + if (r.wide.length) (compact ? fail : warn).push(`${r.wide.length} pane(s) wider than the viewport or scrolling sideways: ${r.wide.map(p => `${p.tag}.${p.cls || '-'} right=${p.right} ${p.sw}/${p.cw}`).join(', ')}`); + if (r.doc.sh > r.doc.ch) (compact && !SPEC.DOCUMENT_SCROLLS ? fail : warn).push(`document scrolls: scrollHeight ${r.doc.sh} > clientHeight ${r.doc.ch}`); + // Panes: phone shows one, the others two. + if (r.panes !== PANES[tier]) fail.push(`${r.panes} pane(s) visible (aside ${r.asides}, main ${r.mains}), spec says ${PANES[tier]}`); + // The deep link opened its card, inside the viewport. + if (x.card !== null) { + if (r.card !== x.card) fail.push(`card ${JSON.stringify(r.card)} open, deep link named ${JSON.stringify(x.card)}`); + else if (!r.cardRect || r.cardRect.top < 0 || r.cardRect.top >= r.doc.vh || r.cardRect.left < 0 || r.cardRect.right > r.doc.vw + 1) fail.push(`card not in viewport: ${JSON.stringify(r.cardRect)}`); + } else if (r.mains !== 1) fail.push(`detail pane not open (${r.mains} main)`); + if (tier === 'phone') { + if (!r.back) fail.push('no .spec-x-back control on the card'); + else if (r.back.h < SPEC.BACK_CONTROL_MIN_PX || r.back.w < SPEC.BACK_CONTROL_MIN_PX) fail.push(`back control ${r.back.w}x${r.back.h} < ${SPEC.BACK_CONTROL_MIN_PX}`); + } + if (compact) { + if (r.controls.length === 0) fail.push('zero controls matched — broken probe'); + const small = r.controls.filter(c => c.h < SPEC.TOUCH_TARGET_MIN_PX || c.w < SPEC.TOUCH_TARGET_MIN_PX); + if (small.length) fail.push(`${small.length} target(s) < ${SPEC.TOUCH_TARGET_MIN_PX}px: ${small.slice(0, 4).map(c => `${c.tag} ${c.w}x${c.h} "${c.text}"`).join(', ')}`); + } + if (SPEC.ONE_SCROLLER_PER_PANE && r.scrollers.length > PANES[tier]) { + (compact ? fail : warn).push(`${r.scrollers.length} live vertical scrollers for ${PANES[tier]} pane(s): ${r.scrollers.map(s => `${s.tag}.${s.cls || '-'} ${s.h}/${s.sh}`).join(', ')}`); + } + if (r.scrollers.length === 0 && x.card !== null) warn.push('no live scroller at all (short catalog?)'); + const shot = await call('Page.captureScreenshot', { format: 'png' }); + writeFileSync(join(SHOTS, `${x.name}-${w}x${h}.png`), Buffer.from(shot.data, 'base64')); + if (fail.length) { + failures++; + console.log(` ${w}x${h} ${x.name.padEnd(9)} FAIL ${fail.join(' ; ')}${warn.length ? ' ; warn: ' + warn.join(' ; ') : ''}`); + } else { + warnings += warn.length; + console.log(` ${w}x${h} ${x.name.padEnd(9)} PASS tier=${tier} panes=${r.panes} controls=${r.controls.length} scrollers=${r.scrollers.length}${warn.length ? ' warn: ' + warn.join(' ; ') : ''}`); + } + } +} + +if (errors.length) { + console.error(`\n ${errors.length} uncaught exception(s) while probing:`); + for (const e of errors.slice(0, 5)) console.error(` ${e.split('\n')[0]}`); + failures++; +} + +cleanup(); +if (failures) { + console.error(`\n Explorer viewport contract: FAIL (${failures} size/explorer combination(s))`); + process.exit(1); +} +console.log(`\n Explorer viewport contract: PASS (${SIZES.length} sizes × ${EXPLORERS.length} explorers, ${warnings} warning(s), screenshots in ${SHOTS})`); +process.exit(0); diff --git a/apps/website/scripts/agents-from-specs.mjs b/apps/website/scripts/agents-from-specs.mjs index 9f75e5159a..e2dee21d6a 100644 --- a/apps/website/scripts/agents-from-specs.mjs +++ b/apps/website/scripts/agents-from-specs.mjs @@ -338,6 +338,17 @@ export function shapeProblem(decl, shape) { if (type !== `[${value.length}]str`) return `annotated ${type}, holds ${value.length} string(s)` return null } + // Integer arrays ([N]u8, [N]u16, [N]u32), e.g. the viewport matrix widths and heights. + case 'arr-u8': + case 'arr-u16': + case 'arr-u32': { + const elem = shape.slice(4) + if (!Array.isArray(value)) return 'value is not an array' + const bad = value.find((x) => !Number.isInteger(x) || x < 0 || x > INT_MAX[elem]) + if (bad !== undefined) return `element ${bad} is not in ${elem} range` + if (type !== `[${value.length}]${elem}`) return `annotated ${type}, holds ${value.length} ${elem} value(s)` + return null + } default: return `unknown shape ${shape}` } diff --git a/apps/website/scripts/viewport-from-spec.mjs b/apps/website/scripts/viewport-from-spec.mjs new file mode 100644 index 0000000000..d28edc40cd --- /dev/null +++ b/apps/website/scripts/viewport-from-spec.mjs @@ -0,0 +1,315 @@ +#!/usr/bin/env node +// viewport-from-spec.mjs -- the site's viewport contract from `specs/ui/viewport.t27`. +// +// Reads the vendored `public/t27/files/specs/ui/viewport.t27` through the real compiler +// (`t27_compiler.wasm`), checks the constant schema, evaluates every `test` block of the +// spec against the declared constants, and writes two generated files nobody edits by hand: +// +// src/lib/viewport.generated.ts the constants, the matrix, and `tierOf(width)` +// src/styles/viewport.generated.css `:root { --bp-phone-max: 600px; ... }` +// +// Nothing here parses a `.t27` with a regular expression; constants and test asserts come +// from the compiler's AST. The asserts are evaluated here on purpose: the analyzer's +// `typecheck.ok` is necessary, not sufficient (it stays true for `assert 1 > 2`), so a spec +// whose own tests do not hold must not produce a green build. +// +// Run: node scripts/viewport-from-spec.mjs (write) +// node scripts/viewport-from-spec.mjs --check (fail if the committed files are stale) +// node scripts/viewport-from-spec.mjs --json (print the constants as JSON for the QA contracts) +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { SITE, checkSchema, constsOf, decodeBytes, loadCompiler, sha256, verdictOf } from './agents-from-specs.mjs' + +const WASM = 'public/t27/t27_compiler.wasm' +export const VIEWPORT_SPEC = 'public/t27/files/specs/ui/viewport.t27' +export const TS_OUT = 'src/lib/viewport.generated.ts' +export const CSS_OUT = 'src/styles/viewport.generated.css' +export const EXPECTED_MODULE = 'ui_viewport' + +export const VIEWPORT_REQUIRED = { + KIND: 'str', ID: 'str', NAME: 'str', GENERATED: 'arr', + TIERS: 'arr', PHONE_MAX: 'u16', TABLET_MAX: 'u16', DESKTOP_MAX: 'u16', COARSE_POINTER_QUERY: 'str', HEADER_CHROME_MAX: 'u16', + PHONE_PANES: 'u8', TABLET_PANES: 'u8', DESKTOP_PANES: 'u8', ONE_SCROLLER_PER_PANE: 'bool', DOCUMENT_SCROLLS: 'bool', + TOUCH_TARGET_MIN_PX: 'u8', BACK_CONTROL_MIN_PX: 'u8', + VIEWPORTS: 'arr', VIEWPORT_WIDTHS: 'arr-u16', VIEWPORT_HEIGHTS: 'arr-u16', VIEWPORT_TIERS: 'arr', DESKTOP_REFERENCE_WIDTH: 'u16', + RAIL_TILES: 'u8', RAIL_TILE_W_COMPACT: 'u8', RAIL_TILE_GAP_COMPACT: 'u8', RAIL_TILE_H_COMPACT: 'u8', RAIL_CLIENT_W_PHONE: 'u16', + RAIL_TILE_H_COLUMN: 'u8', RAIL_TILE_GAP_COLUMN: 'u8', RAIL_CHROME_H: 'u16', RAIL_ROW_H_COLUMN: 'u8', RAIL_ROW_W_COMPACT: 'u8', + RAIL_MIN_VISIBLE: 'u8', RAIL_CAPACITY_BY_HEIGHT: 'arr-u8', +} +export const TIER_NAMES = ['phone', 'tablet', 'desktop', 'wide'] + +// --------------------------------------------------------------------------- +// Test blocks. A tiny evaluator over the AST the compiler returns: identifiers are the +// spec's own constants, literals are integers / booleans / strings, `a[i]` indexes an +// array constant, and the binary operators are the integer and comparison operators the +// spec uses. Anything else is a failure, never a silent pass. +// --------------------------------------------------------------------------- +function literal(raw) { + const text = decodeBytes(raw) + if (text === 'true') return true + if (text === 'false') return false + if (/^-?\d+$/.test(text)) return Number(text) + return JSON.parse(text) +} + +export function evalExpr(node, env) { + switch (node.kind) { + case 'ExprLiteral': + return literal(node.value) + case 'ExprIdentifier': { + if (!(node.name in env)) throw new Error(`unknown identifier ${node.name}`) + return env[node.name] + } + case 'ExprIndex': { + const [target, index] = node.children ?? [] + const arr = evalExpr(target, env) + const i = evalExpr(index, env) + if (!Array.isArray(arr)) throw new Error(`indexing a non-array (${target.name ?? target.kind})`) + if (!Number.isInteger(i) || i < 0 || i >= arr.length) throw new Error(`index ${i} out of range for ${target.name ?? 'array'}[${arr.length}]`) + return arr[i] + } + case 'ExprBinary': { + const [l, r] = (node.children ?? []).map((c) => evalExpr(c, env)) + switch (node.op) { + case '+': return int(l) + int(r) + case '-': return int(l) - int(r) + case '*': return int(l) * int(r) + case '/': if (int(r) === 0) throw new Error('division by zero'); return Math.trunc(int(l) / int(r)) + case '%': if (int(r) === 0) throw new Error('modulo by zero'); return int(l) % int(r) + case '<': return int(l) < int(r) + case '<=': return int(l) <= int(r) + case '>': return int(l) > int(r) + case '>=': return int(l) >= int(r) + case '==': return same(l, r) + case '!=': return !same(l, r) + default: throw new Error(`unsupported operator ${node.op}`) + } + } + default: + throw new Error(`unsupported expression ${node.kind}`) + } +} +const int = (v) => { if (!Number.isInteger(v)) throw new Error(`arithmetic on a non-integer (${JSON.stringify(v)})`); return v } +const same = (a, b) => (typeof a === typeof b ? a === b : (() => { throw new Error(`comparing ${typeof a} with ${typeof b}`) })()) + +/** Every assert of every test block, evaluated. Returns { tests, asserts, failures[] }. */ +export function runSpecTests(analysis, env) { + const blocks = (analysis.ast?.children ?? []).filter((n) => n.kind === 'TestBlock') + let asserts = 0 + const failures = [] + for (const b of blocks) { + for (const stmt of b.children ?? []) { + const call = stmt.kind === 'StmtExpr' ? stmt.children?.[0] : stmt + if (!call || call.kind !== 'ExprCall' || call.name !== 'assert') { + failures.push(`${b.name}: statement is not an assert (${call?.kind ?? stmt.kind}${call?.name ? ' ' + call.name : ''}); a \`;\` comment inside a block is parsed as code`) + continue + } + if ((call.children ?? []).length !== 1) { failures.push(`${b.name}: assert takes one expression`); continue } + asserts++ + try { + const v = evalExpr(call.children[0], env) + if (v !== true) failures.push(`${b.name}: assert #${asserts} is ${JSON.stringify(v)}`) + } catch (e) { + failures.push(`${b.name}: ${e.message}`) + } + } + } + return { tests: blocks.length, asserts, failures } +} + +// --------------------------------------------------------------------------- +// Semantic checks the schema cannot express. +// --------------------------------------------------------------------------- +export function semanticProblems(f, file) { + const problems = [] + if (f.KIND !== 'viewport') problems.push(`${file}: KIND must be "viewport"`) + if (JSON.stringify(f.TIERS) !== JSON.stringify(TIER_NAMES)) problems.push(`${file}: TIERS must be ${JSON.stringify(TIER_NAMES)}`) + const n = f.VIEWPORTS.length + if (f.VIEWPORT_WIDTHS.length !== n || f.VIEWPORT_HEIGHTS.length !== n || f.VIEWPORT_TIERS.length !== n) { + problems.push(`${file}: VIEWPORTS, VIEWPORT_WIDTHS, VIEWPORT_HEIGHTS, VIEWPORT_TIERS must have one entry each per size`) + } + for (let i = 0; i < n; i++) { + if (f.VIEWPORTS[i] !== `${f.VIEWPORT_WIDTHS[i]}x${f.VIEWPORT_HEIGHTS[i]}`) problems.push(`${file}: VIEWPORTS[${i}] ${f.VIEWPORTS[i]} is not ${f.VIEWPORT_WIDTHS[i]}x${f.VIEWPORT_HEIGHTS[i]}`) + const tier = tierOf(f.VIEWPORT_WIDTHS[i], f) + if (f.VIEWPORT_TIERS[i] !== tier) problems.push(`${file}: VIEWPORT_TIERS[${i}] says ${f.VIEWPORT_TIERS[i]}, the bounds say ${tier}`) + } + if (f.RAIL_CAPACITY_BY_HEIGHT.length !== n) problems.push(`${file}: RAIL_CAPACITY_BY_HEIGHT must have one entry per size`) + if (!f.GENERATED.includes(TS_OUT) || !f.GENERATED.includes(CSS_OUT)) problems.push(`${file}: GENERATED must name ${TS_OUT} and ${CSS_OUT}`) + return problems +} + +export function tierOf(width, f) { + if (width <= f.PHONE_MAX) return 'phone' + if (width <= f.TABLET_MAX) return 'tablet' + if (width <= f.DESKTOP_MAX) return 'desktop' + return 'wide' +} + +// --------------------------------------------------------------------------- +// Emission. Deterministic: the same spec bytes give the same two files. +// --------------------------------------------------------------------------- +const kebab = (name) => name.toLowerCase().replace(/_/g, '-') + +export function renderTs(f, specSha) { + const matrix = f.VIEWPORTS.map((label, i) => ` { label: ${JSON.stringify(label)}, width: ${f.VIEWPORT_WIDTHS[i]}, height: ${f.VIEWPORT_HEIGHTS[i]}, tier: ${JSON.stringify(f.VIEWPORT_TIERS[i])}, railCapacity: ${f.RAIL_CAPACITY_BY_HEIGHT[i]} },`) + return `// GENERATED by scripts/viewport-from-spec.mjs from ${VIEWPORT_SPEC} +// spec sha256 ${specSha} +// Do not edit by hand: change specs/ui/viewport.t27 in gHashTag/t27, re-vendor, re-run the generator. + +export type ViewportTier = ${TIER_NAMES.map((t) => JSON.stringify(t)).join(' | ')} +/** The tiers, narrowest first. */ +export const TIERS_ORDER: readonly ViewportTier[] = ${JSON.stringify(f.TIERS)} + +/** Inclusive upper bound of the phone tier (CSS px, window.innerWidth). */ +export const PHONE_MAX = ${f.PHONE_MAX} +/** Inclusive upper bound of the tablet tier. */ +export const TABLET_MAX = ${f.TABLET_MAX} +/** Inclusive upper bound of the desktop tier; wider is "wide". */ +export const DESKTOP_MAX = ${f.DESKTOP_MAX} +export const COARSE_POINTER_QUERY = ${JSON.stringify(f.COARSE_POINTER_QUERY)} +/** Below this width the explorer header drops subtitle and note; inside the desktop tier, not a tier. */ +export const HEADER_CHROME_MAX = ${f.HEADER_CHROME_MAX} + +/** Smallest interactive box, each side, on phone and tablet. */ +export const TOUCH_TARGET_MIN_PX = ${f.TOUCH_TARGET_MIN_PX} +export const BACK_CONTROL_MIN_PX = ${f.BACK_CONTROL_MIN_PX} + +export const PHONE_PANES = ${f.PHONE_PANES} +export const TABLET_PANES = ${f.TABLET_PANES} +export const DESKTOP_PANES = ${f.DESKTOP_PANES} +export const ONE_SCROLLER_PER_PANE = ${f.ONE_SCROLLER_PER_PANE} +export const DOCUMENT_SCROLLS = ${f.DOCUMENT_SCROLLS} + +/** The width the desktop layout must stay pixel-identical at across a change. */ +export const DESKTOP_REFERENCE_WIDTH = ${f.DESKTOP_REFERENCE_WIDTH} + +export interface ViewportSize { + label: string + width: number + height: number + tier: ViewportTier + /** Queen rail tiles the one-column rail holds at this height (model, see the spec). */ + railCapacity: number +} + +/** The QA matrix every viewport contract iterates. */ +export const VIEWPORTS: readonly ViewportSize[] = [ +${matrix.join('\n')} +] + +export const RAIL_TILES = ${f.RAIL_TILES} +export const RAIL_MIN_VISIBLE = ${f.RAIL_MIN_VISIBLE} +export const RAIL_TILE_W_COMPACT = ${f.RAIL_TILE_W_COMPACT} +export const RAIL_TILE_H_COMPACT = ${f.RAIL_TILE_H_COMPACT} +export const RAIL_TILE_H_COLUMN = ${f.RAIL_TILE_H_COLUMN} + +/** The tier a CSS viewport width falls into, by the inclusive bounds above. */ +export function tierOf(width: number): ViewportTier { + if (width <= PHONE_MAX) return 'phone' + if (width <= TABLET_MAX) return 'tablet' + if (width <= DESKTOP_MAX) return 'desktop' + return 'wide' +} + +/** The media query that is true exactly on the given tier. */ +export function tierQuery(tier: ViewportTier): string { + switch (tier) { + case 'phone': return \`(max-width: \${PHONE_MAX}px)\` + case 'tablet': return \`(min-width: \${PHONE_MAX + 1}px) and (max-width: \${TABLET_MAX}px)\` + case 'desktop': return \`(min-width: \${TABLET_MAX + 1}px) and (max-width: \${DESKTOP_MAX}px)\` + case 'wide': return \`(min-width: \${DESKTOP_MAX + 1}px)\` + } +} +` +} + +export function renderCss(f, specSha) { + const px = (name) => ` --${kebab(name)}: ${f[name]}px;` + return `/* GENERATED by scripts/viewport-from-spec.mjs from ${VIEWPORT_SPEC} + spec sha256 ${specSha} + Do not edit by hand: change specs/ui/viewport.t27 in gHashTag/t27, re-vendor, re-run the generator. + Custom properties cannot drive @media, so the tiers are applied by useViewport (data-tier); + these values are for sizes inside a tier (touch targets, the back control). */ +:root { + --bp-phone-max: ${f.PHONE_MAX}px; + --bp-tablet-max: ${f.TABLET_MAX}px; + --bp-desktop-max: ${f.DESKTOP_MAX}px; +${px('TOUCH_TARGET_MIN_PX')} +${px('BACK_CONTROL_MIN_PX')} +${px('RAIL_TILE_W_COMPACT')} +${px('RAIL_TILE_H_COMPACT')} +${px('RAIL_TILE_H_COLUMN')} +} +` +} + +// --------------------------------------------------------------------------- +// Build. +// --------------------------------------------------------------------------- +export async function buildViewport({ specText, analyze }) { + const problems = [] + const analysis = analyze(specText) + const verdict = verdictOf(analysis) + const file = VIEWPORT_SPEC.replace(/^public\/t27\/files\//, '') + if (!verdict.typecheckOk || verdict.discarded > 0 || !verdict.hirOk) problems.push(`${file}: compiler verdict not clean (${JSON.stringify(verdict)})`) + if (/[^\x00-\x7f]/.test(specText)) problems.push(`${file}: non-ASCII byte in the spec (L3)`) + const moduleName = analysis.ast?.name ?? null + if (moduleName !== EXPECTED_MODULE) problems.push(`${file}: module must be ${EXPECTED_MODULE}, is ${moduleName}`) + let consts = {} + try { consts = constsOf(analysis) } catch (e) { problems.push(`${file}: ${e.message}`) } + problems.push(...checkSchema(consts, VIEWPORT_REQUIRED, {}, file)) + const f = Object.fromEntries(Object.entries(consts).map(([k, v]) => [k, v.value])) + let tests = { tests: 0, asserts: 0, failures: [] } + if (problems.length === 0) { + problems.push(...semanticProblems(f, file)) + tests = runSpecTests(analysis, f) + if (tests.tests === 0) problems.push(`${file}: no test block; the spec must test its own invariants`) + problems.push(...tests.failures.map((m) => `${file}: test ${m}`)) + } + const specSha = sha256(Buffer.from(specText, 'utf8')) + return { + problems, verdict, fields: f, specSha, tests, + ts: problems.length ? null : renderTs(f, specSha), + css: problems.length ? null : renderCss(f, specSha), + } +} + +async function main() { + const check = process.argv.includes('--check') + const json = process.argv.includes('--json') + const specPath = join(SITE, VIEWPORT_SPEC) + if (!existsSync(specPath)) { console.error(`viewport-from-spec: ${VIEWPORT_SPEC} is not vendored`); process.exit(1) } + const analyze = await loadCompiler(readFileSync(join(SITE, WASM))) + const specText = readFileSync(specPath, 'utf8') + const out = await buildViewport({ specText, analyze }) + if (out.problems.length) { + console.error(`viewport-from-spec: ${out.problems.length} problem(s)`) + for (const p of out.problems) console.error(' ' + p) + process.exit(1) + } + if (json) { + // For qa/explorer-viewport-contract.mjs: the matrix and thresholds without importing TypeScript. + console.log(JSON.stringify({ specSha: out.specSha, ...out.fields })) + return + } + const targets = [[TS_OUT, out.ts], [CSS_OUT, out.css]] + if (check) { + const stale = targets.filter(([rel, text]) => !existsSync(join(SITE, rel)) || readFileSync(join(SITE, rel), 'utf8') !== text) + if (stale.length) { + console.error(`viewport-from-spec --check: stale ${stale.map(([r]) => r).join(', ')}; run node scripts/viewport-from-spec.mjs`) + process.exit(1) + } + } else { + for (const [rel, text] of targets) { + mkdirSync(dirname(join(SITE, rel)), { recursive: true }) + writeFileSync(join(SITE, rel), text) + } + } + const f = out.fields + console.log(`viewport-from-spec: ${check ? 'up to date' : 'wrote'} ${TS_OUT}, ${CSS_OUT}; spec sha256 ${out.specSha.slice(0, 16)}; tiers phone<=${f.PHONE_MAX} tablet<=${f.TABLET_MAX} desktop<=${f.DESKTOP_MAX}; matrix ${f.VIEWPORTS.join(' ')}; spec tests ${out.tests.tests}, asserts ${out.tests.asserts}, all hold`) +} + +if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { + main().catch((e) => { console.error(e); process.exit(1) }) +} diff --git a/apps/website/scripts/viewport-from-spec.test.mjs b/apps/website/scripts/viewport-from-spec.test.mjs new file mode 100644 index 0000000000..f71454fb9d --- /dev/null +++ b/apps/website/scripts/viewport-from-spec.test.mjs @@ -0,0 +1,100 @@ +// What the viewport generator refuses, and what it lets through. +// +// The interesting cases are the refusals: a spec whose own `test` block does not +// hold (the analyzer's typecheck.ok stays true for `assert 1 > 2`), a `;` comment +// inside a test block (the parser reads it as a statement, so it must be +// reported, not skipped), a matrix whose declared tiers disagree with the +// bounds, a non-ASCII byte. The vendored spec itself must build, and the two +// emitted files must be a pure function of the spec bytes. +// +// node --test scripts/viewport-from-spec.test.mjs + +import test from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { SITE, loadCompiler, sha256 } from './agents-from-specs.mjs' +import { CSS_OUT, TS_OUT, VIEWPORT_SPEC, buildViewport, tierOf } from './viewport-from-spec.mjs' + +const analyze = await loadCompiler(readFileSync(join(SITE, 'public/t27/t27_compiler.wasm'))) +const vendored = readFileSync(join(SITE, VIEWPORT_SPEC), 'utf8') + +// Edit the vendored spec's constants by text substitution on one declaration line; +// the result is fed to the real compiler, never parsed here. +const withConst = (src, name, value) => { + const re = new RegExp(`^(pub const ${name} : [a-z0-9\\[\\]]+ = )[^;]+;`, 'm') + assert.match(src, re, `${name} is declared once in the spec`) + return src.replace(re, `$1${value};`) +} + +test('the vendored spec builds: clean verdict, tests evaluated, two files rendered', async () => { + const out = await buildViewport({ specText: vendored, analyze }) + assert.deepEqual(out.problems, []) + assert.equal(out.verdict.typecheckOk, true) + assert.ok(out.tests.tests >= 6, `test blocks: ${out.tests.tests}`) + assert.ok(out.tests.asserts >= 40, `asserts: ${out.tests.asserts}`) + assert.deepEqual(out.tests.failures, []) + assert.equal(out.specSha, sha256(Buffer.from(vendored, 'utf8'))) + assert.match(out.ts, new RegExp(`spec sha256 ${out.specSha}`)) + assert.match(out.css, new RegExp(`spec sha256 ${out.specSha}`)) + assert.equal(out.fields.PHONE_MAX, 600) + assert.equal(out.fields.TABLET_MAX, 1024) + assert.equal(out.fields.TOUCH_TARGET_MIN_PX, 44) + assert.equal(out.fields.VIEWPORTS.length, 6) +}) + +test('the committed generated files are the ones the vendored spec produces', async () => { + const out = await buildViewport({ specText: vendored, analyze }) + assert.equal(readFileSync(join(SITE, TS_OUT), 'utf8'), out.ts, `${TS_OUT} is stale; run node scripts/viewport-from-spec.mjs`) + assert.equal(readFileSync(join(SITE, CSS_OUT), 'utf8'), out.css, `${CSS_OUT} is stale; run node scripts/viewport-from-spec.mjs`) +}) + +test('emission is deterministic: same bytes in, same bytes out', async () => { + const a = await buildViewport({ specText: vendored, analyze }) + const b = await buildViewport({ specText: vendored, analyze }) + assert.equal(a.ts, b.ts) + assert.equal(a.css, b.css) +}) + +test('a false assert in the spec is a build failure even though typecheck.ok stays true', async () => { + // TOUCH_TARGET_MIN_PX = 40 breaks `assert TOUCH_TARGET_MIN_PX >= 44` and the + // back-control test; nothing about it is a type error. + const src = withConst(vendored, 'TOUCH_TARGET_MIN_PX', '40') + const out = await buildViewport({ specText: src, analyze }) + assert.equal(out.verdict.typecheckOk, true, 'typecheck alone would have passed this') + assert.ok(out.problems.some((p) => /test .*assert #\d+ is false/.test(p)), out.problems.join('\n')) + assert.equal(out.ts, null) +}) + +test('a `;` comment inside a test block is reported, not silently skipped', async () => { + const src = vendored.replace('test breakpoints_are_monotone {\n', 'test breakpoints_are_monotone {\n ; this is a comment the parser reads as a statement\n') + assert.notEqual(src, vendored) + const out = await buildViewport({ specText: src, analyze }) + assert.ok(out.problems.some((p) => /not an assert/.test(p)), out.problems.join('\n')) +}) + +test('a matrix tier that disagrees with the bounds is refused', async () => { + // 600 is PHONE_MAX, so 600x900 is phone; moving PHONE_MAX below it makes the + // declared VIEWPORT_TIERS wrong and the spec's own tests fail too. + const src = withConst(vendored, 'PHONE_MAX', '599') + const out = await buildViewport({ specText: src, analyze }) + assert.ok(out.problems.some((p) => /VIEWPORT_TIERS\[\d+\] says phone, the bounds say tablet/.test(p)), out.problems.join('\n')) +}) + +test('a non-ASCII byte is refused (L3)', async () => { + const src = vendored.replace('// SPDX-License-Identifier: Apache-2.0', '// SPDX-License-Identifier: Apache-2.0 \u2014 nope') + assert.notEqual(src, vendored) + const out = await buildViewport({ specText: src, analyze }) + assert.ok(out.problems.some((p) => /non-ASCII/.test(p)), out.problems.join('\n')) +}) + +test('tierOf follows the inclusive bounds', () => { + const f = { PHONE_MAX: 600, TABLET_MAX: 1024, DESKTOP_MAX: 1600 } + assert.equal(tierOf(390, f), 'phone') + assert.equal(tierOf(600, f), 'phone') + assert.equal(tierOf(601, f), 'tablet') + assert.equal(tierOf(1024, f), 'tablet') + assert.equal(tierOf(1025, f), 'desktop') + assert.equal(tierOf(1600, f), 'desktop') + assert.equal(tierOf(1601, f), 'wide') +}) diff --git a/apps/website/src/components/AgentSpecPanel.tsx b/apps/website/src/components/AgentSpecPanel.tsx index 931826cfd0..898a044d4f 100644 --- a/apps/website/src/components/AgentSpecPanel.tsx +++ b/apps/website/src/components/AgentSpecPanel.tsx @@ -182,6 +182,12 @@ interface Props { i18n?: I18nContract[] /** Extra buttons the page adds to the management strip (an agent's SOUL.md, its experience log). */ extraControls?: React.ReactNode + /** + * The source box has no scroller of its own: on a phone or a tablet the card + * pane is the one scroller (spec ONE_SCROLLER_PER_PANE), so the 420px box + * that nests a second one on desktop opens out to its full height. + */ + flat?: boolean } async function sha256Hex(text: string): Promise { @@ -202,7 +208,7 @@ function label(kind: Props['kind'], t: Copy): { links: string; empty: string } { const SPEC_DIR: Record = { skill: 'specs/skills', cron: 'specs/crons', agent: 'specs/agents', function: 'specs/functions', tool: 'specs/tools' } const LINK_GLYPH: Record = { skill: '◷', cron: '⟲', agent: '◈', function: '⟲', tool: 'Ω' } -export function AgentSpecPanel({ lang, kind, id, entry, links, embedded, i18n = [], extraControls }: Props) { +export function AgentSpecPanel({ lang, kind, id, entry, links, embedded, i18n = [], extraControls, flat }: Props) { const t: Copy = COPY[lang] // An agent is not a job: the control plane's run/enable/disable verbs do not // apply to a letter, so the plane is shown for skills and crons only. @@ -274,7 +280,12 @@ export function AgentSpecPanel({ lang, kind, id, entry, links, embedded, i18n = ) const heading = (text: string) =>
{text}
- const box: React.CSSProperties = { background: C.panel, border: `1px solid ${C.border}`, borderRadius: 6, padding: 12, display: 'flex', flexDirection: 'column', gap: 8 } + // Longhands, not the `border` shorthand: the code-only branch below overrides + // borderColor, and when a later render drops that override React clears the + // longhand, which strips the colour out of the shorthand (currentColor, i.e. + // a white frame). Keeping borderColor a stable key makes the swap a value + // change, not a removal. + const box: React.CSSProperties = { background: C.panel, borderWidth: 1, borderStyle: 'solid', borderColor: C.border, borderRadius: 6, padding: 12, display: 'flex', flexDirection: 'column', gap: 8 } const runNow = useMemo(() => { if (!entry || kind !== 'cron') return null @@ -302,6 +313,7 @@ export function AgentSpecPanel({ lang, kind, id, entry, links, embedded, i18n =
{t.codeOnly}
{entry.inSpecCorpus ? ( - + {t.openSpec} ) : ( - {t.notInCorpus} + {t.notInCorpus} )}
{entry.messages.length > 0 && ( @@ -384,7 +396,7 @@ export function AgentSpecPanel({ lang, kind, id, entry, links, embedded, i18n = {entry.messages.join(' · ')}
)} -
+
{err ? (
{t.failed} {err} @@ -420,7 +432,7 @@ export function AgentSpecPanel({ lang, kind, id, entry, links, embedded, i18n =
{links.map((l) => l.ok ? ( - + {LINK_GLYPH[kind]} {l.id} ) : ( @@ -447,10 +459,10 @@ export function AgentSpecPanel({ lang, kind, id, entry, links, embedded, i18n = {t.enabled} = {entry.fields.ENABLED ? t.on : t.off} ) : null} - + {t.editSpec} - + {t.vendored} {extraControls} @@ -465,7 +477,7 @@ export function AgentSpecPanel({ lang, kind, id, entry, links, embedded, i18n = ) : kind === 'cron' && runNow ? ( runNow.kind === 'link' ? ( <> - + {t.runNow} diff --git a/apps/website/src/components/ExplorerHeader.tsx b/apps/website/src/components/ExplorerHeader.tsx index 3d277368ae..b86c9c9ca1 100644 --- a/apps/website/src/components/ExplorerHeader.tsx +++ b/apps/website/src/components/ExplorerHeader.tsx @@ -62,7 +62,7 @@ export function ExplorerHeader({ overflow: 'hidden', }} > - + {back}
diff --git a/apps/website/src/components/ExplorerLibrary.tsx b/apps/website/src/components/ExplorerLibrary.tsx index c55d5a065b..bca6f42a1f 100644 --- a/apps/website/src/components/ExplorerLibrary.tsx +++ b/apps/website/src/components/ExplorerLibrary.tsx @@ -9,6 +9,7 @@ import { useMemo, useState } from 'react' import { C, HEALTH_GLYPH, tagChip, inputStyle, selectStyle, type Health } from '../lib/explorerTheme' import { HealthDot, StackBar, type StackSegment } from './SpecGraphics' +import type { ViewportTier } from '../lib/viewport.generated' export interface ExplorerItem { id: string @@ -56,7 +57,7 @@ export function ExplorerLibrary({ tagSel, toggleTag, clearTags, - phone, + tier, countLabel, titlesAreGenerated, ui, @@ -80,7 +81,13 @@ export function ExplorerLibrary({ tagSel: string[] toggleTag: (t: string) => void clearTags: () => void - phone: boolean + /** + * From useViewport. Phone: the aside is the whole screen. Phone and tablet: + * the category, group and tag controls fold into one collapsible row under + * the search box, so the list keeps the height (spec: TABLET_PANES = 2 with + * filters as a collapsible row). Desktop: unchanged. + */ + tier: ViewportTier /** e.g. "12 skills" — already interpolated by the page. */ countLabel: string /** @@ -96,9 +103,16 @@ export function ExplorerLibrary({ clear: string noResults: string noneInGroup: string + filters: string } }) { + const phone = tier === 'phone' + const compact = tier === 'phone' || tier === 'tablet' const [tagsOpen, setTagsOpen] = useState(false) + const [filtersOpen, setFiltersOpen] = useState(false) + // How many constraints are live while the row is folded, so a filtered list + // never looks like the whole library. + const activeFilters = (category ? 1 : 0) + (filters.length > 0 && filter !== filters[0].key ? 1 : 0) + tagSel.length const allTags = useMemo( () => Object.keys(tagCounts).sort((a, b) => (tagCounts[b] ?? 0) - (tagCounts[a] ?? 0) || a.localeCompare(b)), [tagCounts], @@ -107,7 +121,7 @@ export function ExplorerLibrary({ return (