Skip to content

Document workspace design pass: themes, verdict undo, sandboxed reports, type scale - #7

Merged
cailmdaley merged 112 commits into
mainfrom
design/document-workspace
Oct 5, 2026
Merged

cailmdaley merged 112 commits into
mainfrom
design/document-workspace

Conversation

@cailmdaley

@cailmdaley cailmdaley commented Oct 5, 2026 •

Copy link
Copy Markdown
Owner

This is the visual and interaction design pass on the document workspace from #6, together with the security hardening that the pre-merge review required. The UI and daemon ship together: the daemon now adds the sandbox CSP and nosniff headers and the /file-assets route that sandboxed reports load through.

Opus 5.5 on behalf of Cail.

Fiber page in the reader

What changes for a person using the board

  • Reader. The Desk recedes behind a desaturated veil with slight parallax, and the chrome floats on plates. The filmstrip tabs are thumbnails in recency order, with declared titles and fresh marks. A sidebar lists the cards in the open card's Desk column (c).
  • The fiber page leads with the outcome, and its controls follow the Desk column: Temper/Discard while a card awaits review, the composer's verb, history and settings.
  • Verdicts. Every verdict goes through one delayed commit with a 6 s undo toast: the worker plate, the fiber page's buttons, t/x, and z to undo. A new verdict on a fiber replaces its pending one. A pending verdict binds to the fiber's uid and owner, and is re-checked against the current card when it commits.
  • Overview (Board tab). It opens with what changed since you were here, capped at eight rows with needs you first, then lays out folios in graduated density, grouped by recent work, project or host.
  • Per-constitution themes. Four themes are bundled (Portolan, Blueprint, Laboratory Paper, Night Chart), or a fiber can carry a theme.css beside it. Themes are CSSOM-scoped to their channel and stop at the act zone. A Plain item in the page menu drops a theme. See docs/concepts/themes.md.
  • Reports run in an opaque-origin sandbox over a versioned bridge. Filenames in a report link to their pages, and audio plays inline.
  • Audio page. It has a waveform and A/B compare. [ and ] seek back and forward 5 s, and p plays or pauses.
  • Type. A named scale: IBM Plex Mono for data, EB Garamond for names and verbs (11 / 15 / 18–21 / 24 / 34), defined as tokens in tokens.css.
  • Phone. The reader runs edge to edge, with one bottom bar and a page sheet. Checked in the iOS Simulator's Mobile Safari.
Overview Sidebar open Audio page
Filmstrip on a report Phone: fiber page Phone: Mobile Safari

The screenshots come from the fixture harness (npm run harness:board). Live-data shots for every lane, themes included, are in the felt constitution ai-futures/felt/constitutions/document-workspace (shots-6/).

Security

The pre-merge review's two must-fixes, each with a regression test that fails before its fix:

  1. A report can't act on its own constitution. The parent re-dispatches only an allowlist of navigation intents from a frame: stepping, scrolling, back, sidebar/find and help. The allowlist sits beside the intents in keymap.ts, so a new intent is excluded by default. Verdicts, undo, compose, conversation, audio transport and expand are refused. The bridge also forwards only trusted key events.
  2. Every API response is sandboxed. Content-Security-Policy: sandbox … and X-Content-Type-Options: nosniff are applied to every response whose percent-decoded path starts with /api (and to any path that doesn't decode), whatever the extension. That covers .xml, .svg, unknown types, error bodies, 206/304 responses, and responses relayed from an owner host: the hub sets the headers and doesn't trust the owner's. Matching decoded segments matters because the router decodes them too: /api/v1/fil%65?path=… reaches the file controller. The board page itself is unaffected.

Fix-soons, also landed:

  • The one-verdict path described above. It closes a theme painting a fake label over Discard, and the case where x followed by Temper wrote twice.
  • Theme @font-face families are renamed per channel, and theme url() may reference only the fiber's folder, data: or Google Fonts.
  • The overview no longer fetches theme.css per fiber. Only the open reader loads custom CSS.
  • Bridge messages are size-checked, and references messages are capped and rate-limited per frame.

Known limitation. A report at an opaque origin can't fetch() sibling data files. A sample of real reports turned up none that do. The proposed remedy is a bounded parent-bridge fetch, limited to the fiber's own folder and owner, rather than Access-Control-Allow-Origin: null.

Verification

Gates on this head, observed:

  • npm test: 1,497 passed under each pinned time zone, on Node 25 locally and Node 22 (CI's runtime).
  • npm run typecheck: clean.
  • npm run e2e: 81/81; the reference-limit probe passes 6 of 6 runs in isolation.
  • mix test test/shuttle_web: 558/558, including 97 file-controller tests.
  • make build SKIP_CLI=1: passes.

An independent Opus review (high effort) of the merged hardening and finish diffs confirmed that must-fix #1 is closed, with no other parent-side route around the allowlist. It also confirmed the verdict path, the theme confinement and the merge resolution. Its findings, and what happened to each:

  1. Blocker: percent-encoded route spellings skipped the CSP and nosniff. This was a regression from the base, where the controller sandboxed by the path parameter. Fixed (007f0792) by the decoded, default-deny rule above, with an encoded-route test that failed before the fix.
  2. The references throttle used the wall clock. A backward jump in Date.now(), from NTP or a report stubbing Date, stalled rescans for hours. This was why the "ambiguous basenames" e2e test was flaky. Fixed (aaff0937): both sides now use performance.now(), the wait is clamped, and the parent defers the newest over-budget batch rather than dropping it.
  3. The live-worker confirm fired 6 s after the gesture, which hit every verdict on a Codex-app card. Fixed (d6ed27bd): it now asks at queue time, and the commit is refused with a toast if a worker started during the undo window.
  4. A stored Plain choice on a channel with no declared theme couldn't be cleared. Fixed (b7930db3).
  5. [ and ] need Option on AZERTY and QWERTZ layouts. Accepted for now; p still plays and pauses.

CI's UI job also caught that 8 workspace integration tests failed under Node 22. The cause was too short a microtask flush in the test harness, present since the earlier design lanes, and it's fixed (5d33379a).

Follow-ups

  • The worker plate has no "last step": the daemon doesn't yet serve last_step or turn_active.
  • A sibling-data bridge for reports, as proposed above.
  • Safari under the CSP sandbox is unverified for native PDF viewing. Chrome was checked.
  • A deeper pass on a real iPhone, for media and Back.

🤖 Generated with Claude Code

https://claude.ai/code/session_01LwX9WpuD7VnV6mfmN9eb67

cailmdaley and others added 30 commits October 5, 2026 06:36
Co-Authored-By: GPT-6.1 Sol <noreply@openai.com>
Co-Authored-By: GPT-6.1 Sol <noreply@openai.com>
Co-Authored-By: GPT-6.1 Sol <noreply@openai.com>
Co-Authored-By: GPT-6.1 Sol <noreply@openai.com>
Co-Authored-By: GPT-6.1 Sol <noreply@openai.com>
Co-Authored-By: GPT-6.1 Sol <noreply@openai.com>
Co-Authored-By: GPT-6.1 Sol <noreply@openai.com>
…on budget

Co-Authored-By: GPT-6.1 Sol <noreply@openai.com>
Co-Authored-By: GPT-6.1 Sol <noreply@openai.com>
Co-Authored-By: GPT-6.1 Sol <noreply@openai.com>
Co-Authored-By: GPT-6.1 Sol <noreply@openai.com>
Co-Authored-By: GPT-6.1 Sol <noreply@openai.com>
Co-Authored-By: GPT-6.1 Sol <noreply@openai.com>
Co-Authored-By: GPT-6.1 Sol <noreply@openai.com>
… asset bases

Co-Authored-By: GPT-6.1 Sol <noreply@openai.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…, declared titles

Ribbon thumbnails keep their caption as the only name (captioned faces).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Preserve column bands, animate card transfers with FLIP, and return Desk focus to the current constitution. Fade neighbour margins, desaturate the receded Desk, and keep the phone veil opaque.

Co-Authored-By: GPT-6.1 Sol <noreply@openai.com>
…Desk-card sidebar

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: GPT-6.1 Sol <noreply@openai.com>
Co-Authored-By: GPT-6.1 Sol <noreply@openai.com>
Co-Authored-By: GPT-6.1 Sol <noreply@openai.com>
Co-Authored-By: GPT-6.1 Sol <noreply@openai.com>
Co-Authored-By: GPT-6.1 Sol <noreply@openai.com>
…arial messages

Co-Authored-By: GPT-6.1 Sol <noreply@openai.com>
Reserve dot for conversation everywhere; audio forward uses Shift-dot. Pending verdicts survive navigation but not tab closure.

Co-Authored-By: GPT-6.1 Sol <noreply@openai.com>
Track prior overview visits and per-fiber outcome/review changes, prioritize needed attention, and give older work compact lookup layouts.

Co-Authored-By: GPT-6.1 Sol <noreply@openai.com>
Exercise first visits, reloads, inline lenses, declared-title Find, folded latest files, per-fiber acknowledgement and phone visibility in system Chrome.

Co-Authored-By: GPT-6.1 Sol <noreply@openai.com>
The dot follows the served working phase; no last-step or exact foreground-turn signal is served by the daemon.

Co-Authored-By: GPT-6.1 Sol <noreply@openai.com>
Co-Authored-By: GPT-6.1 Sol <noreply@openai.com>
cailmdaley and others added 28 commits October 5, 2026 10:51
Co-Authored-By: GPT-6.1 Sol <noreply@openai.com>
Co-Authored-By: GPT-6.1 Sol <noreply@openai.com>
Co-Authored-By: GPT-6.1 Sol <noreply@openai.com>
Co-Authored-By: GPT-6.1 Sol <noreply@openai.com>
Ran new schema/size and rate regressions against unfixed code: 2 failed (501 candidates accepted, 20 callbacks rather than 4). After fix: bridge/viewer/host tests 51 passed; targeted reference/navigation Chrome e2e 5 passed plus rate/size probe passed; typecheck passed. All message types have closed bounded schemas. References allow 500 strings of 256 characters and four batches per sliding second per frame. The injected scanner coalesces ordinary rescans to keep the latest layout reachable without bypassing the parent rate guard.

Co-Authored-By: GPT-6.1 Sol <noreply@openai.com>
Apply the sandbox CSP and nosniff to /file and /file-assets independently of extension, MIME type, owner, and status.
Wrap Phoenix's endpoint error boundary so malformed query/JSON errors, early gate refusals, HEAD, ranges, and cache responses retain the policy.
Remove the extension denylist; add no exemptions.
The hub applies its own policy rather than trusting remote headers.

Ran the regressions against unchanged production code before the fix (git diff --exit-code confirmed the controller and endpoint matched HEAD):
  cd daemon && unbuffer mix test test/shuttle_web/controllers/file_controller_test.exs test/shuttle_web/controllers/file_relay_test.exs test/shuttle/origin_router_test.exs test/shuttle_web/relay_helpers_test.exs --seed 0
Observed 107 tests, 53 failures: XML/RSS/Atom/unknown/PDF/media and relayed/error responses lacked CSP; HTML/SVG lacked nosniff.
Real httpc relay, malformed JSON/query errors, CORS failures, and booting responses also failed the header assertions.
The same command passes after the fix: 107 tests, 0 failures.

Additional file-invalid-range/readiness/CORS/peer/large-body-parser suites: 56 tests, 0 failures (1 excluded).
mix format --check-formatted and git diff --check pass.
A throwaway Bandit endpoint with Chrome 154.0.8037.93 renders the native PDF viewer and plays MP3/MP4 with both headers; screenshot inspected.
Safari 26.5.2 WebDriver could not start because Allow remote automation is disabled, so Safari compatibility is unverified.
Full daemon/UI suites not run; no UI changes, CLI installation, release build, live daemon restart, or push.

Co-Authored-By: GPT-6.1 Sol <noreply@openai.com>
Co-Authored-By: GPT-6.1 Sol <noreply@openai.com>
Co-Authored-By: GPT-6.1 Sol <noreply@openai.com>
Co-Authored-By: GPT-6.1 Sol <noreply@openai.com>
Co-Authored-By: GPT-6.1 Sol <noreply@openai.com>
Co-Authored-By: GPT-6.1 Sol <noreply@openai.com>
Co-Authored-By: GPT-6.1 Sol <noreply@openai.com>
Co-Authored-By: GPT-6.1 Sol <noreply@openai.com>
Co-Authored-By: GPT-6.1 Sol <noreply@openai.com>
Full Chrome run exposed a rate-fixture race: a normal decoration rescan consumed one of the four slots, so the hostile sender saw three replies. Remove reference nodes and let scanner traffic settle before flooding. Production limits are unchanged. The targeted Chrome regression passes twice after fixture isolation.

Co-Authored-By: GPT-6.1 Sol <noreply@openai.com>
Adversarial Chrome regression failed before fix: escaped fonts.googleapis.com hostname requested fonts.googleapis.com.evil.test and loaded unscoped CSS. Decode CSS escapes, validate the resulting HTTPS Google Fonts URL including port/credentials, and serialize that canonical URL while retaining import modifiers. Browser regression plus font/URL/scoping tests: 4 passed after fix; typecheck and diff check passed. Updated existing Google import assertion for canonical quoting.

Co-Authored-By: GPT-6.1 Sol <noreply@openai.com>
Workspace integration regression run (America/Los_Angeles): red 6 failed, 28 passed (34 total); green 34 passed, 0 failed. Indexed identities that disappear cannot authorize a path write. Off-index linked fibers require a fresh owner-routed UID check.

Co-Authored-By: GPT-6.1 Sol <noreply@openai.com>
KanbanModal verdict regression run (America/Los_Angeles): red 3 failed, 3 passed (6 total); green 6 passed, 0 failed. No-op verdicts cannot become delayed writes after reopening. Gesture basis, normal queuing, and commit-time worker confirmation remain covered.

Co-Authored-By: GPT-6.1 Sol <noreply@openai.com>
Use the named title size for serif navigation glyphs so phone chevrons remain legible; text labels keep the UI size.

Co-Authored-By: GPT-6.1 Sol <noreply@openai.com>
Co-Authored-By: GPT-6.1 Sol <noreply@openai.com>
… nosniff on every file response, one verdict path, theme font/url confinement, bounded bridge messages

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… eleven fixes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

# Conflicts:
#	ui/src/board/workspace/ChannelThemes.test.ts
#	ui/src/board/workspace/ChannelThemes.ts
… drop, excess batches

The frame and parent reference limiters read Date.now, so a backward wall-clock
step (NTP, a report stubbing Date, Playwright's page.clock) deferred rescans by
hours and pinned parent budget entries in the future. Both sides now use
performance.now; the frame clamps its wait to [0, interval] and the parent
expires future-dated entries. The parent keeps the newest batch over budget and
delivers it when the window reopens, since the frame never resends.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LwX9WpuD7VnV6mfmN9eb67
The security plug matched raw path segments while the router decodes them,
so /api/v1/fil%65 reached FileController without the CSP sandbox or nosniff.
Match the percent-decoded first segment and default-deny: every /api response
(and any undecodable path) carries both headers; the board page does not.
This also covers /transcript/raw's relayed bytes. The new encoded-route test
failed before this change (97 tests, 1 failure).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…o expiry

Verdicts were queued for the 6 s undo window before the hasWorkerToStop
confirm, so the modal fired six seconds after the click, drag, menu or plate,
and every verdict on an app card with a durable thread produced one.
Workspace.queueVerdict now asks at queue time (confirmWorkerStop, shared with
the Dock's unqueued path and the board's no-workspace path) and carries the
answer to the delayed write; an expired verdict whose card gained an
unconfirmed worker is refused with a toast, like the awaiting-review recheck.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LwX9WpuD7VnV6mfmN9eb67
Under Node 22 (CI's runtime) undici's fetch/Response takes more microtask
hops than Node 25, so a 25-tick flush returned before the reader mounted and
8 integration tests read an absent frame. Pre-existing since the design lanes
merged at 43bee76; reproduced with node@22 and fixed by draining 200 ticks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The parent now defers an over-budget batch until its window reopens, so the
report scanner's last batch can occupy a slot past the old 1.1 s settle and
the probe saw 3 responses instead of 4 (1 run in 3). Settle 2.1 s; 6/6.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@cailmdaley
cailmdaley marked this pull request as ready for review October 5, 2026 09:39
@cailmdaley
cailmdaley merged commit 90e0b9c into main Oct 5, 2026
4 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