diff --git a/.beads/.gitignore b/.beads/.gitignore index 92031504..549fa02e 100644 --- a/.beads/.gitignore +++ b/.beads/.gitignore @@ -1,7 +1,11 @@ # Dolt database (managed by Dolt, not git) dolt/ +embeddeddolt/ dolt-access.lock +# Transient auto-import staging file +.auto-import-issues.jsonl + # Runtime files bd.sock sync-state.json diff --git a/.beads/interactions.jsonl b/.beads/interactions.jsonl index afed5e17..523a9140 100644 --- a/.beads/interactions.jsonl +++ b/.beads/interactions.jsonl @@ -491,3 +491,162 @@ {"id":"int-3e09a1fe","kind":"field_change","created_at":"2026-07-23T05:15:28.991323Z","actor":"James Lal","issue_id":"attn-ij9y","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Closed"}} {"id":"int-a0a3dce8","kind":"field_change","created_at":"2026-07-23T14:49:31.783506Z","actor":"James Lal","issue_id":"attn-9ek7","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Resolved by the core projection (commit 50dbe9e). The sidebar 'shared' badges + unread counts derive from reviewStore.snapshots + room role, which the projection now populates and stamps role='owner' in EVERY tab (adoptRoom) — same store the leader uses, so follower tabs light up identically (proven by the byte-identical two-tab convergence). The old watchReviewLog seam is fully replaced by openReviewProjection. Residual ownerState-fallback chains (reviewRoomActive etc.) now read correctly because the projection feeds reviewStoreRef.currentRoomId in every tab; simplifying them further is cosmetic, not a bug."}} {"id":"int-9d31b50a","kind":"field_change","created_at":"2026-07-23T16:18:27.305147Z","actor":"James Lal","issue_id":"attn-qs03","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Closed"}} +{"id":"int-7c31cef3f23130fd71736c4ffada1cfd","kind":"field_change","created_at":"2026-08-04T15:18:06.354763Z","actor":"Angus Bezzina","issue_id":"attn-rd3j.1","extra":{"field":"status","new_value":"open","old_value":"in_progress"}} +{"id":"int-5d6ff32c7e7af957c205c1c4d0202644","kind":"field_change","created_at":"2026-08-04T15:18:06.763206Z","actor":"Angus Bezzina","issue_id":"attn-rd3j.3","extra":{"field":"status","new_value":"open","old_value":"in_progress"}} +{"id":"int-ad5c4373d5775957fdcf66b8176389e0","kind":"field_change","created_at":"2026-08-04T15:18:07.191229Z","actor":"Angus Bezzina","issue_id":"attn-rd3j.4","extra":{"field":"status","new_value":"open","old_value":"in_progress"}} +{"id":"int-40bbfb828ce72f090c1219912087354e","kind":"field_change","created_at":"2026-08-04T15:18:07.596199Z","actor":"Angus Bezzina","issue_id":"attn-rd3j.5","extra":{"field":"status","new_value":"open","old_value":"in_progress"}} +{"id":"int-da0f173585ef89f4350fa1d7b78a1782","kind":"field_change","created_at":"2026-08-04T15:18:08.020149Z","actor":"Angus Bezzina","issue_id":"attn-rd3j.8","extra":{"field":"status","new_value":"open","old_value":"in_progress"}} +{"id":"int-713983276f6f9d6c5c773be9f89f958b","kind":"field_change","created_at":"2026-08-04T15:18:08.411475Z","actor":"Angus Bezzina","issue_id":"attn-rd3j.9","extra":{"field":"status","new_value":"open","old_value":"in_progress"}} +{"id":"int-d42027c73c1ba2cbe4cff9926a2ea2bd","kind":"field_change","created_at":"2026-08-04T15:18:08.839244Z","actor":"Angus Bezzina","issue_id":"attn-rd3j.10","extra":{"field":"status","new_value":"open","old_value":"in_progress"}} +{"id":"int-b61153fbaaea61b11f28463a91a5502e","kind":"field_change","created_at":"2026-08-04T16:51:13.023907Z","actor":"Angus Bezzina","issue_id":"attn-rd3j.1","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Closed"}} +{"id":"int-8b80edb3e576e3008cdefb9d7d385920","kind":"field_change","created_at":"2026-08-04T16:51:13.323592Z","actor":"Angus Bezzina","issue_id":"attn-rd3j.2","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Closed"}} +{"id":"int-6cff787806ec84e467cada6c6f39887e","kind":"field_change","created_at":"2026-08-04T16:51:13.646987Z","actor":"Angus Bezzina","issue_id":"attn-rd3j.3","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Closed"}} +{"id":"int-1b612cbb9cede96afaf928a4ab74acb6","kind":"field_change","created_at":"2026-08-04T16:51:13.946214Z","actor":"Angus Bezzina","issue_id":"attn-rd3j.4","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Closed"}} +{"id":"int-a408e511ab0ac9919e67f47febaa63b2","kind":"field_change","created_at":"2026-08-04T16:51:14.257088Z","actor":"Angus Bezzina","issue_id":"attn-rd3j.5","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Closed"}} +{"id":"int-e9a31d2c1a54bdc022f68bb85e0e25f3","kind":"field_change","created_at":"2026-08-04T16:51:14.795318Z","actor":"Angus Bezzina","issue_id":"attn-rd3j.6","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Closed"}} +{"id":"int-e2e1fc1e468630fa0803b0bf02145ef2","kind":"field_change","created_at":"2026-08-04T16:51:15.098947Z","actor":"Angus Bezzina","issue_id":"attn-rd3j.7","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Closed"}} +{"id":"int-8c6d28289f661ceabc541baaac238215","kind":"field_change","created_at":"2026-08-04T16:51:15.390843Z","actor":"Angus Bezzina","issue_id":"attn-rd3j.8","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Closed"}} +{"id":"int-b83f7a1de3e22c97e5c8cf5fa01675f6","kind":"field_change","created_at":"2026-08-04T16:51:15.687939Z","actor":"Angus Bezzina","issue_id":"attn-rd3j.9","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Closed"}} +{"id":"int-8c2182ea592a223c088e0ea84070a515","kind":"field_change","created_at":"2026-08-04T16:51:15.990012Z","actor":"Angus Bezzina","issue_id":"attn-rd3j.10","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Closed"}} +{"id":"int-068dead36c807216511ec15792a25693","kind":"field_change","created_at":"2026-08-04T16:51:28.922784Z","actor":"Angus Bezzina","issue_id":"attn-rd3j","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"All 10 child issues implemented and verified live in the app. Full regression pass: 97 web test files green, 1207 Rust tests green, E2E and review-E2E identical to the pre-change baseline (their failures are pre-existing), release binary 32.09/40 MiB."}} +{"id":"int-b28b4e92bc6fadc493d198cd9cfbbeac","kind":"field_change","created_at":"2026-08-04T18:08:30.316482Z","actor":"Angus Bezzina","issue_id":"attn-rd3j.11","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Share dialog fits without body scrolling (file list capped against the window); sidebar empty state inset to match siblings; settings moved into the header with appearance switching; code/table vertical rhythm restored."}} +{"id":"int-13dea0be9a7a447ff4fc0f2d3dc841f4","kind":"field_change","created_at":"2026-08-04T23:29:32.523053Z","actor":"Angus Bezzina","issue_id":"attn-n01r.13","extra":{"field":"priority","new_value":"1","old_value":"2"}} +{"id":"int-c610466f2483107c1cc26875110374e2","kind":"field_change","created_at":"2026-08-04T23:34:57.13323Z","actor":"Angus Bezzina","issue_id":"attn-n01r.1","extra":{"field":"priority","new_value":"1","old_value":"2"}} +{"id":"int-7ef06cf7a00b7bb16c3c7f5864e02d13","kind":"field_change","created_at":"2026-08-04T23:43:00.291033Z","actor":"Angus Bezzina","issue_id":"attn-n01r.41","extra":{"field":"priority","new_value":"0","old_value":"1"}} +{"id":"int-aba81a6c3ebde89f054f4ab577213ea9","kind":"field_change","created_at":"2026-08-04T23:43:40.63649Z","actor":"Angus Bezzina","issue_id":"attn-n01r.42","extra":{"field":"priority","new_value":"0","old_value":"1"}} +{"id":"int-30cb1e67adf9cbccb7e43c02b1aad001","kind":"field_change","created_at":"2026-08-04T23:45:47.952396Z","actor":"Angus Bezzina","issue_id":"attn-n01r.6","extra":{"field":"priority","new_value":"1","old_value":"2"}} +{"id":"int-54ea2280dc4cd0e9ae707f597c70b3b2","kind":"field_change","created_at":"2026-08-04T23:45:48.581682Z","actor":"Angus Bezzina","issue_id":"attn-n01r.43","extra":{"field":"priority","new_value":"1","old_value":"2"}} +{"id":"int-1c5966b4531d9ed3470bb68181f2bbbc","kind":"field_change","created_at":"2026-08-04T23:47:18.508367Z","actor":"Angus Bezzina","issue_id":"attn-05x","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Design note landed at planning/collab/html-annotation.md; decisions #19/#20 added to amendments.md; prototype README refreshed. Supersedes Design B (distinct localhost origin) — unimplementable for the hosted browser reviewer, which has no Rust process and renders via srcdoc."}} +{"id":"int-bc0535c105457066f4efae83c9e89ac9","kind":"field_change","created_at":"2026-08-04T23:52:37.577835Z","actor":"Angus Bezzina","issue_id":"attn-n01r.41","extra":{"field":"status","new_value":"in_progress","old_value":"open"}} +{"id":"int-9721e6e74435d4002af3d3e4cdb8262c","kind":"field_change","created_at":"2026-08-05T00:00:32.245238Z","actor":"Angus Bezzina","issue_id":"attn-n01r.41","extra":{"field":"status","new_value":"open","old_value":"in_progress"}} +{"id":"int-4d08ca4a5ef90c161f15ddfc878688d4","kind":"field_change","created_at":"2026-08-05T02:16:44.057261Z","actor":"Angus Bezzina","issue_id":"attn-n01r.22","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Fixed and verified by frame capture.\n\nMECHANISM: added a pre-paint inline theme stamp to every hosted entry, admitted by CSP source hash.\n- src/lib/hosted/theme-preflight.ts — the script (one copy) + its sha256, with the rationale.\n- vite.browser.config.ts injectThemePreflight() — stamps it head-prepend at build AND serve, so dev\n matches production. One source, three entries, no drift.\n- src/lib/hosted/csp.ts — script-src now carries 'sha256-dlo6xal0...'. A hash admits exactly those\n bytes; strictly narrower than a nonce and unusable by injected markup.\n- scripts/theme-preflight-hash.mjs — regenerates the hash.\n- csp.test.ts — recomputes the hash from the script and fails on drift, plus asserts script-src never\n gains 'unsafe-inline' or a nonce. Without this guard a drifted hash silently blocks the script and\n the flash returns with no error anywhere.\n\nMEASURED, same method as the finding (300 kbps, dark-preference UA, CDP screencast):\n BEFORE t= 14ms UA canvas · t=2645ms rgb(226,223,215) PAPER — wrong theme · t=3842ms rgb(9,13,19) INK\n => ~1.2 s of full-page paper-white\n AFTER t= 16ms UA canvas · t=3321ms rgb(9,13,19) INK\n => zero wrong-theme frames; first painted frame is already INK (verified visually, not just\n by pixel probe)\n\nWHY NOT THE CSS FALLBACK: a @media (prefers-color-scheme: dark) block would duplicate ~90 lines of INK\ntokens — guaranteed to drift — and cannot see localStorage, so a visitor on a light OS who explicitly\nchose dark would still flash. The inline stamp covers both.\n\nApplies to all three hosted entries (/, /app, /review), which was the point: they share the token stack.\n\n97/97 unit tests pass; svelte-check 0 errors."}} +{"id":"int-8353cac2dea24c1b9c4463aef4306e50","kind":"field_change","created_at":"2026-08-05T02:26:07.201633Z","actor":"Angus Bezzina","issue_id":"attn-n01r.1","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Fixed at the class, not the instance.\n\n- app-shell.css .folio-label now owns margin-block-start: 2.5rem. It previously had no margin and\n borrowed its entire top gap from .quick-actions' 4rem bottom margin via adjacent-sibling collapse,\n which is why inserting the join panel between them left it with nothing.\n- app-shell.css .join-panel margin changed from '14px 0 0' (top only, px) to symmetric\n margin-block: 1.5rem in rem, matching the rest of the desk.\n\nMEASURED, same probe as the finding (1280x900, panel expanded):\n BEFORE panel bottom 671.09 / label top 671.09 -> gap 0.00px\n AFTER panel bottom 648.50 / label top 688.50 -> gap 40.00px\nVerified visually in light and dark at 1280 and 390.\n\nThe class of bug is gone too: anything inserted between the action grid and the list (import error,\ndegraded banner, a future filter bar) can no longer strip the label's spacing."}} +{"id":"int-b0ab83347c87366817b2d83d08d5c7ae","kind":"field_change","created_at":"2026-08-05T02:26:07.792542Z","actor":"Angus Bezzina","issue_id":"attn-n01r.7","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Fixed, and the dead option removed rather than defaulted.\n\n- icon-resolver.ts: deleted MARKDOWN_NO_ICON and the includeMarkdown parameter entirely. Every file\n now resolves an icon; the filename lookup still runs first, so README.md keeps its specific glyph.\n- FileTree.svelte:141 and DirectoryOverview.svelte:98 both call resolveFileIcon(node.name) — the two\n surfaces no longer disagree.\n\nOnce both call sites wanted markdown icons the flag had no remaining caller, so leaving it would have\nbeen a dead option in the API. If icon density in a Markdown-heavy tree turns out to be the real\nproblem, the answer is a better Markdown glyph, not a missing one — noted in the function's doc\ncomment so the next person does not simply re-add the suppression.\n\nsvelte-check 0 errors; 97/97 unit tests pass."}} +{"id":"int-2511d16232a515096a0b469eb6af8dcc","kind":"field_change","created_at":"2026-08-05T02:26:08.364268Z","actor":"Angus Bezzina","issue_id":"attn-n01r.43","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Fixed; horizontal overflow now clean at every breakpoint and both text sizes.\n\nFour causes, all measured:\n1. app-shell.css .app-shell had grid-template-rows: 64px 1fr — a fixed row cannot hold a header whose\n content grows with text size. Now minmax(64px, auto) 1fr.\n2. The shell's single grid column was sized by its widest child, so the header's unwrapped content set\n a 612px floor that main.desk inherited. Added grid-template-columns: minmax(0, 1fr).\n3. .app-header and .app-header .right now flex-wrap; the header growing taller at 200% is correct,\n the page scrolling sideways is not.\n4. .quick big and .empty-desk now break long strings (overflow-wrap). The mono meta line\n 'UNTITLED.MD · NOT CREATED YET' carries 0.1em tracking and cannot shrink — it alone drove 87px\n past the card.\nAlso converted the desk's bare 1fr tracks (.quick-actions, .workspace-row, and their <=900px\noverrides, plus .storage-grid) to minmax(0, 1fr), matching the idiom already used throughout the file.\n\nMEASURED, documentElement scrollWidth - clientWidth:\n 100% text 200% text\n BEFORE @390 clean OVERFLOW 222px header box 63 vs content 76 (clipped)\n AFTER @320 clean clean\n @390 clean clean\n @768 clean clean\n @1280 clean clean header box 89 vs content 89 (no clip)\n @1920 clean clean\n\nWCAG 1.4.4 Resize Text and 1.4.10 Reflow now pass on the desk. Verified visually at 1280 light,\n1280 dark and 390. svelte-check 0 errors; 97/97 unit tests pass.\n\nNOT in this change, still open in the epic: the <=900px breakpoint still hides .detail rather than\nreflowing it, and there is still no 2-column tier between 901-1100px."}} +{"id":"int-357fdeb61ea683eef52e85f40832000a","kind":"field_change","created_at":"2026-08-05T02:26:42.478869Z","actor":"Angus Bezzina","issue_id":"attn-n01r.42","extra":{"field":"status","new_value":"in_progress","old_value":"open"}} +{"id":"int-218734bea3caef7eede36a2ac6ff6845","kind":"field_change","created_at":"2026-08-05T02:30:20.28502Z","actor":"Angus Bezzina","issue_id":"attn-n01r.42","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Fixed and verified by live token probe in both themes.\n\nDESTRUCTIVE COLOUR — the six surfaces now use --danger-ink instead of --rust-deep (which aliases\n--primary-hover, so in INK they rendered the same steel blue as the primary Join button):\n chrome.css .button.danger\n app-shell.css .row-action.danger:hover\n app-shell.css .join-error\n DeskHome.svelte / OpenPage.svelte / StoragePage.svelte — the three inline error

styles\n\nMEASURED (live probe on /app):\n light --danger-ink oklch(0.45 0.18 27) red (--rust-deep was oklch(0.42 0.15 28))\n dark --danger-ink oklch(0.70 0.16 25) red (--rust-deep is oklch(0.78 0.10 220) steel blue)\nINK now has a real destructive colour.\n\nThe three inline copies became a .form-error class — the audit's read was right, it was a class\ncopied instead of created. That also removes 3 of the 20 inline style= attributes tracked in .44.\n\nUNDEFINED TOKENS — declared in chrome.css beside the other hosted aliases. They were in src/app.css\ninside a Tailwind block the hosted entries never import, so they resolved to\n0px/transparent with no error and no visual alarm:\n --radius-md now calc(.625rem - 2px) (was UNDEFINED -> 0px; .file-row, .file-add-row rendered square)\n --radius-lg now .625rem (was UNDEFINED -> 0px; .viewing-safely, .arrival-toast)\n --wash now var(--accent) (was UNDEFINED -> transparent; .share-selection-actions\n hover had no background at all)\nVerified resolving and flipping correctly: --wash is ink 7% in PAPER, white 3% in INK.\nDerived --radius-* from the same --radius as app.css so the two declarations cannot drift.\n\nHAIRLINES — .desk-title border-bottom and .storage-panel border-top moved from solid var(--ink) to\nvar(--rule). DESIGN.md: hairlines are 'ink at low alpha, never a solid gray line'. Measured in INK:\nnow oklch(0.87 0.008 250 / 0.16), previously full opacity — the brightest element on the dark page,\nbrighter than the h1 it underlined.\n\nSPECIFICITY COLLISION — added .workspace-row .local-badge { color: var(--green) }. '.workspace-row\nspan' (0,1,1) had been outranking '.local-badge' (0,1,0), so the same class rendered green in the\nheader and muted grey in a row, making 'Shared · relay sees only ciphertext' visually identical to\n'Local only' (the dot followed, since it paints with currentColor).\n\nsvelte-check 0 errors; 97/97 unit test files pass.\n\nStill open elsewhere in the epic: --rust-deep remains a hover token used as a resting colour in ~13\nother places, which is the root cause behind this whole family (tracked in .36)."}} +{"id":"int-7bd6cf0a6fb466a7535c3feead2abc3c","kind":"field_change","created_at":"2026-08-05T02:30:42.630306Z","actor":"Angus Bezzina","issue_id":"attn-n01r.23","extra":{"field":"status","new_value":"in_progress","old_value":"open"}} +{"id":"int-71bffa88811b11682e1691cf8074a08d","kind":"field_change","created_at":"2026-08-05T02:37:35.229769Z","actor":"Angus Bezzina","issue_id":"attn-n01r.23","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Fixed; horizontal overflow now clean at all eight widths at both text sizes.\n\nFive causes, found by iterating the measurement rather than assuming the audit's list was complete:\n1. Four grids declared bare 1fr (= minmax(auto,1fr), floors at min-content): .entry-strip,\n .chapter-head, .steps, .surfaces — plus .native-grid and the single-column mobile overrides.\n All now minmax(0, …), matching .local-note which already did it correctly.\n2. .site-nav used a fixed height (70px / 64px), so it clipped its own contents at 200%\n (box 70 vs content 79). Now min-height.\n3. .site-nav and .nav-right could not wrap, so the brand + links + CTA row set a floor wider than\n the viewport. This was the largest single contributor and was NOT in the original finding —\n it only surfaced after the grids were fixed.\n4. The display headings could not break a long word ('documents.'), overflowing by 46px at 390.\n Added overflow-wrap: break-word to the h1/h2/h3/p rule.\n5. The install command is one unbreakable token. .code now has min-width: 0 and .code > span\n scrolls internally, per DESIGN.md's Wide-Sheet Rule ('oversized tables/code scroll inside it').\n .footer-links wraps.\n\nMEASURED, documentElement scrollWidth - clientWidth at 200% text:\n BEFORE AFTER\n 320 (untested) 221px clean\n 375 — clean\n 390 184px clean\n 414 — clean\n 768 143px clean\n 1024 — clean\n 1280 clean clean (nav box 89 vs content 89 — no longer clipped)\n 1920 — clean\n100% text remains clean at every width. WCAG 1.4.4 and 1.4.10 now pass on the landing.\n\nVerified visually at 1280 light, 1280 dark and 390 — no regression; the mobile masthead still uses\nits two-tier h1. The four-line ragged mobile masthead is pre-existing and tracked separately in .17.\n\n97/97 unit test files pass; svelte-check 0 errors."}} +{"id":"int-3f929420a22e1f46482f38012ad90598","kind":"field_change","created_at":"2026-08-05T02:37:53.25254Z","actor":"Angus Bezzina","issue_id":"attn-n01r.12","extra":{"field":"status","new_value":"in_progress","old_value":"open"}} +{"id":"int-8732ccd597effe0e6ff7aacdc939727c","kind":"field_change","created_at":"2026-08-05T02:40:28.96144Z","actor":"Angus Bezzina","issue_id":"attn-n01r.12","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Fixed. Zero controls now fall under the WCAG 2.2 AA floor at any breakpoint.\n\nThe diagnosis in the finding was right and the fix was systemic: .button already carried\nmin-height: 46px and every control using it passed; every miss was a control outside that class.\nGiven each one a hit-target floor rather than patching sizes individually:\n\n chrome.css .icon-button min-width/min-height: 44px (was 32.8 x 32.8)\n landing.css .nav-link inline-flex + min-height 44 (was 20px tall)\n landing.css .brand min-height: 44px (was 29px tall)\n landing.css .code-copy min 44 x 44 (was 40 x 23.4)\n landing.css .footer-links a inline-flex + min-height 44 (was 18px tall)\n landing.css .site-nav .button min-height 40px -> 44px (mobile override had dropped the CTA\n below .button's own floor at exactly\n the width where it matters most)\n\nMEASURED, all interactive elements, per breakpoint:\n under 24x24 (WCAG 2.2 AA) under 44x44\n BEFORE @390 code-copy 40x23.4, footer 8 controls\n links 37x18 / 32x18 / 67x18\n AFTER @320/390 NONE GitHub 37x44, Issues 32x44\n @768/1280 NONE + GitHub 41x44\n\nWCAG 2.5.8 Target Size (Minimum), AA — now passes everywhere.\n\nDELIBERATELY NOT FORCED TO 44 WIDE: the two short footer text links. They are full 44px tall and\nfail only on width because the words are short; 2.5.8's 24px floor is met with room to spare, and a\nmin-width on inline text links would open odd gaps in the row rather than improve acquisition. The\n44px figure is 2.5.5 AAA / platform guidance, not the AA bar.\n\nAlso folded in from the sibling a11y issue: .footer-links a now carries --ink and an underline. They\npreviously rendered byte-identical to the adjacent licence text with no underline, so nothing at rest\nidentified them as links (WCAG 1.4.1, the stricter form — not distinguished by colour OR any\nnon-colour cue).\n\nsvelte-check 0 errors; 97/97 unit test files pass."}} +{"id":"int-a27b2c21c3096829956c9fbaa0758360","kind":"field_change","created_at":"2026-08-05T02:41:12.703367Z","actor":"Angus Bezzina","issue_id":"attn-n01r.25","extra":{"field":"status","new_value":"in_progress","old_value":"open"}} +{"id":"int-69313faa11d1f7c2ee8cb44923fa9893","kind":"field_change","created_at":"2026-08-05T02:41:13.11937Z","actor":"Angus Bezzina","issue_id":"attn-n01r.19","extra":{"field":"status","new_value":"in_progress","old_value":"open"}} +{"id":"int-af63021743aef9a27ced99d694ecc764","kind":"field_change","created_at":"2026-08-05T02:44:40.820386Z","actor":"Angus Bezzina","issue_id":"attn-n01r.19","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Fixed and verified by live AX/keyboard probe.\n\nSKIP LINK — added as the first element in Landing.svelte, visible only on :focus-visible, targeting\nthe existing #top so no other anchor changed. Verified: the first Tab stop is now 'Skip to content',\nrendered visible at top:12. Previously stops 0-6 were all navigation with no way past them, while\n

carried an id nothing linked to. WCAG 2.4.1 Bypass Blocks.\n\nBANNER LANDMARK —
+
+ ${payload.steer ? '' : ''} + ${payload.reroll ? '' : ''} + ${payload.canon && !payload.canonCard ? '' : ''} +
+`; +} + +const server = http.createServer((req, res) => { + if (req.method === 'GET' && req.url === '/') { + const pending = nextFile(); + if (pending && fs.existsSync(pending)) { + try { loadRound(fs.readFileSync(pending, 'utf8')); fs.rmSync(pending); } catch { /* keep current round */ } + } + res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); + res.end(page()); + return; + } + if (req.method === 'POST' && req.url === '/heartbeat') { + res.writeHead(204); res.end(); + if (detachedKey) { + const now = Date.now(); + if (!server.lastBeatWrite || now - server.lastBeatWrite > 4000) { + server.lastBeatWrite = now; + try { + const state = JSON.parse(fs.readFileSync(stateFile(detachedKey), 'utf8')); + state.lastBeat = now; + fs.writeFileSync(stateFile(detachedKey), JSON.stringify(state)); + } catch { /* state file recreated on next beat */ } + } + } + return; + } + if (req.method === 'GET' && req.url === '/next-status') { + const pending = nextFile(); + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ ready: Boolean(pending && fs.existsSync(pending)) })); + return; + } + const imageMatch = req.method === 'GET' && req.url?.match(/^\/img\/(\d+)(?:\?.*)?$/); + if (imageMatch) { + const abs = localImages[Number(imageMatch[1])]; + if (!abs || !fs.existsSync(abs)) { res.writeHead(404); res.end(); return; } + const type = abs.endsWith('.webp') ? 'image/webp' + : abs.endsWith('.png') ? 'image/png' + : abs.endsWith('.svg') ? 'image/svg+xml' + : abs.endsWith('.gif') ? 'image/gif' + : 'image/jpeg'; + res.writeHead(200, { 'content-type': type }); + fs.createReadStream(abs).pipe(res); + return; + } + if (req.method === 'POST' && req.url === '/answer') { + let body = ''; + req.on('data', (chunk) => { body += chunk; }); + req.on('end', () => { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end('{"ok":true}'); + let parsed = {}; + try { parsed = JSON.parse(body); } catch { /* empty steer */ } + const chosen = options.find((o) => o.id === parsed.optionId); + const answer = JSON.stringify({ + optionId: parsed.optionId ?? null, + steer: parsed.steer ?? '', + ...(chosen?.hero || chosen?.board ? { hero: chosen.hero ?? null, board: chosen.board ?? null } : {}), + ...(chosen?.sketch ? { sketch: chosen.sketch } : {}), + }); + const isReroll = parsed.optionId === 'reroll'; + if (detachedKey) { + fs.mkdirSync(QUESTION_DIR, { recursive: true }); + fs.writeFileSync(answerFile(detachedKey), answer + '\n'); + } else { + printAnswer(answer); + } + // A re-roll in detached mode keeps the table open: the client shows a + // loading hand and reloads when --update delivers the next round. + if (!(isReroll && detachedKey)) setTimeout(() => process.exit(0), 150); + }); + return; + } + res.writeHead(404); res.end(); +}); + +server.listen(portArg, '127.0.0.1', () => { + const { port } = server.address(); + const url = `http://127.0.0.1:${port}/`; + if (hasFlag('detached-serve')) { + fs.mkdirSync(QUESTION_DIR, { recursive: true }); + fs.writeFileSync(stateFile(arg('key')), JSON.stringify({ pid: process.pid, port, url })); + } else { + console.log(`QUESTION URL: ${url}`); + console.log('Waiting for the user to choose in the browser (Ctrl-C aborts)...'); + } + if (!hasFlag('no-open')) { + const opener = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open'; + try { spawn(opener, [url], { stdio: 'ignore', detached: true }).unref(); } catch { /* URL printed anyway */ } + } + if (timeoutSec > 0) { + setTimeout(() => { + console.log('serve-question: timed out with no answer'); + process.exit(2); + }, timeoutSec * 1000).unref?.(); + } +}); diff --git a/.claude/skills/impeccable/scripts/surface-brief.mjs b/.claude/skills/impeccable/scripts/surface-brief.mjs new file mode 100644 index 00000000..723f7c1b --- /dev/null +++ b/.claude/skills/impeccable/scripts/surface-brief.mjs @@ -0,0 +1,74 @@ +#!/usr/bin/env node +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { resolveProjectRoot } from './context.mjs'; +import { + listSurfaceBriefs, + resolveSurfaceBrief, + surfaceBriefPathForTarget, + writeSurfaceBrief, +} from './lib/surface-briefs.mjs'; + +function summary(brief, projectRoot) { + return { + slug: brief.slug, + path: path.relative(projectRoot, brief.path).split(path.sep).join('/'), + primaryTarget: brief.primaryTarget, + relatedTargets: brief.relatedTargets, + }; +} + +function main(argv) { + const [command, target, bodyFile, ...relatedTargets] = argv; + const projectRoot = resolveProjectRoot(process.cwd(), target ? { targetPath: target } : {}); + if (command === 'path') { + const filePath = surfaceBriefPathForTarget(target, { projectRoot }); + if (!filePath) throw new Error('surface brief path requires a concrete target'); + process.stdout.write(`${path.relative(process.cwd(), filePath) || filePath}\n`); + return; + } + if (command === 'list') { + process.stdout.write(`${JSON.stringify(listSurfaceBriefs(projectRoot).map((brief) => summary(brief, projectRoot)), null, 2)}\n`); + return; + } + if (command === 'read') { + const result = resolveSurfaceBrief(projectRoot, target || null); + if (result.brief) { + process.stdout.write(result.brief.text); + return; + } + if (result.candidates.length) process.stderr.write(`${JSON.stringify(result.candidates.map((brief) => summary(brief, projectRoot)), null, 2)}\n`); + process.exit(2); + } + if (command === 'write') { + if (!target || !bodyFile) throw new Error('usage: surface-brief.mjs write '); + const filePath = writeSurfaceBrief({ + projectRoot, + primaryTarget: target, + relatedTargets, + body: fs.readFileSync(bodyFile, 'utf-8'), + }); + process.stdout.write(`${path.relative(process.cwd(), filePath) || filePath}\n`); + return; + } + throw new Error('usage: surface-brief.mjs [target] [body-file] [related-target ...]'); +} + +function isMainModule() { + if (!process.argv[1]) return false; + try { + return fs.realpathSync(fileURLToPath(import.meta.url)) === fs.realpathSync(process.argv[1]); + } catch { + return import.meta.url === pathToFileURL(process.argv[1]).href; + } +} + +if (isMainModule()) { + try { + main(process.argv.slice(2)); + } catch (error) { + process.stderr.write(`${error?.message || error}\n`); + process.exit(1); + } +} diff --git a/.impeccable/critique/2026-08-04T23-39-08Z__landing-page-hosted.md b/.impeccable/critique/2026-08-04T23-39-08Z__landing-page-hosted.md new file mode 100644 index 00000000..0d327514 --- /dev/null +++ b/.impeccable/critique/2026-08-04T23-39-08Z__landing-page-hosted.md @@ -0,0 +1,68 @@ +--- +target: landing page (hosted) +total_score: 20 +max_score: 36 +na_heuristics: 9 +p0_count: 2 +p1_count: 5 +timestamp: 2026-08-04T23-39-08Z +slug: landing-page-hosted +--- +Method: dual-agent (Assessment A design review and Assessment B detector/browser evidence run as isolated parallel sub-agents), plus a two-agent technical audit (a11y+responsive, perf+theming+integrity). Browser evidence via Playwright + system Chrome against the production build; the claude-in-chrome extension was not connected. No user-visible overlay was produced — the in-page detector ran headless. + +## Design Health Score — 20/36 applicable + +| # | Heuristic | Score | Key issue | +|---|-----------|-------|-----------| +| 1 | Visibility of System Status | 2 | CopyCode aria-label frozen at "Copy "; zero aria-live regions; nav has no active-section state | +| 2 | Match System / Real World | 2 | Four nouns (desk/workspace/room/document), "workspace" overloaded, "desk" used 4x defined 0x | +| 3 | User Control and Freedom | 3 | Anchors and theme persistence work; global smooth scroll; all nav links display:none <=680px | +| 4 | Consistency and Standards | 2 | Landing has forked four DESIGN.md named rules; five terracotta elements on the fold | +| 5 | Error Prevention | 3 | Little to get wrong; clipboard failure swallowed by an empty catch | +| 6 | Recognition Rather Than Recall | 2 | The one artifact showing what a review IS is cropped through its own text; unglossed protocol vocabulary | +| 7 | Flexibility and Efficiency | 1 | Page branches on readDeskCount() for returning users, then serves them the identical 6,100px scroll | +| 8 | Aesthetic and Minimalist Design | 3 | Genuinely restrained; 18 non-code mono strings; three identical /app#new CTAs in 1.5 screens | +| 9 | Error Recovery | n/a | Zero forms, zero inputs, zero user-visible async operations — no error surface exists | +| 10 | Help and Documentation | 2 | A no-account E2EE tool with no docs, no FAQ, no threat model, no security page | + +Total 20/36 (56%) — Acceptable band. Visual craft sits well above that number; information architecture and the demonstration of behaviour drag it down. + +## Design Specificity Verdict + +Well-made and under-authored. Roughly 70% could ship for any local-first dev tool with the nouns swapped, and the 30% that is attn's is spent on the wrong argument. + +The damning fact, verified: "agent" and "AI" appear ZERO times on the page. PRODUCT.md positions attn as "the reviewer for agent-authored docs… human comments and AI suggestions in a single end-to-end-encrypted thread". The page argues "private local markdown editor with sharing" — a category with a dozen occupants. Even the hero screenshot shows only human reviewer cards. + +Interchangeable structures: the eyebrow->oversized-headline->lede->button-pair->micro-proof hero with a rotated window screenshot; the three-up entry triptych; numbered 01/02/03 how-it-works; the two-up comparison with one panel inverted to near-black; the brew-install section; accent-mono chapter indices. + +Genuinely authored: the paper ground and grain, the two-tier h1 (line two at 0.78em), the 6rem serif masthead, the Surfaces ground-colour inversion. All surface. The brand is "warm surface, sharp behavior" and the page ships only the surface — two interactions in 6,100px of scroll, zero @keyframes. + +Deterministic scan: CLI detector returns 0 findings on web/src/hosted/landing, but that zero is a scope artifact — the landing's styling lives in chrome.css/tokens.css outside the scanned paths, and the rules that matter are render-time. Injected into the live page the detector found 4: hero-eyebrow-chip (genuine), all-caps-body x2 (false positives — 31/32-char kickers, which is what the rule says uppercase is for), cream-palette (false positive — DESIGN.md specifies oklch(0.905 0.010 78) with chroma deliberately held at 0.010 to read as paper, not cream). All three eyebrow findings originate from one CSS rule at chrome.css:98-103. + +## Audit Health Score — 14/20 + +| # | Dimension | Score | Key finding | +|---|-----------|-------|-------------| +| 1 | Accessibility | 3 | 37 text styles measured, one contrast failure (decorative window dots); five AA-tier defects | +| 2 | Performance | 3 | LCP 1932ms / CLS 0.0027 / TBT 5ms on Fast 3G+4x CPU; hero sizes over-fetches 1.5x; 7.5MB dead PNGs | +| 3 | Responsive Design | 3 | Zero overflow at eight widths; 184px h-scroll at 200% text; 500px breakpoint gap with a real collision | +| 4 | Theming | 2 | Zero hard-coded colours, but three named rules broken systemically plus the INK bootstrap flash | +| 5 | Implementation Integrity | 3 | Detector clean and verified real; hero crop; duplicated window chrome; a public alternate homepage | + +## Priority Issues + +- [P0] The positioning is absent from the positioning surface (zero mentions of agent/AI). +- [P0] The hero screenshot is cropped through its own text on both edges — object-fit:cover on a 1.333 source in a 1.025 box. +- [P1] E2EE asserted four times, demonstrated zero times; no threat model, no security page. +- [P1] Mobile navigation disappears entirely below 680px with no replacement. +- [P1] 200% text produces 184px of horizontal scroll and an unreachable CTA at 390px (four grids use 1fr instead of minmax(0,1fr)). +- [P1] Dark-mode visitors see a full paper-white paint before INK applies; no prefers-color-scheme fallback exists. +- [P1] Eight controls miss 44x44; four miss the WCAG 2.2 24px floor. Only .button carries a size rule. + +## Persona Red Flags + +Jordan: clicks "Open your desk" — a word used four times and defined zero times — and the nav surfaces the empty desk most prominently to the person least equipped to read it. Riley: tests the E2EE claim first and finds nothing testable; presses Copy with permission denied and gets silence. Casey: no navigation at all, 6,100px of scroll, the only reachable controls top-right and undersized. James (PRODUCT.md's primary user): the returning path is one swapped nav label; no recent files, no keyboard entry, no Cmd-K, on the homepage of a keyboard-first product. + +## What's Working + +Token discipline is real — zero hard-coded colours across landing.css and all ten landing components; dark mode is a second design rather than an inversion; AA holds at all 24-37 sampled roles in both themes with the AA-retune documented in-comment. Focus indication is complete: all 17 tabbable controls draw a 2px ring at 5.30-8.18:1. The route-bundle boundary is real and gated pre-deploy at 25.9KB brotli. The side-stripe antipattern was anticipated and avoided on purpose, with the reasoning written into the CSS. diff --git a/.impeccable/critique/2026-08-04T23-40-27Z__desk-page-hosted-app.md b/.impeccable/critique/2026-08-04T23-40-27Z__desk-page-hosted-app.md new file mode 100644 index 00000000..2a29d344 --- /dev/null +++ b/.impeccable/critique/2026-08-04T23-40-27Z__desk-page-hosted-app.md @@ -0,0 +1,60 @@ +--- +target: desk page (hosted /app) +total_score: 20 +max_score: 40 +na_heuristics: +p0_count: 3 +p1_count: 4 +timestamp: 2026-08-04T23-40-27Z +slug: desk-page-hosted-app +--- +Method: dual-agent (Assessment A design review and Assessment B detector/browser evidence as isolated sub-agents) plus a technical audit agent. Browser evidence via Playwright + system Chrome against the production build across empty / populated / invite-open / delete-confirm states in both themes; claude-in-chrome was not connected. + +Register: OPERATE. Judged as a tool opened fifty times a week. + +## Design Health Score — 20/40 (all ten applicable) + +| # | Heuristic | Score | Key issue | +|---|-----------|-------|-----------| +| 1 | Visibility of System Status | 3 | best-effort returns warn:false, so "Backup recommended" paints the same green as "On this device" | +| 2 | Match System / Real World | 3 | U+21A5 glyph reads as mojibake; Local only / Backed up / Shared have no legend | +| 3 | User Control and Freedom | 2 | Escape does not close the join panel; closeJoin() never restores focus | +| 4 | Consistency and Standards | 1 | Join panel is a foreign system (10px radius, raw px) inside a rem/0-radius desk; serif buttons | +| 5 | Error Prevention | 2 | role="alertdialog" with no focus move and no Escape; rename commits on blur | +| 6 | Recognition Rather Than Recall | 2 | Mobile deletes file count and last-edited from a list titled "Recently on this device" | +| 7 | Flexibility and Efficiency | 0 | Zero keyboard affordances on the whole desk; no palette, filter, sort or search | +| 8 | Aesthetic and Minimalist Design | 2 | 558px of chrome before the first workspace name; 813px on iPhone — payload below the fold | +| 9 | Error Recovery | 3 | Join error copy is genuinely good and role="alert"-ed; import error is an inline-styled p | +| 10 | Help and Documentation | 2 | Nothing explains what "Backup recommended" wants you to do, or that Storage fixes it | + +Total 20/40 — Acceptable band, and the 0 on heuristic 7 is the headline: this is a keyboard-first product's most-opened surface with no keyboard model. + +## Design Specificity Verdict + +Authored from the fold up; a generic "recent projects" list below it. The masthead is unmistakably attn — "Your desk" at 72px Source Serif over a hairline rule, mono storage line on the baseline, rust eyebrow. Then the payload arrives and the authorship stops: .workspace-row is a four-column div-table with no header, no hover (the hover CSS targets a.workspace-row and the element is a div — dead rule), no keyboard model, no search, and Rename/Delete stamped on every line. Strip the serif and it is any project list. + +Three tells: the accent is spent on a static eyebrow and withheld from the primary action (One Pencil inverted); buttons are set in 400-weight serif (Read/Do violated); four chrome roles are set in mono while the system's label token is used zero times. + +The deeper gap: the desk shows no review state at all, and WorkspaceSummary (types.ts:36-47) carries no review facts to show. A desk that lists files instead of reviews in flight is a file manager wearing attn's typeface. + +## Audit evidence + +Detector: 1 finding on web/src/hosted/app, verified false positive (blockquote rule using the neutral --rule token, not on the desk). Zero true positives. +Contrast: 31 text styles measured across four state/theme combinations — zero failures, lowest 5.23:1. The perceived washed-out metadata is small uppercase mono with wide tracking, not a WCAG problem. +Overflow: clean at 320/375/390/768/1024/1280, zero offending elements. +Console: zero errors, warnings or failed requests across all eight state/theme combinations. +Touch targets at 390: Rename 57.2x27.6, Delete 47.6x27.6 (0.6rem apart, destructive, irreversible), row-open 26px tall, join-go 60x37. + +## Priority Issues + +- [P0] Join panel has 64px above it and 0px below — .folio-label owns no margin and borrows it from .quick-actions via sibling collapse. +- [P0] No keyboard model at all on the surface a keyboard-first power user opens most. +- [P0] Rename/Delete announce no workspace name — a screen-reader user cannot tell which workspace is about to be irreversibly deleted. +- [P1] "Backup recommended" is painted in the safe-state green, and there is no backup affordance on the desk. +- [P1] The workspace row: dead hover CSS, a 200x28px open target in an 80,000px² row, resident admin controls heavier than the content. +- [P1] Mobile puts the first workspace at y=813 in an 844px viewport. +- [P1] The desk exposes no review state; the data model has nowhere to put one. + +## What's Working + +Colour token discipline is excellent and could not be broken — every text pair clears AA in both themes, most clear AAA, and the "never lighter than oklch(0.32 0.012 65)" floor is held exactly. Focus-visible is complete: 16 Tab stops, every one drawing a 2px ring, correctly recoloured to steel in INK. The privacy copy earns its place without a badge wall — "Shared · relay sees only ciphertext" and "The part after # is the room key — it never reaches the relay" are the best-written text in the product. The empty-desk composition (tilted sheet, "What deserves your attention?") is a genuinely authored moment. diff --git a/.impeccable/design.json b/.impeccable/design.json index cb61773f..449911a1 100644 --- a/.impeccable/design.json +++ b/.impeccable/design.json @@ -1,6 +1,6 @@ { "schemaVersion": 2, - "generatedAt": "2026-07-22T00:20:42.662708+00:00", + "generatedAt": "2026-08-07T02:06:32.640618+00:00", "title": "Design System: attn", "extensions": { "colorMeta": { @@ -150,31 +150,105 @@ "displayName": "Participant Berry", "canonical": "oklch(0.58 0.14 358)", "tonalRamp": [] + }, + "panel-surface": { + "role": "neutral", + "displayName": "Panel Surface", + "canonical": "oklch(0.855 0.012 75)", + "tonalRamp": [ + "oklch(0.150 0.012 75)", + "oklch(0.264 0.012 75)", + "oklch(0.379 0.012 75)", + "oklch(0.493 0.012 75)", + "oklch(0.607 0.012 75)", + "oklch(0.721 0.012 75)", + "oklch(0.836 0.012 75)", + "oklch(0.950 0.012 75)" + ] + }, + "panel-border": { + "role": "neutral", + "displayName": "Panel Border", + "canonical": "oklch(0.14 0.008 55 / 16%)", + "tonalRamp": [ + "oklch(0.150 0.008 55)", + "oklch(0.264 0.008 55)", + "oklch(0.379 0.008 55)", + "oklch(0.493 0.008 55)", + "oklch(0.607 0.008 55)", + "oklch(0.721 0.008 55)", + "oklch(0.836 0.008 55)", + "oklch(0.950 0.008 55)" + ] + }, + "rail-chip-surface": { + "role": "neutral", + "displayName": "Rail Chip Surface", + "canonical": "oklch(0.88 0.012 75)", + "tonalRamp": [ + "oklch(0.150 0.012 75)", + "oklch(0.264 0.012 75)", + "oklch(0.379 0.012 75)", + "oklch(0.493 0.012 75)", + "oklch(0.607 0.012 75)", + "oklch(0.721 0.012 75)", + "oklch(0.836 0.012 75)", + "oklch(0.950 0.012 75)" + ] } }, "typographyMeta": { "display": { "displayName": "Display", - "purpose": "Document title at the top of the reading column. Serif, fixed rem." + "purpose": "Document title, top of the reading column.", + "fontSize": "2rem" }, "body": { "displayName": "Body", - "purpose": "The reading surface. Serif, 1.72 line-height for restful long-form markdown." + "purpose": "The reading surface; serif, generous leading.", + "fontSize": "1rem" }, "label": { "displayName": "Label", - "purpose": "Table headers, meta chips, sidebar markers. Sans, uppercase, tracked." + "purpose": "Table headers, meta chips, sidebar section markers. Sans, uppercase.", + "fontSize": "0.7rem" }, "mono": { "displayName": "Mono", - "purpose": "Code blocks and inline code. Source Code Pro." + "purpose": "Code blocks and inline code.", + "fontSize": "0.85rem" + }, + "headline": { + "displayName": "Headline", + "purpose": "Major section.", + "fontSize": "1.5rem" + }, + "title": { + "displayName": "Title", + "purpose": "Subsection.", + "fontSize": "1.25rem" + }, + "meta": { + "displayName": "Meta", + "purpose": "App-shell chrome: secondary/meta text. Sans.", + "fontSize": "0.78rem" + }, + "control": { + "displayName": "Control", + "purpose": "App-shell chrome: default control and button text. Sans.", + "fontSize": "0.95rem" + }, + "control-lg": { + "displayName": "Control Lg", + "purpose": "App-shell chrome: larger controls and emphasis. Sans.", + "fontSize": "1.15rem" } }, "shadows": [ { "name": "review-card-lift", "value": "0 16px 42px oklch(0.20 0.02 55 / 16%), 0 1px 0 oklch(1 0 0 / 45%) inset", - "purpose": "Margin review cards \u2014 ambient drop plus top inset highlight so the card reads as physical paper." + "purpose": "Margin review cards — ambient drop plus top inset highlight so the card reads as physical paper." }, { "name": "panel-soft", @@ -189,7 +263,7 @@ { "name": "pressed-inset", "value": "inset 0 1px 3px oklch(0 0 0 / 4%)", - "purpose": "Code blocks and inputs \u2014 pressed into the paper, not raised." + "purpose": "Code blocks and inputs — pressed into the paper, not raised." } ], "motion": [ @@ -216,7 +290,7 @@ "name": "Primary Button", "kind": "button", "refersTo": "button-primary", - "description": "Solid terracotta action button \u2014 primary CTA and confirm actions.", + "description": "Solid terracotta action button — primary CTA and confirm actions.", "html": "", "css": ".ds-btn-primary { display: inline-flex; align-items: center; justify-content: center; gap: 0.55rem; min-height: 46px; padding: 0.7rem 1.05rem; border: 1px solid var(--primary, oklch(0.48 0.14 28)); border-radius: 8px; background: var(--primary, oklch(0.48 0.14 28)); color: var(--primary-foreground, oklch(0.98 0.005 78)); font-family: 'Source Sans 3 Variable', system-ui, sans-serif; font-weight: 700; font-size: 0.91rem; cursor: pointer; transition: transform 0.18s ease, background 0.18s ease, border-color 0.18s ease; } .ds-btn-primary:hover { transform: translateY(-2px); background: oklch(0.42 0.15 28); border-color: oklch(0.42 0.15 28); } .ds-btn-primary:focus-visible { outline: 2px solid oklch(0.48 0.14 28); outline-offset: 2px; }" }, @@ -224,7 +298,7 @@ "name": "Secondary Button", "kind": "button", "refersTo": "button-secondary", - "description": "Translucent sheet-fill button with hairline border \u2014 non-primary actions.", + "description": "Translucent sheet-fill button with hairline border — non-primary actions.", "html": "", "css": ".ds-btn-secondary { display: inline-flex; align-items: center; justify-content: center; gap: 0.55rem; min-height: 46px; padding: 0.7rem 1.05rem; border: 1px solid oklch(0.14 0.008 55 / 18%); border-radius: 8px; background: oklch(0.89 0.012 76 / 45%); color: oklch(0.14 0.008 55); font-family: 'Source Sans 3 Variable', system-ui, sans-serif; font-weight: 700; font-size: 0.91rem; cursor: pointer; transition: transform 0.18s ease, border-color 0.18s ease; } .ds-btn-secondary:hover { transform: translateY(-2px); border-color: oklch(0.14 0.008 55); } .ds-btn-secondary:focus-visible { outline: 2px solid oklch(0.48 0.14 28); outline-offset: 2px; }" }, @@ -232,7 +306,7 @@ "name": "Search Input", "kind": "input", "refersTo": "input-field", - "description": "Sidebar search / text field \u2014 pressed-paper fill with inset highlight.", + "description": "Sidebar search / text field — pressed-paper fill with inset highlight.", "html": "", "css": ".ds-input { width: 100%; height: 32px; padding: 6px 12px; border-radius: 10px; border: 1px solid oklch(0.14 0.008 55 / 12%); background: oklch(0.905 0.010 78 / 84%); color: oklch(0.14 0.008 55); font-family: 'Source Sans 3 Variable', system-ui, sans-serif; font-size: 0.82rem; font-weight: 600; box-shadow: inset 0 1px 0 oklch(1 0 0 / 35%); transition: border-color 0.12s ease, box-shadow 0.12s ease; } .ds-input::placeholder { color: oklch(0.32 0.012 65); } .ds-input:focus-visible { outline: none; border-color: oklch(0.14 0.008 55 / 24%); box-shadow: inset 0 1px 0 oklch(1 0 0 / 45%); }" }, @@ -248,8 +322,8 @@ "name": "Review Margin Card", "kind": "card", "refersTo": "review-card", - "description": "The signature container \u2014 a slip of raised paper carrying a comment or suggestion, with a small accent identifying which.", - "html": "
R Tighten this sentence \u2014 the clause runs long.
", + "description": "The signature container — a slip of raised paper carrying a comment or suggestion, with a small accent identifying which.", + "html": "
R Tighten this sentence — the clause runs long.
", "css": ".ds-review-card { position: relative; display: flex; gap: 10px; padding: 16px; border-radius: 10px; border: 1px solid oklch(0.14 0.008 55 / 22%); background: oklch(0.94 0.010 76 / 96%); color: oklch(0.14 0.008 55); box-shadow: 0 16px 42px oklch(0.20 0.02 55 / 16%), 0 1px 0 oklch(1 0 0 / 45%) inset; font-family: 'Source Serif 4 Variable', Georgia, serif; font-size: 0.95rem; line-height: 1.5; } .ds-review-card__accent { flex: 0 0 auto; width: 4px; align-self: stretch; border-radius: 999px; background: oklch(0.58 0.15 150); } .ds-review-card__body { display: flex; align-items: baseline; gap: 8px; }" }, { @@ -263,67 +337,96 @@ ], "narrative": { "northStar": "The Lit Reading Room", - "overview": "attn is a private study where documents are read closely and marked by hand \u2014 warm paper under a desk lamp, ink that dried a century ago, a single red pencil for the one mark that matters. The surface is unmistakably editorial, but the behavior under it is a precision instrument that responds like Linear or Raycast: instant, exact, keyboard-first. Warm surface, sharp behavior. The system runs two themes from one identity \u2014 PAPER (warm parchment, light) and INK (cool blue-black, dark) \u2014 the same room at two times of day, the accent shifting warm terracotta to steel blue. It rejects three neighbors: cloud-SaaS review tools, IDEs, and rounded-pastel productivity apps.", + "overview": "attn is a private study where documents are read closely and marked by hand — warm paper under a desk lamp, ink that dried a century ago, a single red pencil for the one mark that matters. The surface is unmistakably editorial, but the behavior under it is a precision instrument that responds like Linear or Raycast: instant, exact, keyboard-first. Warm surface, sharp behavior. The system runs two themes from one identity — PAPER (warm parchment, light) and INK (cool blue-black, dark) — the same room at two times of day, the accent shifting warm terracotta to steel blue. It rejects three neighbors: cloud-SaaS review tools, IDEs, and rounded-pastel productivity apps.", "keyCharacteristics": [ - "Serif for reading, sans for chrome, mono for code \u2014 a strict three-role split.", - "One accent (terracotta / steel), spent only on action, selection, and state \u2014 never decoration.", + "Serif for reading, sans for chrome, mono for code — a strict three-role split.", + "One accent (terracotta / steel), spent only on action, selection, and state — never decoration.", "A real paper grain overlay unifies every surface, including the browser build.", - "Fixed rem type scale (product register): headings don't fluidly resize in a pane.", - "A dedicated review vocabulary: inline tracked-change marks, margin cards, role-colored peer avatars." + "Fixed rem type scale (product register): headings don't fluidly resize in a sidebar.", + "A dedicated review vocabulary: inline tracked-change marks, margin cards, role-colored peer avatars.", + "Five typeset presets change the reading column only — app chrome never moves when you switch one.", + "Three planes: chrome rails (sidebar + comments rail) recede equally, the document is the lit sheet between them, and review cards float above the rail." ], "rules": [ { "name": "The One Pencil Rule", - "body": "The primary accent is a red pencil, not a highlighter. It appears on primary action, current selection, and focus \u2014 nowhere else. If two things on a screen are terracotta, one is wrong.", + "body": "The primary accent is a red pencil, not a highlighter. It appears on primary action, current selection, and focus — nowhere else. If two things on a screen are terracotta, one of them is wrong.", "section": "colors" }, { "name": "The Quarantine Rule", - "body": "Green, amber, and the peer hues belong to the collaboration layer only. They never appear as decoration on base chrome.", + "body": "Green, amber, and the peer hues belong to the collaboration layer only. They never appear as decoration on base chrome; their meaning (suggestion / comment / who) is the entire reason they exist.", "section": "colors" }, { "name": "The Warm-Paper, Not-Cream Rule", - "body": "The ground holds chroma \u2264 0.012. Warmth is carried by the accent and the serif, not by the background.", + "body": "The ground holds chroma ≤ 0.012. The moment it drifts warmer it becomes the saturated AI cream default. Warmth is carried by the accent and the serif, not by the background.", "section": "colors" }, + { + "name": "The Chrome-Invariance Rule", + "body": "A preset may set only *document-scoped* tokens: `--doc-font`, `--attn-doc-scale`, `--doc-leading`, `--doc-tracking`, and `--content-measure`. It may never touch `--attn-base-font-size` (the rem baseline for all app chrome) or the global `--serif`/`--sans`/`--mono` families. Presets originally did both, which meant choosing a typeset silently rescaled every header, dialog and control in the app — the rem baseline drives `html { font-size }`. Chrome now holds still and only the reading column reflows. Document type sizes are therefore `em` (relative to the doc's own scale) while margins stay `rem` (anchored to the app baseline), so a preset's margin override means the same thing at every scale.", + "section": "typography" + }, { "name": "The Read/Do Rule", - "body": "If the user is reading it, it's serif. If the user is operating it, it's sans. A button never uses the serif; a document heading never uses the sans.", + "body": "If the user is reading it, it's serif. If the user is operating it, it's sans. There is no third case; a button never uses the serif, a heading in the document never uses the sans. (A preset may change *which* face reads as the serif — Modern makes it a sans — but never which role gets the reading face.)", + "section": "typography" + }, + { + "name": "The Scoped-Document Rule", + "body": "Document typography is scoped to `.attn-doc` — the class the editor mount and the viewer article carry. Bare `p` / `h1` / `ul` / `li` selectors are never global: chrome rendered in the same tree used to inherit 2rem heading gaps and absolutely-positioned list bullets that escaped their card, and each leak got patched individually until an opt-out class existed purely to undo the defaults. Type the document, not the page.", "section": "typography" }, { "name": "The Fixed-Scale Rule", - "body": "Product register: headings are fixed rem, not clamp(). A fluid h1 that shrinks in a sidebar looks worse, not better.", + "body": "Product register: headings are fixed rem, not `clamp()`. Users view at consistent DPI inside panes and windows; a fluid h1 that shrinks in a sidebar looks worse, not better.", + "section": "typography" + }, + { + "name": "The Wide-Sheet Rule", + "body": "The reading surface is full-width and left-set, never a centered narrow column: all content — running prose *and* wide blocks (mermaid diagrams, tables, code) — shares one column capped at the `--content-measure` token (**960px**); oversized tables/code scroll inside it. (Revised 2026-07-13 from the original split layout — 72ch prose beside full-pane blocks — which read as ragged whenever a wide block was on screen. Retuned 1100px → 960px, and corrected here 2026-08-06 where the doc still said 1100.) The `micro` (2px) radius is the mark family for inline review marks, focus rings, and accent bars.", + "section": "typography" + }, + { + "name": "The Measure-Is-Opt-Out Rule", + "body": "The shared column is currently applied by a hand-maintained *allowlist* of element selectors, so anything not named in it silently escapes the measure. Two blocks were found escaping in one sweep (the frontmatter card and the math container), and on the viewer side the list can never be complete, because comrak passes raw HTML through — an author writing `
` or `
` in markdown lands an arbitrary element outside the column. The durable shape is `article.attn-doc > *` with explicit opt-outs for the wrappers that need a `min()` clamp. Recorded as the intent; not yet implemented.", "section": "typography" }, { "name": "The Flat-Until-Lifted Rule", - "body": "Surfaces are flat and tonal at rest. A shadow appears only when something is genuinely floating or genuinely pressed. Shadow states physical position, never decorative depth.", - "section": "elevation" + "body": "Surfaces are flat and tonal at rest. A shadow appears only when something is genuinely floating above the page (a card, a dialog, a menu) or genuinely pressed into it (an input, a code block). Shadow is a statement about physical position, never a decorative gradient of depth.", + "section": "elevation-depth" }, - "The Truth Rule: pixels always equal state \u2014 no visible fact may depend on an animation completing or a debounce flushing; closed overlays are display:none in plain CSS; theme flips are atomic.", - "The Topmost-Escape Rule: Escape closes exactly one layer (palette \u2192 composer \u2192 dialog \u2192 popover \u2192 drawer) and never destroys a draft; overlays store focus on open and restore it on close.", - "The Wide-Sheet Rule: the reading surface is full-width and left-set; document prose and wide content share one column capped by the 1100px content-measure token, and oversized blocks scroll inside it." + { + "name": "The Truth Rule", + "body": "Pixels always equal state: no user-visible fact — a modal open, a comment arrived, a file saved — may depend on an animation completing or a debounce flushing. Closed overlays are `display: none` in plain CSS (`[data-state=\"closed\"]`); theme flips are atomic (transitions suppressed for the flip frame); animation is enhancement, never the carrier of state. Occluded windows freeze the animation clock, so anything less soft-locks the app.", + "section": "elevation-depth" + }, + { + "name": "The Topmost-Escape Rule", + "body": "Escape closes exactly one layer — the topmost (palette → composer → dialog → popover → drawer) — and never destroys a draft. Every overlay stores focus on open and restores it on close.", + "section": "elevation-depth" + } ], "dos": [ - "Do keep the terracotta/steel accent to action, selection, and focus only \u2014 the One Pencil Rule.", - "Do use serif for everything read and sans for everything operated \u2014 no exceptions.", - "Do hold the paper ground at chroma \u2264 0.012; carry warmth through the accent and the serif.", - "Do keep the review hues quarantined to the collaboration layer, distinguished by meaning and attribution.", - "Do specify default / hover / focus-visible / active / disabled for every interactive component.", - "Do honor prefers-reduced-motion; keep state transitions 120-250ms and let motion convey state, not choreography.", - "Do carry white-on-role-hue chips at their AA-tuned lightness." + "Do keep the terracotta/steel accent to action, selection, and focus only — the One Pencil Rule. Everything else is ink, paper, and the second neutral layer.", + "Do use serif for everything read and sans for everything operated — no exceptions (the Read/Do Rule).", + "Do hold the paper ground at chroma ≤ 0.012; carry warmth through the accent and the serif.", + "Do keep the review hues (green / amber / peer colors) quarantined to the collaboration layer, distinguished by meaning and attribution — never decoration.", + "Do specify default / hover / focus-visible / active / disabled for every interactive component; keyboard reachability is a requirement, not a nicety.", + "Do honor `prefers-reduced-motion`; keep state transitions in the 120–250ms range and let motion convey state, not choreography.", + "Do carry white-on-role-hue chips at their AA-tuned lightness; if you add a peer hue, tune it to clear 4.5:1 for its monogram." ], "donts": [ - "Don't let attn read like a cloud-SaaS review tool (Google Docs): no account-wall chrome, no toolbar-dense header.", - "Don't let it read like an IDE (VS Code): no activity bars, no panels-in-panels. The reading column is the hero.", - "Don't let it drift toward Notion rounded-pastel: no candy-colored blocks, no emoji-forward headers.", - "Don't borrow the Linear-clone saturated-purple glassy gradient-glow dark theme; INK mode is a cool blue-black study.", - "Don't use a border-left/border-right colored stripe > 1px on cards or callouts.", - "Don't use gradient text, glassmorphism as a default, or a warm-cream background.", - "Don't let muted body text go lighter than oklch(0.32 0.012 65) on paper.", - "Don't fluidly clamp() UI headings; the product type scale is fixed rem." + "Don't let attn read like a cloud-SaaS review tool (Google Docs): no account-wall chrome, no toolbar-dense header, no \"your doc lives in our cloud\" framing.", + "Don't let it read like an IDE (VS Code): no activity bars, no panels-in-panels, no everything-is-a-toolbar. The reading column is the hero.", + "Don't let it drift toward Notion rounded-pastel: no candy-colored blocks, no emoji-forward headers, no soft-everything. Warmth is paper and type.", + "Don't borrow the Linear-clone saturated-purple glassy gradient-glow dark theme; INK mode is a cool blue-black study, not neon.", + "Don't use a colored side-stripe on a card or callout as *decoration* — the AI-UI tell is a thick tinted border that means nothing. The one sanctioned exception is the review margin card's `3px` accent strip, which is load-bearing: it encodes the comment's author and its kind/state, and removing it deletes an information channel. (Amended 2026-08-06: this previously read as a flat \">1px\" prohibition, which the shipped card had never satisfied — the rule described an intent the product had already outgrown. If a new stripe cannot say what it *means*, it is decoration and the prohibition stands.)", + "Don't use gradient text, glassmorphism as a default, or the drift toward a warm-cream background — all are prohibited.", + "Don't let muted body text go lighter than ~`oklch(0.32 0.012 65)` on paper; light-gray-for-elegance is the fastest way to fail the 4.5:1 floor.", + "Don't fluidly `clamp()` UI headings; the product type scale is fixed rem." ] } } \ No newline at end of file diff --git a/DESIGN.md b/DESIGN.md index c62ef9d3..996fce69 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -9,7 +9,10 @@ colors: muted-ink: "oklch(0.32 0.012 65)" card: "oklch(0.89 0.012 76)" sidebar: "oklch(0.855 0.012 75)" - code-block: "oklch(0.885 0.012 73)" + panel-surface: "oklch(0.855 0.012 75)" + panel-border: "oklch(0.14 0.008 55 / 16%)" + rail-chip-surface: "oklch(0.88 0.012 75)" + code-block: "oklch(0.972 0.008 78)" border: "oklch(0.14 0.008 55 / 18%)" link: "oklch(0.38 0.04 55)" destructive: "oklch(0.55 0.20 27)" @@ -58,6 +61,24 @@ typography: fontWeight: 600 lineHeight: 1.2 letterSpacing: "0.06em" + meta: + fontFamily: "Source Sans 3 Variable, Source Sans 3, -apple-system, system-ui, sans-serif" + fontSize: "0.78rem" + fontWeight: 500 + lineHeight: 1.4 + letterSpacing: "normal" + control: + fontFamily: "Source Sans 3 Variable, Source Sans 3, -apple-system, system-ui, sans-serif" + fontSize: "0.95rem" + fontWeight: 500 + lineHeight: 1.4 + letterSpacing: "normal" + control-lg: + fontFamily: "Source Sans 3 Variable, Source Sans 3, -apple-system, system-ui, sans-serif" + fontSize: "1.15rem" + fontWeight: 600 + lineHeight: 1.35 + letterSpacing: "normal" mono: fontFamily: "Source Code Pro Variable, Source Code Pro, SF Mono, Consolas, monospace" fontSize: "0.85rem" @@ -103,13 +124,13 @@ components: review-card: backgroundColor: "oklch(0.94 0.010 76 / 96%)" textColor: "{colors.ink}" - rounded: "{rounded.lg}" - padding: "16px" + rounded: "{rounded.sm}" + padding: "10px 12px 10px 13px" --- # Design System: attn -## 1. Overview +## Overview **Creative North Star: "The Lit Reading Room"** @@ -125,11 +146,15 @@ This system explicitly rejects three neighbors. It is **not a cloud-SaaS review - A real paper grain overlay unifies every surface, including the browser build. - Fixed rem type scale (product register): headings don't fluidly resize in a sidebar. - A dedicated review vocabulary: inline tracked-change marks, margin cards, role-colored peer avatars. +- Five typeset presets change the reading column only — app chrome never moves when you switch one. +- Three planes: chrome rails (sidebar + comments rail) recede equally, the document is the lit sheet between them, and review cards float above the rail. -## 2. Colors +## Colors A warm parchment field carrying near-black ink and a single terracotta accent; cool review hues (green, amber, blue, violet) are quarantined to the collaboration layer so they never dilute the editorial ground. All values are canonical **OKLCH** — attn is OKLCH-native and the frontmatter carries OKLCH directly. +**Paper / Ink / System.** Appearance is a three-state preference (Settings → Appearance), defaulting to **System** — the app follows the OS appearance and tracks changes to it live. The preference is durable (`prefs.json`, next to the project registry) and is stamped into the page before the bundle loads, so launching never shows a frame of the wrong theme. `light`/`dark` are explicit overrides that ignore the OS. + ### Primary - **Terracotta Pencil** (`oklch(0.48 0.14 28)`, INK theme `oklch(0.72 0.10 220)` steel blue): the one accent. Primary buttons, current selection, checked checkboxes, focus rings, the "shared for review" marker. Warm red-clay on paper; it becomes a cool steel blue in dark mode because a saturated red-clay glows unpleasantly against a near-black ground. @@ -153,7 +178,9 @@ Role is no longer a color channel for humans — shape carries it (round = human - **Ink** (`oklch(0.14 0.008 55)`): body text, strong rules, the native side panel. - **Muted Ink** (`oklch(0.32 0.012 65)`): secondary text, labels, table headers. Sits at ~4.5:1 on paper — the floor for body, never lighter. - **Card / Sidebar** (`oklch(0.89 0.012 76)` / `oklch(0.855 0.012 75)`): the second neutral layer for chrome, a hair darker than the content surface so panels recede. -- **Code Block** (`oklch(0.885 0.012 73)`): inline code and `pre` ground. +- **Panel Surface** (`oklch(0.855 0.012 75)`, INK `oklch(0.172 0.014 257)`): the chrome plane — the comments rail and the app header sit on it, deliberately the *same* value as the sidebar. Both edges of the workspace recede equally so the document reads as a lit sheet between two rails. In INK the move inverts (the rails lift off a darker ground rather than sinking into it). +- **Rail Chip Surface** (`oklch(0.88 0.012 75)`, INK `oklch(0.205 0.013 257)`): fills for chips sitting *on* the panel plane. It exists because `--muted` lands 0.003 from `--panel-surface` in INK, so a `muted` chip on the rail is invisible there — a trap that has now been hit twice. +- **Code Block** (`oklch(0.972 0.008 78)`, INK `oklch(0.19 0.014 256)`): the raised surface shared by `pre`, inline code, **and tables** — a table and a code block are the same class of object and must not read as different materials. - **Border** (`oklch(0.14 0.008 55 / 18%)`): hairline dividers — ink at low alpha, never a solid gray line. ### Named Rules @@ -163,7 +190,7 @@ Role is no longer a color channel for humans — shape carries it (round = human **The Warm-Paper, Not-Cream Rule.** The ground holds chroma ≤ 0.012. The moment it drifts warmer it becomes the saturated AI cream default. Warmth is carried by the accent and the serif, not by the background. -## 3. Typography +## Typography **Reading Font:** Source Serif 4 Variable (with Georgia, serif) **Chrome Font:** Source Sans 3 Variable (with system-ui) @@ -179,14 +206,45 @@ Role is no longer a color channel for humans — shape carries it (round = human - **Label** (600, `0.7rem`, `0.06em`, UPPERCASE): table headers, meta chips, sidebar section markers. Sans. - **Mono** (400, `0.85rem`, 1.55): code blocks and inline code. +### Typeset presets +The three cuts above are the **Editorial** preset — the default, and the shape every rule in this section describes. Settings offers four alternates (shadcn's typeset model: a preset is a complete reading system, never a pile of independent font knobs): + +- **Editorial** — the default described above. Its values are the canonical tokens restated verbatim. +- **Modern** — sans for reading as well as chrome, with display sizes pulled in and tracking tightened (serif display scale reads oversized in sans). For readers who want a code-review tool rather than a manuscript. +- **Compact** — Editorial's fonts at a denser scale and leading, on a narrower measure. For dense ops docs. +- **Manuscript** (added 2026-08-06) — large serif on a short column (`660px`), 1.9 leading. The opposite pole from Compact: for reading a spec end to end rather than scanning it. It deliberately trades technical width for reading comfort, since wide blocks share the same narrow edge under the Wide-Sheet Rule. +- **Terminal** (added 2026-08-06) — monospace throughout, for diffs and config where column alignment *is* the content. Display sizes compress (a 2em mono h1 reads as shouting) and headings keep natural tracking, because negative letter-spacing fights a monospaced face. + +Presets live in `web/styles/typeset.css`, keyed off `data-typeset` on ``. They are orthogonal to light/dark (which owns color) and to the ⌘+/⌘- font scale — all three compose. + +**The Chrome-Invariance Rule** (added 2026-08-06). A preset may set only *document-scoped* tokens: `--doc-font`, `--attn-doc-scale`, `--doc-leading`, `--doc-tracking`, and `--content-measure`. It may never touch `--attn-base-font-size` (the rem baseline for all app chrome) or the global `--serif`/`--sans`/`--mono` families. Presets originally did both, which meant choosing a typeset silently rescaled every header, dialog and control in the app — the rem baseline drives `html { font-size }`. Chrome now holds still and only the reading column reflows. Document type sizes are therefore `em` (relative to the doc's own scale) while margins stay `rem` (anchored to the app baseline), so a preset's margin override means the same thing at every scale. + +Because every preset states its full hand — including Editorial — `[data-typeset]` is authoritative wherever it appears, including on a nested specimen in Settings. Declaring nothing was how the default preset's own specimen ended up rendering in whichever face happened to be active. + ### Named Rules -**The Read/Do Rule.** If the user is reading it, it's serif. If the user is operating it, it's sans. There is no third case; a button never uses the serif, a heading in the document never uses the sans. +**The Read/Do Rule.** If the user is reading it, it's serif. If the user is operating it, it's sans. There is no third case; a button never uses the serif, a heading in the document never uses the sans. (A preset may change *which* face reads as the serif — Modern makes it a sans — but never which role gets the reading face.) + +*Product-chrome steps* (added 2026-08-05, attn-n01r.8). The six steps above describe the **document**. The app shell needs four more between `label` and `title`, and pretending otherwise is why `app-shell.css` had drifted to 33 distinct sizes with no rhythm — every new component invented a value because no existing one fit. The full chrome ramp is: + +`0.7` label · `0.78` meta · `0.85` mono/caption · `0.95` control · `1` body · `1.15` control-lg · `1.25` title · `1.5` headline · `2` display + +Nine steps, and nothing between them. This is an *extension* of the ramp, not an exemption from it: a size outside this list is still a defect, and the hosted app shell now uses exactly these nine. + +(Corrected 2026-08-06: `meta`, `control` and `control-lg` existed only in this prose for two weeks, while the frontmatter carried the six document roles. Tokens are the normative layer — prose only contextualises them — so every chrome-ramp size read as off-ramp to any tool consuming this file, and `0.95rem` alone accounted for most of the drift reported against `app-shell.css`. All nine steps are now declared as frontmatter typography roles. **A ramp step that is not in the frontmatter does not exist.**) + +**The Scoped-Document Rule** (2026-08-04). Document typography is scoped to `.attn-doc` — the class the editor mount and the viewer article carry. Bare `p` / `h1` / `ul` / `li` selectors are never global: chrome rendered in the same tree used to inherit 2rem heading gaps and absolutely-positioned list bullets that escaped their card, and each leak got patched individually until an opt-out class existed purely to undo the defaults. Type the document, not the page. **The Fixed-Scale Rule.** Product register: headings are fixed rem, not `clamp()`. Users view at consistent DPI inside panes and windows; a fluid h1 that shrinks in a sidebar looks worse, not better. -**The Wide-Sheet Rule** (decided 2026-07-12). The reading surface is full-width and left-set, never a centered narrow column: all content — running prose *and* wide blocks (mermaid diagrams, tables, code) — shares one column capped at the `--content-measure` token (1100px); oversized tables/code scroll inside it. (Revised 2026-07-13 from the original split layout — 72ch prose beside full-pane blocks — which read as ragged whenever a wide block was on screen.) The `micro` (2px) radius is the mark family for inline review marks, focus rings, and accent bars. +*Marketing carve-out* (added 2026-08-05, attn-n01r.18). The rule's rationale is panes and sidebars, which the hosted **landing** does not have — it is a full-bleed Persuade surface viewed at whatever width the visitor brings. Display headings there may `clamp()`, in two tiers only: the hero `h1` at `clamp(3.2rem, 5.2vw, 6rem)` and every section head at `clamp(2.6rem, 4.4vw, 4.6rem)`. Two tiers, not per-section values — a third coefficient is how the `h1` ended up rendering *smaller* than two `h2`s at 1440px. Everything else, including the desk and the app shell, stays on the fixed ramp. The landing had already forked this by 3x with nothing written down; this records the fork rather than pretending it isn't there. + +**The Wide-Sheet Rule** (decided 2026-07-12). The reading surface is full-width and left-set, never a centered narrow column: all content — running prose *and* wide blocks (mermaid diagrams, tables, code) — shares one column capped at the `--content-measure` token (**960px**); oversized tables/code scroll inside it. (Revised 2026-07-13 from the original split layout — 72ch prose beside full-pane blocks — which read as ragged whenever a wide block was on screen. Retuned 1100px → 960px, and corrected here 2026-08-06 where the doc still said 1100.) The `micro` (2px) radius is the mark family for inline review marks, focus rings, and accent bars. + +*Measure is a preset's to move, but only when the column IS the preset's identity* (2026-08-06). Changing `--content-measure` re-wraps every line and moves the document's right edge — the most disruptive thing a preset can do — and 960px is a reviewed decision, not a neutral default. So Manuscript sets it (660px; a short measure is the entire point of a book column) and Compact keeps its long-standing 880px. Everything else inherits 960px. Modern and Terminal briefly carried 920/900px: arbitrary nudges that re-litigated a settled decision, and Terminal's was backwards, since monospace fits *fewer* characters per pixel and a narrower column shortens the line twice over. + +**The Measure-Is-Opt-Out Rule** (open, 2026-08-06). The shared column is currently applied by a hand-maintained *allowlist* of element selectors, so anything not named in it silently escapes the measure. Two blocks were found escaping in one sweep (the frontmatter card and the math container), and on the viewer side the list can never be complete, because comrak passes raw HTML through — an author writing `
` or `
` in markdown lands an arbitrary element outside the column. The durable shape is `article.attn-doc > *` with explicit opt-outs for the wrappers that need a `min()` clamp. Recorded as the intent; not yet implemented. -## 4. Elevation +## Elevation & Depth A hybrid: mostly flat tonal layering (chrome recedes by being a step darker than content, not by floating), with a small, restrained shadow vocabulary reserved for genuinely-lifted surfaces — review cards, dialogs, dropdowns — and soft *inset* shadows that make code blocks and inputs read as pressed into the paper. The paper-grain overlay (a fixed fractal-noise SVG at `--grain-opacity`) sits above everything as the unifying texture; it is not elevation but it is why nothing looks like flat plastic. @@ -202,7 +260,7 @@ A hybrid: mostly flat tonal layering (chrome recedes by being a step darker than **The Topmost-Escape Rule.** Escape closes exactly one layer — the topmost (palette → composer → dialog → popover → drawer) — and never destroys a draft. Every overlay stores focus on open and restores it on close. -## 5. Components +## Components ### Buttons - **Shape:** gently rounded (`8px`, `{rounded.md}`), `min-height: 46px`, sans-serif 700. @@ -217,8 +275,10 @@ A hybrid: mostly flat tonal layering (chrome recedes by being a step darker than - **Moved badge:** a muted neutral pill (`--moved-badge-bg`) marking a re-anchored suggestion. ### Cards / Containers -- **Review margin card** (signature): the primary container. Near-opaque raised paper (`oklch(0.94 0.010 76 / 96%)`), `10px` radius, `16px` padding, the review-card lift shadow, and a top hairline border. A left color accent identifies comment (amber) vs. suggestion (green) — carried as a small accent element, **not** a thick side-stripe border. +- **Review margin card** (signature): the primary container. Near-opaque raised paper (`oklch(0.94 0.010 76 / 96%)`), `6px` radius, `10px 12px 10px 13px` padding (the asymmetric left leaves room for the accent), the review-card lift shadow, and a top hairline border. +- **The accent strip** (corrected 2026-08-06): a `3px` full-height strip on the card's left edge, **square at both ends** even though the card's corners are round. It carries `--rmc-accent` — the comment author's personal color, with kind (comment amber / suggestion green) and state (stale / low-confidence) overrides layered after. Implemented as an absolutely-positioned `::before` at `border-radius: 0`, with `isolation: isolate` on the card so its negative `z-index` cannot escape. It was previously an `inset` box-shadow, which the card's radius necessarily clipped into a tapered curve at both ends; the strip is information (who, and what kind), so it must not read as a decorative flourish. The card must never gain `overflow: hidden` — that would re-clip the strip and bring the curve back. - **General panels:** flat, one tonal step off the content surface, hairline `18%`-ink borders. No nested cards. +- **Tables** are code blocks: same `--code-block` fill, 1px border, `6px` radius and inset lip. Achieved with `border-collapse: separate` + `border-spacing: 0` (a collapsed table merges cell borders into the table box and squares off the radius) and **no cell backgrounds** — a filled header row would re-square the top corners and cover the inset lip, so header distinction is carried by ink weight instead. ### Inputs / Fields - **Style:** `84%` paper fill, `10px` radius, hairline `12%`-ink border, `32px` high, sans-serif `0.82–0.95rem`, a top inset highlight. @@ -236,7 +296,7 @@ The editorial heart of the product. Reviewer edits render as attributed inline m - **Comment anchor:** amber highlight tint behind the running text, `box-decoration-break: clone` so it wraps cleanly across lines. - **Confidence ramp & stale:** anchored suggestions carry a descending-presence background (high → low) in the accent hue; a stale anchor desaturates and switches to a dotted underline. -## 6. Do's and Don'ts +## Do's and Don'ts ### Do: - **Do** keep the terracotta/steel accent to action, selection, and focus only — the One Pencil Rule. Everything else is ink, paper, and the second neutral layer. @@ -252,7 +312,7 @@ The editorial heart of the product. Reviewer edits render as attributed inline m - **Don't** let it read like an **IDE** (VS Code): no activity bars, no panels-in-panels, no everything-is-a-toolbar. The reading column is the hero. - **Don't** let it drift toward **Notion rounded-pastel**: no candy-colored blocks, no emoji-forward headers, no soft-everything. Warmth is paper and type. - **Don't** borrow the Linear-clone **saturated-purple glassy gradient-glow** dark theme; INK mode is a cool blue-black study, not neon. -- **Don't** use a `border-left`/`border-right` colored stripe > 1px on cards or callouts (the review card identifies comment vs. suggestion with a small accent element, not a side-stripe). +- **Don't** use a colored side-stripe on a card or callout as *decoration* — the AI-UI tell is a thick tinted border that means nothing. The **one** sanctioned exception is the review margin card's `3px` accent strip, which is load-bearing: it encodes the comment's author and its kind/state, and removing it deletes an information channel. (Amended 2026-08-06: this previously read as a flat ">1px" prohibition, which the shipped card had never satisfied — the rule described an intent the product had already outgrown. If a new stripe cannot say what it *means*, it is decoration and the prohibition stands.) - **Don't** use gradient text, glassmorphism as a default, or the drift toward a warm-cream background — all are prohibited. - **Don't** let muted body text go lighter than ~`oklch(0.32 0.012 65)` on paper; light-gray-for-elegance is the fastest way to fail the 4.5:1 floor. - **Don't** fluidly `clamp()` UI headings; the product type scale is fixed rem. diff --git a/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json b/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json new file mode 100644 index 00000000..9e7bd921 --- /dev/null +++ b/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json @@ -0,0 +1 @@ +{"version":"4.1.10","results":[[":web/src/lib/embedded-svg-roundtrip.test.ts",{"duration":0,"failed":true}]]} \ No newline at end of file diff --git a/planning/embedded-svg-threat-model.md b/planning/embedded-svg-threat-model.md new file mode 100644 index 00000000..950c4e1f --- /dev/null +++ b/planning/embedded-svg-threat-model.md @@ -0,0 +1,687 @@ +# Embedded SVG: Threat Model and Sanitisation Decision Record + +**Issue:** attn-vlmz.4.1 (decision) → attn-vlmz.4.2 (implementation) +**Date:** 2026-08-06 +**Status:** Accepted and implemented. Re-argued against six named bypass classes +after review — see §6. + +| File | | +| --- | --- | +| `web/src/lib/svg-sanitizer.ts` | allowlist sanitiser (DOM-free, unit-testable) | +| `web/src/lib/embedded-svg-view.ts` | DOM builder, post-build audit, sizing, injected CSS | +| `web/src/lib/schema.ts` | `embedded_svg` node, `attn_svg_block` markdown-it rule, serializer | +| `web/src/lib/svg-sanitizer.test.ts` | 74 cases, adversarial | +| `web/src/lib/embedded-svg-roundtrip.test.ts` | 22 cases, byte-exact round trip | + +## The report + +A document containing raw SVG rendered as literal escaped text in a paragraph — +the reporter saw `'` — no `unsafe-inline`, so injected inline handlers and inline `