Skip to content

Review-surface polish, reading-palette + autosave epics, design-system consolidation - #12

Merged
angusbezzina merged 59 commits into
mainfrom
angus/attn-review-tweaks
Aug 10, 2026
Merged

Review-surface polish, reading-palette + autosave epics, design-system consolidation#12
angusbezzina merged 59 commits into
mainfrom
angus/attn-review-tweaks

Conversation

@angusbezzina

@angusbezzina angusbezzina commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator

Started as review-surface polish (attn-rd3j); the branch now carries five verified bodies of work.

What's here

attn-64iy — browser App.svelte repair + shell-aware chrome. The comment round trip works in the browser dev loop (mock share publishes real snapshots with canonical anchor indexes); composer entry points fail honestly instead of silently; chrome is shell-aware (brand takes the freed corner in a browser tab, desktop keeps traffic-light clearance); header cluster evened out; comments toggle uses the panel glyph pair; ShareChip rests as a ghost.

attn-evme — Kindle-calm reading surface. One ink for prose; links are the accent with a load-bearing rest underline (the contrast maths shows no colour alone can pass AA against the page and 3:1 against body text); code/tables/frontmatter recessed into the page; syntax theme re-picked for the new ground.

attn-yzsa — desktop autosave. native-autosave.ts (pure gate + clock): debounce + ceiling, a disk-conflict HOLD that refuses to overwrite a file changed underneath the editor, ⌘S as immediate flush. "Changes autosaved" is now true on both surfaces. Proven against a real daemon writing a real file.

Design-system consolidation — 10 ratified issues. Type ramp gains its micro end (11px standard / 10px floor; 78 arbitrary sizes collapsed); pill radius unified; literals → tokens (fixing a dark-mode contrast bug in suggestion ghosts); dead static renderer deleted; the document surface single-sourced under an explicit six-layer cascade (theme < base < chrome < doc < components < utilities), retiring the table-twin and unlayered-link escape hatches; one save-state vocabulary; all three headers reconciled (attn-o17v).

Guard rails added

reading-palette.spec.ts (live contrast sweep, both themes, canvas-resolved colours), doc-surface-parity, design-doc-parity (DESIGN.md frontmatter pinned to tokens.css, mutation-tested), save-state-copy orphan sweep, header-parity extensions, review-bar-cluster, shell, compose-availability, mock-share-snapshot, native-autosave (20 cases).

Verification

  • 120 unit test files, 0 failures
  • 21 Playwright tests (native-share + browser-review-chrome + reading-palette), green
  • svelte-check at its pre-existing 6-error baseline (local-file-source.test.ts WIP)
  • Desktop autosave verified on a real daemon: file hash changed with no keystroke; external-writer conflict held; ⌘S override honoured
  • Design detector: tree-wide findings down to classified false positives (paper-grain stipple, one hosted side-tab, two test fixtures)

🤖 Generated with Claude Code

…code blocks (attn-rd3j)

Nine reported defects in the native app. Three of them shared one root cause
and two were misdiagnosed until measured; the notes below record what was
actually wrong, since the fixes only make sense against it.

Document typography was global, not scoped (attn-rd3j.3/.4/.5). Bare
p/h1/ul/li selectors in base.css typed the whole page, so app chrome
inherited reading-surface margins and the document's custom checkbox rule
drew a checkmark pinned at left:0 inside the Share dialog — the "weird modal
checkmark" was never an element in ShareDialog. Typography is now scoped to
.attn-doc (carried by the editor mount and the viewer article) and the
.attn-chrome opt-out class, which existed purely to undo the leak, is gone.
Bullets were a faked absolutely-positioned ::before dot with no list-style
fallback, so they vanished whenever the positioning context shifted; they are
real ::marker glyphs now, which cannot detach from their line.

The table "rail" was 13px of stolen layout (attn-rd3j.8). base.css styles
::-webkit-scrollbar globally, and in WKWebView styling it at all downgrades
that element from macOS overlay scrollbars to classic space-reserving ones.
Measured alternatives: scrollbar-width:auto = 17px, webkit revert = 17px,
hidden = 0px — nothing restores overlay behavior. Prose blocks now hide the
bar and scroll by trackpad, the treatment PathBreadcrumb already used. The
rules must sit outside @layer components, because base.css's scrollbar rules
are deliberately unlayered and unlayered beats layered.

Review-exit membership was answered from the wrong set (attn-rd3j.2).
ownerRoomForPath resolves a file to a room through the share ROOT, which for
a multi-file share is the whole project — so every file "belonged" to the
review. Added roomPublishesPath, which answers from published snapshots and
reconciles relative snapshot paths against absolute nav paths. The same
confusion is fixed in the owner auto-follow effect, which was re-selecting
the room right after an explicit exit and putting review chrome on files that
were never shared; it now does what its docstring already claimed. BEHAVIOR
CHANGE: opening an unshared file in a shared project turns collaboration
chrome off instead of leaving the chip and rail on.

Syntax highlighting was never a Rust concern (attn-rd3j.10). src/markdown.rs
renders no HTML and comrak's syntect feature is off by design — client-side
shiki is the intended architecture. The gaps were a hardcoded 20-language
allowlist that silently dropped everything else, and untagged fences getting
zero decorations. Languages now resolve against shiki's full bundle with
on-demand loading, and untagged fences get conservative content-based
detection (confident-match-or-nothing; JSON verified by parsing it).

Also: a zoom_window IPC so double-clicking the hidden titlebar zooms and
restores, attached to every existing drag surface (attn-rd3j.1); the code
copy button and language label moved onto a non-scrolling frame so they stay
pinned over wide blocks (attn-rd3j.9); dialog bodies, the project switcher
and other chrome scroll through the shared ScrollArea (attn-rd3j.5); and a
Settings dialog with three-state appearance (Paper/Ink/System, durable and
stamped before first paint so there is no flash of the wrong theme) plus
shadcn-style typeset presets (attn-rd3j.6/.7).

Verified by driving the running app, not just the tests: dblclick zoomed
960x720 -> 1512x887 -> back with buttons excluded; the copy button moved 0px
while content scrolled 106px; reserved scrollbar space went 13px -> 0 on
every overflowing block; the exit prompt fires, cancel preserves, confirm
tears down cleanly and re-entering re-activates; system->dark resolved on a
dark-mode Mac and a manual Paper choice survived a daemon restart. That pass
also caught a defect of its own — the selected Appearance segment used
bg-background over bg-muted/30 and was invisible in dark mode.

97 web test files and 1207 Rust tests pass (17 new unit tests). test-e2e and
test-review-e2e are byte-identical to the pre-change baseline, confirmed by
stashing and re-running — their failures are pre-existing. Release binary
32.09/40 MiB.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@vercel

vercel Bot commented Aug 4, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
attn Ready Ready Preview Aug 10, 2026 4:21pm

Request Review

All three were already failing on main; the CI quality job runs
`cargo fmt --check` and `cargo clippy --all-targets -- -D warnings`, and both
gates were red before this branch.

- apply.rs carried a duplicate `use serde::{Deserialize, Serialize}` inside
  `mod tests`, unindented at column 0. The test module derives via the fully
  qualified `serde::Deserialize`, so the import was dead — and its indentation
  was also what `cargo fmt --check` was failing on.
- publish_snapshot_plaintext takes 8 arguments. Allowed with a rationale,
  matching the ten existing precedents (two in this same file): the argument
  list IS the identity of a published snapshot, so a params struct would move
  the same list one level away from the call site without simplifying it.
- The drag-drop handler's nested `if let` collapses into a let-chain, the form
  already used elsewhere in this codebase.

Verified: cargo fmt --check clean, cargo clippy --all-targets -D warnings
clean, cargo test --locked green (1207), cargo build --locked green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…hm (attn-rd3j.11)

Four follow-ups from review of the branch.

The Share dialog's file picker was capped at a fixed max-h-72. Added to the
surrounding chrome that exceeded the dialog's 85vh ceiling, so the whole modal
scrolled and the create button sat below the fold. The cap is now relative to
the WINDOW — min(20rem, 34vh) — so list plus chrome always fits and only the
list scrolls. Two approaches were tried and rejected first: flex-1 on the list
collapses it to its minimum, because the dialog's own height is auto and
basis-0 children make an auto-height flex container size to minimums; pinning
the ScrollArea viewport with `absolute inset-0` then takes it out of flow, so
the root has no intrinsic height and cannot be sized either way.

Tables render inside prosemirror-tables' `.tableWrapper`, not the schema's
`.prose-scroll-x` — its columnResizing plugin installs a TableView that
replaces the schema's DOM. This stylesheet is hand-rolled and deliberately
does not import prosemirror-tables' CSS, so that wrapper arrived with NO
styles at all: unbounded width, no horizontal scrolling, no frame. It now gets
the same treatment as every other wide block, which is also what makes the
scrollbar-rail fix (attn-rd3j.8) actually reach tables.

Code blocks and tables had zero vertical margin — the `pre` carries an inline
margin:0 and the frame had none — leaving them sandwiched between the heading
above and the paragraph below. Wide blocks are bordered cards, not running
prose, so they take 1.5rem of block margin.

The sidebar's filter empty state sat flush against its container while every
sibling row is inset 10px.

Settings moved from a floating bottom-right cog into the header, immediately
right of the share control, and the resident/mute controls merged into the
Settings dialog alongside appearance and typeset — one settings surface rather
than two. The launch-at-login status listener moved up into App: a dialog is
unmounted while closed, so a listener living there would drop every result the
user is not watching and reopen showing startup state.

Verified live: 30-file share dialog no longer scrolls its body (title and
create button both on screen, list scrolls internally at 245px); an
overflowing 12-column table scrolls with 0px reserved rail and stays inside
the content column; code blocks sit 23px clear of neighbours; empty state
insets 10px on both sides; header settings opens and switches theme.

97 web test files pass, clippy and fmt clean, e2e and review-e2e identical to
the pre-change baseline.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
angusbezzina and others added 22 commits August 4, 2026 21:26
…of desk payload (attn-n01r)

First implementation pass over the attn-n01r remediation epic.

attn-n01r.22 — dark-mode visitors saw ~1.2s of paper-white before INK applied.
The hosted entries carry blocking stylesheets, so CSS painted the PAPER ground
long before the deferred module ran initTheme(), and no stylesheet carried a
prefers-color-scheme fallback. Restored the native app's pre-paint stamp,
admitted by CSP source hash rather than a nonce: one script, one hash, injected
into all three entries by a Vite plugin so they cannot drift. csp.test.ts
recomputes the hash and fails if it does — without that guard a drifted hash
silently blocks the script and the flash returns with no error anywhere.
Measured: first painted frame is now INK, zero wrong-theme frames.

attn-n01r.41 — the desk shipped 1.20MB, including bits-ui and the dialog set for
a component it never renders. EditorShell is now loaded on demand and the
markdown serializer is imported at its call site. 1,203.5KB -> 1,000.8KB.
The remaining ~600KB needs the read-only desk service split from the editor
runtime; commitNow() is synchronous on the autosave path and deferring the
parser there would change collab commit ordering. Left open with the analysis.

attn-n01r.43 — 200% text forced 612px of layout into a 390px viewport and
clipped the header. Four causes: a fixed 64px grid row, a grid column sized by
its widest child, a header that could not wrap, and two unbreakable strings.
Now clean at 320/390/768/1280/1920 at both text sizes.

attn-n01r.1 — the join panel sat exactly on the list label (0.00px gap) because
.folio-label owned no margin and borrowed it from .quick-actions via sibling
collapse. Both now own their spacing. 0.00px -> 40.00px.

attn-n01r.7 — every file gets an icon. Removed MARKDOWN_NO_ICON and the
includeMarkdown flag rather than defaulting it, since neither call site wanted
suppression once they agreed.

svelte-check 0 errors; 97/97 unit test files pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ntom tokens (attn-n01r.42)

--rust-deep aliases --primary-hover, so every delete/error surface on the desk
rendered steel blue in the dark theme — the INK desk had no red at all. The six
destructive surfaces now use --danger-ink, which is the text-safe tier and flips
correctly: oklch(0.45 0.18 27) in PAPER, oklch(0.70 0.16 25) in INK.

--radius-md, --radius-lg and --wash were consumed on the hosted routes but
declared only in src/app.css, inside a Tailwind @theme block the hosted entries
never import. Undefined custom properties fail silently, so four components
rendered square corners and one hover state had no background, with no error
anywhere. Declared in chrome.css beside the other hosted aliases, derived from
the same --radius so they cannot drift.

Also: the three inline error <p> styles became the .form-error class they were
copied from; .desk-title and .storage-panel hairlines moved from solid --ink to
--rule (they rendered as the brightest element on the dark page); and
.workspace-row .local-badge re-asserts its colour after a specificity collision
made 'Shared' indistinguishable from 'Local only'.

svelte-check 0 errors; 97/97 unit test files pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
….23)

At 200% text the home page forced 574px of layout into a 390px viewport and
clipped the nav, taking the primary CTA out of reach. Five causes, only two of
which were in the original finding — the rest surfaced by re-measuring after
each fix:

- Four grids used bare 1fr, which is minmax(auto, 1fr) and floors at
  min-content. Now minmax(0, ...), matching .local-note which already did this.
- .site-nav used a fixed height and so clipped its own contents (box 70 vs
  content 79). Now min-height.
- .site-nav and .nav-right could not wrap, setting a width floor wider than the
  viewport. This was the single largest contributor.
- Display headings could not break a long word ('documents.'), overflowing 46px.
- The install command is one unbreakable token; .code now scrolls internally
  per DESIGN.md's Wide-Sheet Rule, and .footer-links wraps.

Clean at 320/375/390/414/768/1024/1280/1920 at both 100% and 200% text.

97/97 unit test files pass; svelte-check 0 errors.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…1r.12)

Eight controls missed 44x44 and four missed even the WCAG 2.2 AA 24x24 floor.
The pattern was clean: .button already sets min-height 46px and everything using
it passed, so every miss was a control outside that class — nav links, the
brand, the copy buttons, the footer links. The mobile override was also dropping
the nav CTA to 40px, below .button's own floor, at exactly the width where it
matters most.

Now zero controls under 24x24 at any breakpoint. The two short footer text links
remain under 44 wide at full 44 height; that is 2.5.5 AAA guidance rather than
the AA bar, and a min-width there would gap the row without helping acquisition.

Footer links also gained --ink and an underline — they had been byte-identical
to the adjacent licence text with no underline, so nothing at rest marked them
as links.

svelte-check 0 errors; 97/97 unit test files pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…l badge (attn-n01r.19/.24/.25)

Structural accessibility on the home page, all verified by live AX and keyboard
probe rather than by inspection:

- Added a skip link as the first Tab stop; previously stops 0-6 were all
  navigation with no way past them, and <main> carried an id nothing linked to.
- Wrapped the nav in <header>. Landmarks measured navigation -> main ->
  contentinfo with no banner, so the brand, toggle and CTA sat outside every
  landmark.
- The three entry-strip <h2> card labels are now <strong>. They are the names of
  links, not sections; the outline went from 12 headings (six top-level
  sections, three of them buttons) to a clean 9.
- 'Start here' moved from ::after into real markup inside a flex row. As
  generated content it was skipped by translation and in-page search and landed
  last in a 100-character link name; being absolutely positioned it also
  reserved no space, which is what made it sit on top of the eyebrow between
  681 and 730px. Measured at 690: -6px overlap -> +12px gap.
- The theme toggle now exposes aria-pressed and an action-naming label; it was
  static across both states with both icons aria-hidden.
- Removed the aria-label from .product-stage: it is a bare div, so the string
  was discarded by the AX tree. A label that reads as coverage while doing
  nothing is worse than none.
- Entry cards carry aria-labelledby so the link list is scannable.

svelte-check 0 errors; 97/97 unit test files pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…Escape (attn-n01r.30)

The worst defect here was data-loss-grade: Rename and Delete carried no
accessible context, so tabbing a multi-workspace desk announced 'Rename button,
Delete button, Rename button...' with no way to tell which workspace was about
to be irreversibly deleted. Both now name their target.

The rows are a real <ul>/<li> labelled by the section heading, and 'Recently on
this device' is an <h2> rather than a styled div — the populated desk had been
exposing less structure than the empty one, which had a heading and it did not.

The delete confirm announced role=alertdialog but never behaved like one:
focus was never moved into it and Escape did not dismiss it, so a screen-reader
user was told a dialog opened and then found focus still on the button behind
it. It now moves focus in, restores it to the invoking button on cancel, and
closes on Escape. A shell-level handler closes the topmost layer — confirm
first, then the join panel — and closeJoin restores focus to its trigger.

The join tile also exposes aria-expanded/aria-controls.

svelte-check 0 errors; 97/97 unit test files pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…re they matter (attn-n01r.2)

Copy on the install commands and Rename/Delete on every desk row were carrying
their labels as text. At mobile width two stacked commands each had a 'Copy'
competing with the command itself, and on the desk the three most-repeated words
on the screen were 'Rename, Delete, Rename, Delete...'.

All three are now icons at 44x44, each with a title for hover and an aria-label
naming its target ('Delete Untitled', 'Copy npx attnmd'). The header 'Storage'
button keeps its text — it is a navigation destination, not a conventional
glyph.

Fixed alongside, in the same component: the copy button's accessible name was
pinned to 'Copy <cmd>' forever with no live region on the page, and its empty
catch made a denied clipboard permission indistinguishable from success. The
name now tracks state, failure is reported, and a polite live region announces
the result. Added the .visually-hidden utility the page lacked.

svelte-check 0 errors; 97/97 unit test files pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ttn-n01r.4/.5)

The edit bar duplicated the masthead's save state as text capped at 26vw with an
ellipsis, so it read 'Saved on th...' while taking room from the formatting
controls. It is now a glyph that differs per state — check, spinner, alert —
with the full sentence on title and aria-label, and role=status so a change is
announced rather than silently repainted. The glyph carries the distinction, not
the colour.

The workspace title was a bare button that dropped into a text input on one tap,
directly above the document and in the thumb's travel path, for an act performed
about once per workspace. The name is now inert and rename has its own 44x44
pencil beside it — the same pencil the desk row now uses for the same act.

svelte-check 0 errors; 97/97 unit test files pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…(attn-n01r.31/.36)

'Backup recommended' rendered in the same green as 'On this device' — green on
green is the universal 'you're fine' signal, attached to a message meaning the
user's work is one storage eviction from gone. best-effort is now its own
caution tier in muted ink with a hollow dot, so the tiers differ in shape as
well as hue, and any non-ok badge is a link to the storage page rather than a
dead span. Green also stops appearing on base chrome, where the Quarantine Rule
never allowed it.

The desk's accent was inverted: terracotta marked a static eyebrow nobody clicks
while the primary action carried none. The eyebrow is now muted (shared with the
landing, which had the same inversion) and the accent sits on New workspace.
Landing accent marks went from 12 to 3, all of them action.

Action labels moved from 400-weight serif to sans — DESIGN.md is explicit that a
button never uses the serif — and the deprecated <big> became <strong>, with the
label ahead of its fine print in the accessible name.

The 9.5px tile misalignment had a second cause the audit did not see: .quick
carried a duplicate 'display: block' after the grid declaration, silently
overriding it. Label tops now measure identically, spread 0.0px.

svelte-check 0 errors; 97/97 unit test files pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…on anchor jumps (attn-n01r.11/.21)

The h1 was rendering smaller than two of the h2s. The caps were identical at
6rem but the chapter h2 had a steeper vw coefficient, so it overtook the page's
own title at wide viewports — four equal mastheads and no summit. Both h2 rules
now share one tier below the h1, which leads by 18-30% at every width, and the
three h2s are finally equal to each other.

Anchor jumps parked section headings under the 70px sticky nav. Added
scroll-padding-top driven by a --nav-h token the nav itself reads from, so the
offset cannot drift from the height. #how now lands at 86.4px, clear of the nav.

97/97 unit test files pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…tions (attn-n01r.40/.46)

Clicking 'Join a review' only set local state while closeJoin() replaced the URL
as though a hash were there, so the open and close paths disagreed and a reload
lost the panel. Opening now pushes /app#join, which is what the close path
already assumed. The invite field also carries aria-invalid and points at its
error message; the message had role=alert but the field was never marked invalid
or associated with it.

The blocked storage state used the disabled attribute on both primary actions,
which removes them from the tab order entirely — a keyboard user never
encountered them and was never told why. They now use aria-disabled with a click
guard and point at a role=alert explanation of what is wrong and what to check.
The banner above them is role=status, a polite region rendered at load, so it
announces nothing on arrival.

svelte-check 0 errors; 97/97 unit test files pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
workspace-state.svelte.ts was 122 lines nothing imported — verified before
removing, the only reference was a stale comment in workspace-service.ts, which
now points at AppShell's own state fields.

sharingLabel() carried a 'shared' case the template handles first and hardcodes
identically, so one label had two sources of truth. The parameter is now typed
Exclude<SharingState, 'shared'> so the compiler owns which cases the function
handles, rather than leaving a dead arm for someone to maintain.

Left alone deliberately: the ~17 remaining inline styles are mostly in
StoragePage, which 404s on this build (attn-n01r.45), and restyling a page I
cannot render is how regressions ship.

svelte-check 0 errors, file count 1527 -> 1526; 97/97 unit test files pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… green (attn-n01r.26/.37)

The reduced-motion rule was 'transition: none !important' on *, which works on
today's page but outranks component CSS with no per-component escape and never
touched animation at all — so any future transition carrying meaning would be
flattened and any future keyframe would run unsuppressed. Replaced with the
scoped idiom that collapses timing to 0.01ms and covers animation. Verified
resolving to 1e-05s under reduce.

--native-panel-label was warm salmon in both themes, the only warm hue in the
dark theme and an orphan beside a green label. Retired in favour of
--native-panel-muted, with the alt landing's two references repointed first —
deleting a token while consumers still reference it would have reproduced the
silent 0px/transparent failure this epic already fixed once.

--rule-strong aliased straight to --foreground, rendering .step's rule at full
opacity: oklch(0.87) in INK, the brightest line on the dark page. Now 45% alpha,
which is what DESIGN.md means by a hairline.

Green also left base chrome — the browser surface label and the hero bullet dots
had no collaboration meaning, which is the whole point of the Quarantine Rule.

97/97 unit test files pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…-n01r.28)

The woff2 were only discovered after index-*.css parsed, giving 900ms and 836ms
of fallback-font text on the 74.88px serif h1 — and that swap was the entire
source of the page's CLS.

A build plugin now injects rel=preload for the serif and sans latin faces,
reading the content-hashed filenames out of the emitted bundle rather than
hard-coding them; a stale hash would preload a 404 and make things quietly
worse.

Measured on the same throttled profile: both faces now discovered together at
592ms instead of 786/866ms, both complete before first contentful paint, and
CLS 0.0027 -> 0.

Source Code Pro is deliberately not preloaded — 18 of the 20 strings it sets are
prose rather than code, so it should leave the critical path entirely rather
than arrive sooner.

97/97 unit test files pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… to action (attn-n01r.18)

Three of the four forked rules restored, measured rather than eyeballed:

Mono was carrying a decorative 'technical voice' — 18 of its 20 strings were
English prose, which is the third case the Read/Do Rule forbids and the most
generic dev-tool signal on the page. Seven roles moved to the sans; walking
every leaf's computed font-family now finds Source Code Pro on exactly the two
shell commands.

Green left base chrome, where the Quarantine Rule never allowed it, and the
accent left decoration: landing accent marks went 12 -> 3, all of them action.

The window dots became painted circles instead of pseudo-element text — they
measured 1.72:1 and 1.63:1, so the decorative exemption was arguable and any
scanner would flag them.

Glassmorphism left alone: at 88% veil opacity, whether the sticky nav counts as
'a default' is genuinely arguable and changing it alters the page's look.

The Fixed-Scale fork is now documented rather than silent — DESIGN.md gains a
marketing carve-out permitting clamp() on the landing in two tiers only, since
a third coefficient is exactly how the h1 came to render smaller than its h2s.

97/97 unit test files pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The desk set its entire metadata layer in 11.5-13.6px uppercase mono with 0.1em
tracking, doing the job of a label — while DESIGN.md's actual label token
(0.7rem sans, uppercase, 0.06em) went unused on the surface. .local-badge, the
storage line and .folio-label now use it.

This is a legibility change, not an accessibility one, and the distinction
matters for the changelog: 31 desk text styles were measured across four
state/theme combinations and every one clears AA with a 5.23:1 floor, with
axe-core reporting zero violations. The screenshots read washed-out because the
type is small, uppercase and widely tracked, not because the contrast is short.

'UNTITLED.MD · NOT CREATED YET' stays in mono — it is a filename, which is what
mono is for. It is now the only mono string on the desk.

97/97 unit test files pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
….45)

The navigation handler was network-first with a fallback only when fetch threw,
so a 404 — a successful HTTP response — passed straight through. That is why the
service worker could not compensate for a missing host rewrite: /app/storage and
every workspace deep link rendered the host's error page on reload and lost the
workspace.

Narrow by design: navigations only, 404/410 only, and only when a shell is
actually cached. 500s, redirects and the offline path are untouched, so this
cannot dress a real outage up as a working page.

This only covers returning visitors. A cold first visit still needs the host to
rewrite deep paths to the SPA shell, which is a deploy question I cannot verify
from here — recorded on the issue along with the missing e2e assertion.

svelte-check 0 errors; 97/97 unit test files pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…-n01r.20)

The page ended on 'MIT License · Rust + Svelte · ProseMirror editor' and three
GitHub links. Peak-end theory puts half the remembered experience in the ending,
and that ending was a colophon — the developer who will star the repo has
already scrolled seven screens to reach it, while the person who should create a
workspace was shown it instead of an invitation.

Added a closing band carrying the same state-aware CTA pair the nav and hero
use, weighted as a section head rather than a second masthead. The colophon
stays, underneath, where it belongs: the ordering was wrong, not the content.

97/97 unit test files pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…n01r.29/.32)

The desk scored 0/4 on Flexibility and Efficiency — the only keyboard handler in
the file was Enter/Escape inside the rename input — on the surface the product's
primary user opens most, for a product whose stated principle is that every
action is keyboard-reachable. It now has '/' to filter, Up/Down to select, Enter
to open, Escape to clear.

Testing caught a bug reading would not have: the handler was first bound to the
.app-shell div, so a keypress with focus on <body> — the state the page loads in
— never reached it and '/' silently did nothing. It belongs on the window.

The row's hover rule targeted 'a.workspace-row' while the element is a div, so
it never matched and the row had no hover state at all. The open target was the
name text alone, about 200x28px inside an 80,000px² row. Both fixed: the link
stretches across the row, the actions are raised above it, and they now reveal
on hover, focus-within or selection instead of stamping 'Rename Delete' on every
line. Touch devices keep them visible, since there is no hover to reveal them.

svelte-check 0 errors; 97/97 unit test files pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…works (attn-n01r.33/.35)

On mobile the desk put its entire payload below the fold — three stacked tiles
pushed the first workspace to y=813 in an 844px viewport, about 1.7 viewport
heights of chrome before a document. The tiles are compacted rather than the
list moved; the first row now lands at 592-611px across 320/390/768.

The <=900px breakpoint had been hiding file count and last-edited, which turned
three workspaces into byte-identical rows on exactly the device where the name
is least likely to be unique — in a list titled 'Recently on this device'. They
now reflow under the name instead of disappearing, and the name gets its own
full-width line (160px, up from 118).

The empty-state card was an <article> with no click handler: the largest, most
document-like object on the first-run screen, doing nothing, while restating the
offer of the tile 200px above it. It is a button now and creates the workspace.
It also fits — bottom at 880 against a 900px viewport, where it used to be
cropped at 916 and lose its own closing line. Its aria-label was art direction
that contradicted its content, so the button's text is its name instead.

svelte-check 0 errors; 97/97 unit test files pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ttn-n01r.13/.27)

The hero frame was sized by whatever the stage had left over — 1.025 against a
1.333 source — so object-fit: cover discarded ~13% off each edge and sliced the
product's whole thesis. The file tree read '-cap-...', 'er files', 'lan.md'; the
comment card stopped mid-sentence; the COMMENT and SUGGEST badges were cut to
'CO' and 'S'. The frame now carries aspect-ratio 4/3 and the image uses contain,
so all of it is legible. No new art required, and the CLS reservation matches
the painted box as a side effect.

The sizes attribute claimed 920px for a ~615px box, so the LCP over-fetched by
19.3KB on every DPR-1 desktop; it now resolves to the 768w variant.

The six PNG fallbacks were 7.41MB that AVIF-capable browsers never touched —
while a non-AVIF client pulled a 2.0MB PNG into a 631px box with no smaller step
to degrade to. Replaced with 1280w WebP: 274KB total, ~65KB worst case.

svelte-check 0 errors; 97/97 unit test files pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…n-n01r.8 follow-up)

The design hook caught something the epic got wrong about itself. DESIGN.md:200,
which I wrote during attn-n01r.8, claims 'the hosted app shell now uses exactly
these nine' steps. It did not. Three fluid clamp() headings survived the sweep,
because that pass matched literal font-size values and a clamp() is not one:

  .desk-title h1     clamp(2.8rem, 5vw, 5rem)   -> 2rem   (display step)
  .writing-sheet h1  clamp(2.4rem, 5vw, 3.4rem) -> 2rem   (display step)
  .share-head h2     clamp(1.85rem, 6vw, 2.3rem)-> 1.5rem (headline step)

All three violate rules this branch itself added or restated. DESIGN.md:206 puts
the marketing clamp carve-out on the landing only and names the desk and the app
shell as staying fixed; DESIGN.md:175 specifies Display/h1 as 2rem and says in
the same sentence that it must not fluidly shrink inside a pane —
.writing-sheet h1 is that exact heading, and it was shrinking inside a pane.

The desk title drops 64px -> 32px at 1280. It reads better: at 64px it was
competing with the sheet preview's own heading on a page whose real content is
the workspace list.

Also snapped two singleton radii (.folio-filter kbd 4px, .file 5px) to 6px, the
value that file already uses eleven times. The wider problem — eleven distinct
radius values and no documented scale — is filed rather than fixed, since
choosing that scale reshapes every corner in the app and is not hook cleanup.

Verified: 0 off-ramp font sizes and 0 fluid heading clamps remain in
app-shell.css; zero overflow at 390/768/1920; routes e2e unchanged at 89 passed
/1 skipped/1 pre-existing failure; 98 unit files; svelte-check 0 errors.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…are-flow repair

Implements two epics of reported UI issues (attn-11g4 desktop, attn-vlmz web),
plus several defects the work uncovered. 27 of 32 issues closed.

DESKTOP (attn-11g4)
- Share modal now scrolls. A percentage height cannot resolve against a
  max-height-bounded, height:auto ancestor, so the ScrollArea viewport grew to
  full content height and the excess was clipped. Fixed in the shared
  component; all 8 call sites measured before/after. The Settings dialog had
  the same latent bug at short window heights.
- Comments rail is resizable: ARIA splitter handle, pointer + keyboard resize,
  width persisted through prefs.json with clamping on both sides of the IPC.
- Tables now sit on the code-block surface (border-collapse: separate +
  border-spacing: 0; a wrapper cannot work because comrak emits a bare table).
- Review card accent is a straight 3px strip, square at both ends. Was an inset
  box-shadow, which the card radius necessarily clipped into a taper. Three
  inset sites existed, not two.
- Exit review now navigates. The owner focus effect re-selected the room during
  teardown (activePath is still the reviewed file), and a throwing tick()
  silently ate the navigation in an unawaited async handler.
- Header icons: file-clock for snapshots, one active-state convention.
  Note --primary is steel blue in Ink, so the pressed state is not orange there.

WEB (attn-vlmz)
- Browser share flow repaired end to end. mock-ipc had no case for
  review_list_shareable_files, so a folder upload never populated the picker.
- A hosted mint (HTTPS invite, no attn://) reverted the dialog to the file
  picker on a share that had SUCCEEDED; the open effect gated on the
  native-only prop. Ready-with-empty-URL can no longer render a skeleton.
- Frontmatter card aligned to the content measure and rebuilt on a new shared
  accordion primitive (framework-free core so a ProseMirror NodeView can drive
  it; no Svelte mounted inside the editor).
- Inline SVG renders through a deny-by-default sanitiser with a threat model.
- Headers and the drop zone recessed onto the chrome plane.

FIXED EN ROUTE (not reported)
- Table serializer destroyed data on save: inline marks stripped inside cells
  (links losing href) and an escaped pipe corrupting column structure - two
  saves to unrecoverable loss, on a table the user never touched, because any
  save rewrites the whole file. 8/10 baseline tables byte-identical after fix;
  only the two bug cases changed.
- Table cells declared no colspan/rowspan/colwidth, so TableMap was NaN-poisoned:
  cell selection threw, and hovering a cell's right edge threw an uncaught
  RangeError.
- .math-container was also escaping the content measure.

DESIGN SYSTEM
- DESIGN.md merged against shipped code (card 10px->6px, measure 1100->960px,
  code-block value, panel/rail planes, 5 typesets) and headings de-numbered to
  the canonical spec forms. The nine-step chrome ramp existed only in prose;
  all nine are now frontmatter typography roles, which is what the detector
  reads. Sidecar regenerated.

KNOWN
- 6 svelte-check type errors in web/src/lib/local-file-source.test.ts, a
  concurrent workstream's file: TS narrowing cannot see the callback reassigning
  lastPayload. Type-only; the test passes at runtime. Left unedited rather than
  rewriting another workstream's assertions.
- This commit also carries that workstream's in-flight local-file-open work,
  which is entangled with the mock-ipc handler above and cannot be split.

Verification: npm test 111 files 0 failures; cargo test --bin attn 647 passed;
svelte-check 6 errors (all the file noted above). Visual pass on the built
binary in both themes. WKWebView is uncovered by automated tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…eading-palette + autosave epics

Four bodies of work, verified together:

attn-evme — reading surface colour. One ink for prose (blockquote un-greyed);
links carry the accent plus a load-bearing rest underline (no lightness passes
AA-vs-page and G183-vs-body at once on this palette); code/tables/frontmatter
recessed into the page (embedded surfaces step toward the foreground, never
lighter than Paper); syntax theme re-picked (github-*-high-contrast + one
colorReplacements patch) after the surface move stranded vitesse-light.

attn-yzsa — desktop autosave. New native-autosave.ts (pure gate + clock,
20 cases): debounce + ceiling, disk-conflict HOLD that refuses to overwrite a
file changed underneath the editor, ⌘S as immediate flush. 'Changes autosaved'
is now true on both surfaces instead of true on one.

attn-64iy tail + design-system consolidation (10 ratified issues):
- type ramp gains its micro end (11px standard, 10px floor) — 78 arbitrary
  text-[Npx] utilities collapsed onto named steps; pill radius unified at
  9999px; radius/size strays snapped
- literals -> tokens: 17 font stacks, review-hue colours (fixing a dark-mode
  contrast bug in suggestion ghosts and de-Tailwinding ins/del marks)
- one document surface: dead Viewer.svelte deleted; table surface single-
  sourced in a new @layer doc under an explicit six-layer cascade order
  (theme < base < chrome < doc < components < utilities), retiring the
  twin-rule and unlayered-link escape hatches and the mt-*/mb-* trap
- one save-state vocabulary: save-state-copy.ts, hosted SaveState union
  derives from it; reviewer header reconciled to the panel-glyph pair
  (attn-o17v)

Guard rails added: reading-palette.spec.ts (live contrast sweep, both themes),
doc-surface-parity, design-doc-parity (DESIGN.md frontmatter pinned to
tokens.css, mutation-tested), save-state-copy orphan sweep, header parity
extensions. DESIGN.md: Two-Tier Surface Rule, The Layer Order, links-are-action
under One Pencil; sidecar refreshed without regenerating the hand-authored doc.

120 unit test files, 21 Playwright tests, svelte-check at its pre-existing
baseline. Desktop autosave proven against a real daemon writing a real file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@angusbezzina angusbezzina changed the title Review-surface polish: window chrome, review-exit guard, theming, code blocks (attn-rd3j) Review-surface polish, reading-palette + autosave epics, design-system consolidation Aug 9, 2026
The bridge callback is the only writer of lastPayload, and TypeScript cannot
see into it — after a literal null assignment the local narrows to null and
every later optional read collapses to never (6 svelte-check errors, which
npm run check turns into a CI failure now the WIP file is committed). Reading
through a function boundary resets the narrowing; no runtime change, and the
harness still passes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Pre-existing on the branch from the rail-width work (1062d1c) — the multiline
assert_eq rewrap rustfmt wants. Rust Quality's fmt check is the whole failure.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
clippy 1.97's assertions_on_constants (under -D warnings) rejects runtime
assert! on constants. The two range invariants move into a const block —
compile-time instead of test-time, strictly earlier, same intent. The
behavioural assertion on normalize_rail_width stays a runtime test.
Pre-existing from the rail-width work, surfaced once the branch hit CI.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@angusbezzina

Copy link
Copy Markdown
Collaborator Author

CI status: 5/6 green — Web Check, Rust Quality, Binary Size Gate, and both Vercel checks pass as of 64ad625 (Web Check and Rust Quality were red and fixed in this PR: svelte-check narrowing errors in a committed WIP test, a rustfmt diff, and a clippy 1.97 assertions_on_constants).

Relay Tests (Ubuntu) is red with the known failure tracked as attn-i38g — the same two anti-enumeration rate-limit cases that have failed main's CI since at least 2026-07-27 (main's last five runs all conclude failure; the tests pass locally with ~100x margin under their timeouts). This PR contains no relay changes; a rerun of the job reproduced the same failure. Fixing it is attn-i38g's scope — masking it here with a timeout bump or skip would hide a real main-branch problem.

🤖 Generated with Claude Code

… render

Two user reports against /:

HEADER (--header-surface, new token). On the panel plane the header vanished
into the sidebar — its third user-reported move (paper -> panel-surface ->
its own plane). One step darker than the panel plane and warmed toward the
clay on Paper; lifted and steel-tinted in Ink. A tinted ground is furniture,
not a mark, so the One Pencil Rule is untouched. Applied to all three headers
(native, hosted owner, browser reviewer) — one grammar, one plane. The
palette probe now sweeps the header (doc name, muted icon, saved glyph);
the saved glyph is classified at WCAG 1.4.11's non-text 3:1 — the standard
DESIGN.md always stated for it — and measures 4.14:1 Paper / 7.08:1 Ink,
re-quoted in DESIGN.md from the probe.

SVG. The attn_svg block rule refused the two shapes agents actually write:
an SVG glued to the following prose line, and one glued under the preceding
line — both rendered as a paragraph of escaped source. The rule now fires on
both: the blank-line-after condition is dropped, and alt:['paragraph'] lets
'<svg' interrupt a running paragraph like a heading or fence. Contract stated
exactly in the rule comment: separated sources stay byte-exact; glued sources
render and normalise to the separated shape on first save (parse-serialize is
idempotent). Mid-line SVGs, list items, blockquotes and 4-space indents stay
text, deliberately. Security unchanged: widening WHERE the rule fires does
not widen WHAT renders — same sanitiser allowlist, same NodeView second gate.
Roundtrip suite updated: 24 cases including the new fixed-point assertions.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…rand

Three owner requests, two of which converged:

WEB header should match the 'Choose file' button below it — that button is
bg-primary. DESKTOP header should be 'orange/blue' — which is exactly what
--primary is in the two themes. So --header-surface becomes var(--primary),
declared at :root so it freezes the real accent and resolves per theme. This
is the header's fourth surface; the trajectory is the argument: paper
(invisible), panel-surface (vanished into the sidebar), a warm tinted plane
(still too quiet), the accent.

An accent ground breaks every control on it — the header vocabulary is
written in --foreground/--muted-foreground/--accent and, worst, --primary,
which is now the ground itself; the active-pill convention would have been an
invisible accent-on-accent. Rather than rewrite ~8 components, the header
re-points those tokens for its own subtree, so every Tailwind utility inside
adapts with no component edits. Polarity flips between themes (Paper's accent
is dark, Ink's is light), so nothing is hard-coded white — --primary-foreground
is correct in both. --amber-deep goes with them: DESIGN.md already held that
the save chip's real signal is the glyph, not the tint.

The four floating cards that render INSIDE the header subtree (ShareChip,
SnapshotBadge, OutboxIndicator, PeerStrip) restore the base palette from new
--chrome-* captures at :root. That capture trick — a var() in a custom-property
declaration resolves on the element it applies to, so it freezes the root
value — was verified empirically in a browser before the design was built on it.

DESKTOP DROPS THE BRAND (owner: 'not needed, just show the name of the active
file'). brandPlacement is three-way now: none on desktop (the Dock icon, window
and app menu already supply identity), sidebar in a browser tab with one,
header in a browser tab without — the empty state is the file picker, where
'what is this?' is a real question. The old `!brandInHeader` inversion no longer means 'sidebar'
(it now also means 'none') and was corrected at the call site.

Also fixes a real accuracy bug in reading-palette.spec.ts: contrast() dropped
foreground alpha, crediting translucent text with its solid colour's ratio —
always in the flattering direction. It now composites. Header muted icons read
4.84:1 Paper / 6.29:1 Ink under the corrected maths, still clear AA; doc name
6.65:1 / 8.41:1.

DESIGN.md records the plane as a One Pencil PLANE EXCEPTION — the rule governs
marks made ON a surface, and this is a surface made OF the accent, where the
pencil inverts. The frontmatter uses a {colors.primary} token ref and the
parity test learned to resolve refs against var(--x) rather than demanding a
duplicated literal.

Verified: brand count 0 and doc name shown in the real desktop window, both
themes screenshotted on desktop and web; 120 unit files, 21 Playwright,
svelte-check 0 errors.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Reported on the desktop app: the filter bar hung out over the document, its
search field crossing the sidebar's own edge.

The sidebar is user-resizable between SIDEBAR_MIN_WIDTH (180px) and 480px.
Measured, the filter stopped shrinking at 194px — so anywhere below ~200px it
overflowed. TWO causes, and fixing either alone leaves the bug:

1. .sidebar-controls is a GRID and grid items default to min-width:auto, so
   they refuse to shrink below their own min-content.
2. That min-content was dominated by the text input's INTRINSIC width. An
   <input> is sized from its `size` attribute (default 20 characters, ~130px),
   and the `min-width: 0` already on .sidebar-filter-input does NOT remove it:
   that declaration governs flex shrinking INSIDE the filter, not the filter's
   min-content contribution to the grid above it. This is why the obvious
   reading of the CSS says it should already have worked.

min-width:0 on the controls' grid items plus `size="1"` on the input drops the
grid's min-content from 218px to 42px; at a 180px sidebar the filter now sits
at 155px inside its 156px content box.

Chose this over raising SIDEBAR_MIN_WIDTH: the minimum is a product decision
about how narrow the tree may get, and it should not be quietly set by an
input element's default character count.

New web/e2e/sidebar-narrow.spec.ts asserts the OUTCOME rather than the
mechanism — every laid-out descendant inside the container's right edge, at six
widths including 170px (deliberately below the clamp, so a future minimum
cannot silently reintroduce this) — plus that the filter still filters once it
can shrink. Negative-controlled: reverting the fix fails exactly the three
narrow widths. Verified on the real desktop window at 180px: zero overflowing
elements.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@angusbezzina
angusbezzina force-pushed the angus/attn-review-tweaks branch from 35e6c3e to acd6a84 Compare August 10, 2026 15:34
@angusbezzina
angusbezzina marked this pull request as ready for review August 10, 2026 15:37
node_modules/.vite/vitest/<hash>/results.json was tracked. The gitignore only
carried `web/node_modules/`, which left the repository ROOT uncovered — and a
root node_modules does get created here (there is a root package.json), so the
artifact walked straight through the gap.

The rule is now unanchored `node_modules/`, which git matches at any depth, so
neither location can leak again.

Worth naming what the file actually was: a cached record of one FAILING test
run ("embedded-svg-roundtrip.test.ts", failed: true) frozen at 2026-08-06.
Stale state that asserts something false about the suite is worse than plain
noise if a tool ever reads it back.

It entered in 1062d1c, which predates this session's work, so this only removes
it from the tree going forward — the blob stays in history. Say the word if you
want it purged from history properly instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two data-loss findings from the Codex review of this branch.

P1 — autosave was bound to a moment, not to a document. The controller kept
only a pending bit, and the commit callback resolved the active path when the
timer fired. Switching tabs, closing the active tab, switching projects, or the
daemon pushing a new document mid-debounce all replaced the buffer with a write
still owed.

The consequence is worse than a dropped write. `edit_save` carries only
content; the daemon resolves the target from its own active_path (src/ipc.rs),
so a late timer does not save the old file late — it saves the old buffer's
text into the newly opened file. The frontend cannot retarget it, because the
target was never the frontend's to name.

Fixed at both levels:

  - `replaceDocument()` funnels every buffer-replacing site in App.svelte,
    flushing while the path is still current so the edits land where they
    belong. User-initiated navigation aborts when a held disk conflict makes
    the write unflushable, leaving the person on the file whose edits are at
    stake with both resolutions already on screen. The daemon's own
    setContent proceeds — it is authoritative about what is displayed.
  - An identity interlock in NativeAutosave refuses any write whose document
    changed since the burst began. Only openPathNow was guarded before, which
    is the point: "changes the open document" is not a property the type
    system can see, so the backstop has to be structural.

P2 — the browser dev loop reported saves it discarded. Locally picked files
stayed editable and sent `edit_save`, but the mock IPC handled only `navigate`,
so the store kept the original immutable File. The chip said saved; switching
tabs or sharing re-read the original bytes and the edits were gone with no
error anywhere. The store now owns the active-path mirror (it is the module
that delivers content, so it is the one that knows) and applies writes,
advancing lastModified so the app does not read its own save back as someone
else's concurrent write.

Every new assertion was negative-controlled: each fails with the fix removed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@angusbezzina
angusbezzina merged commit 2666108 into main Aug 10, 2026
5 of 6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant