feat(client): define the Figma-style Electron workspace - #424
feat(client): define the Figma-style Electron workspace#424stuffbucket wants to merge 3 commits into
Conversation
Move the canonical theme and self-hosted fonts out of the frozen Tauri tree, generate one token system into both legacy shell and Electron renderer outputs, and enforce every generated/raw-value mirror in the design checks. Land the committed warm-bronze interaction retone while preserving crimson for identity and cool slate for links, with theme-correct status foregrounds and AA-safe site text roles. Add semantic workspace-shell aliases and structural tokens without importing the reference app palette. Refresh stale design docs and make reduced-motion behavior literal in standalone windows.
Consume the reusable workspace frame from maximal-electron and replace copied source with an immutable package dependency. Add a development-only interactive concept based on the agent-detail still: custom titlebar/session tabs, project and status rail, list-first run canvas, contextual inspector with approval gate, quiet status bar, and an in-window assistant overlay. The fixture is explicitly labeled and gated behind DEV + ?preview=workspace; packaged production continues to boot the live core/auth renderer. Use Maximal's canonical generated tokens and self-hosted fonts throughout, with bronze for interaction and crimson reserved for the identity mark. Expand the host window to the spatial workspace geometry and custom titlebar chrome.
Remove the redundant identity badge from the custom titlebar and set the real Maximal icon on app.dock after Electron is ready. Packaged builds continue to use the signed ICNS through Forge; the runtime PNG override is macOS development only so electron-forge start no longer shows Electron's default icon. Fail startup visibly when the development icon is missing or invalid, and keep Windows/Linux behavior unchanged.
…t carry (#114) Closes #109. The slot before a tab's label held a status dot or nothing. It now holds one glyph, and the tab itself can carry an emphasis the dot is the wrong size for. ## Read this first: two consumer-visible costs Both are the price of doing the accessibility properly. Both should be read here rather than discovered by a consumer. **1. A tab carrying a status is now named differently.** A tab with `status: 'running'` used to have the accessible name `Deploy`. It now has `Deploy Running`. The dot was the entire message and a screen reader got none of it, which is a WCAG 1.4.1 failure the strip has always had. The four statuses the stylesheet colours now contribute words automatically, overridable per tab with `statusLabel`. **A consumer with a test that matches a tab by name will need to update it** — `getByRole('tab', { name: 'Deploy' })` stops matching. It is a break, not a refinement. **2. `./renderer` gains one optional peer: `@radix-ui/react-visually-hidden`.** That is what hides the words above. `Overlays.tsx` already imported it on `release/0.0.4`, but `Overlays` is not reachable from `./renderer`, so #107's dependency contract had never seen it. `TabBar` is exported, so this change pulls the package onto the public surface and CI caught it: ``` FAIL ./renderer imports @radix-ui/react-visually-hidden, a declared peer ``` Declared rather than dodged. Writing the clip-rect recipe by hand would avoid the peer, but it would put the hiding in `structural.css` instead of inline styles, so a consumer who has not imported the stylesheet would see the words rendered visibly beside every status. `README.md`'s peer table is updated — by hand, because nothing checks it (see *Not verified*). Nothing else changes an existing call. `Tab.icon`, `Tab.emphasis` and `Tab.statusLabel` are all optional; a tab strip that sets none of them renders exactly as before. ## The parked work `origin/feat/tab-adornments` `d182810` is **discarded, not cherry-picked**. It had the right two ideas — an indicator slot and a `data-emphasis` attribute — and put both in the wrong place. - Its `TabIndicator` decided precedence in JSX, so the rule "what wins the one slot" lived where `npm run mutate` cannot reach it. That rule is the whole design question, and it now lives in `src/renderer/lib/tab-adornment.ts`, which imports neither `electron` nor React and is on the mutate list. - Its slot took a `ReactNode` from a callback. A node cannot be a tab's data — it does not survive a session store or the IPC boundary a consumer has — and a slot that accepts arbitrary nodes has no contract to check. The consumer seam that already existed, the `icon` callback returning a component, does the same job with a shape. - It still rendered `Icon` **and** the dot side by side, so a tab with both was two glyphs wide and the strip lost its rhythm. That is the complaint this work is answering, left in place. - Its `emphasis` had three values including `muted`, which at rest is `--text-muted` — the colour an unselected tab already is. It could not have been drawn. - `statusLabel` was opt-in "because it changes the accessible name". That ships a known WCAG 1.4.1 failure by default. Kept as an override; the four statuses the stylesheet colours now get words automatically. Everything it introduced by name survives in some form. None of its code does. ## How a consumer supplies an icon Two routes, and the split is between data and code. **A registered name.** `Tab.icon` takes one of `TAB_ICON_NAMES` (`document`, `folder`, `settings`, `terminal`), and `TabBar` maps it to a `lucide-react` glyph. A name is a string, so a tab survives `JSON.stringify`, a session store, and an IPC hop. That matters for stuffbucket/maximal#424, where tabs come from a session store rather than being built in render. **A component.** `TabBar`'s existing `icon` callback returns a `ComponentType`, unchanged, and wins the slot outright. No file read, no bundler plugin, no registration step, and the glyph inherits `currentColor` and sizes at 13px like ours. Rejected: - **A data URL.** It cannot inherit `currentColor`, so it would be the one thing in the strip that does not follow the theme. It also puts consumer-controlled bytes into a `url()`, and this repository's rule about never taking a path from the renderer exists for the same family of reason. - **A filesystem path.** Barred outright. `AGENTS.md`: never let a channel take a filesystem path from the renderer. - **Only a name.** A registry the shell owns cannot cover a consumer's product icons, and growing it on request makes this repository a clip-art library. - **Only a component.** Then a tab cannot be serialised, which is exactly what the nearest real consumer needs. ## What "a different effect" means, and how many **Two.** Not three, and the third one the parked branch had could not have been drawn. | Emphasis | Treatment | The state it serves | | --- | --- | --- | | `attention` | A 3px rule down the tab's leading edge, full height, `--warning` | The tab is waiting on a human. #424's inline approval gate, on a session the user is not looking at. | | `busy` | A short `--accent` bar travelling along the bottom, inset and lifted clear of the edge | Work is in flight. #424's streaming assistant, a running command. | That is the whole partition a tab strip needs beyond identity: *something is happening here* and *something needs you here*. The third state — nothing is happening here any more — is the absence of both, and is what the strip already looks like. `muted` is dropped for the reason above. An "unread" or "modified" state was considered and left out: that is what a status dot is for, and the four statuses already carry it. **Neither depends on hue.** Geometry is the carrier. `attention` is a full height vertical rule, which no other tab state draws; `busy` is a short horizontal bar lifted off the bottom edge, which no other tab state draws. Under `prefers-reduced-motion` the busy bar parks at its resting position rather than mid-travel, because the keyframes name the same position at `0%` and `100%` and the reduced-motion override collapses the duration rather than removing the animation. ## What happens when a tab has both an icon and a status The slot is one place, so `tabSlot` resolves it: a caller-supplied component, then the status dot, then the sourced icon, then nothing. Status beats the icon because what a tab *is* stays true whether or not the slot says it, and what it is *doing* does not. Emphasis is not in that contest. It is drawn on the tab rather than in it, so a tab can show a status dot and a travelling bar at once — the third tab in the `WithEmphasis` story does. The accessible name carries every signal, not just the one that won the slot. `adornmentLabel` composes them, and that tab reads `build docs Running, Working`. ## WCAG 1.4.1 A dot, a vertical rule and a travelling bar are colour, shape and motion. None of the three reaches a screen reader. Every one of them now contributes words through a `VisuallyHidden` span after the label: - the four statuses the stylesheet colours (`running`, `blocked`, `done`, `failed`) get words automatically, overridable per tab with `statusLabel` - a status the stylesheet does not colour draws the default grey dot, so colour carries nothing and there is nothing to replace - each emphasis gets words from `EMPHASIS_LABELS` This is a **behaviour change for an existing consumer**: a tab with `status: 'running'` used to be named `Deploy` and is now named `Deploy Running`. A name-based test against such a tab will need updating. Shipping a known 1.4.1 failure by default was the worse option. Called out at the top of this description as well, because it is the one thing here a consumer can be broken by. ## Contrast Three pairs added to `CONTRAST_PAIRS` under a new `AA_NON_TEXT = 3` (WCAG 2.2 SC 1.4.11 — a shape that carries meaning and holds no text): - `--accent` on `--tab-active` — the selected tab's accent, and a busy marker on the selected tab - `--warning` on `--bg-app` — the attention marker - `--warning` on `--tab-active` — the attention marker on the selected tab **Issue #65 applies to all three, and I checked them by hand.** Each marker sets its colour in one rule and sits on a surface set by another, so `tests/contrast-coverage.test.ts` — which only reads a pair a single block states outright — cannot see any of them. Measured values: | Pair | Dark | Light | | --- | --- | --- | | `--accent` on `--tab-active` | 4.51 | 3.68 | | `--warning` on `--bg-app` | 8.02 | 4.45 | | `--warning` on `--tab-active` | 4.92 | 3.17 | Note `--warning` on `--bg-app` is **4.45 in light**, under the 4.5 text threshold. It is a 3px graphical object and 1.4.11 asks 3, which it clears comfortably — but if anyone later draws warning *text* on the title bar, that pair needs a second entry at `AA_NORMAL` and the palette needs a nudge. The first of the three is a **pre-existing hole this fixes incidentally**: the selected tab has been drawing `--accent` on `--tab-active` since the accent underline landed, and no pair named it. Also note `--accent` on `--bg-app` was already listed at `AA_NORMAL`; its `where` gained the busy marker rather than getting a duplicate entry, because `CONTRAST_PAIRS` refuses a pair twice. ## The package seam - `.tab__emphasis` is styled in `structural.css` as well as `shell.css`, which `tests/package-styles.test.ts` requires of any class an exported component renders. - `.sb-shell .tab` gains `position: relative`. It had none; the marker is absolute inside it. - `--shell-warning` is read with a fallback to `--shell-accent`, so a consumer who names no warning colour gets the shape without the hue rather than an invisible marker. Documented in the Fallback table of `docs/shell-variables.md`, which #104's tripwire requires. - `@keyframes sb-tab-busy` is namespaced, because a keyframe name is global and this stylesheet is imported into a consumer's application. **It is the first at-rule in `structural.css` that is not `@media`.** My earlier report warned that #51's replacement guard might trip on the `0%` / `50%` frames. **It does not, and no exemption is needed.** `scripts/css-selectors.mjs` classifies `@keyframes` as a declaration at-rule (it is absent from `NESTS_RULES`), so its block is entered as declarations and the offsets inside it are never collected. Run over the shipped stylesheet: ``` selectors parsed: 90 unscoped: [] any keyframe offset reported: [] sb-tab-busy in the list: false emphasis selectors: [".sb-shell .tab__emphasis", ".sb-shell .tab[data-emphasis='attention'] .tab__emphasis", ".sb-shell .tab[data-emphasis='busy'] .tab__emphasis", ".sb-shell .tab__emphasis"] ``` 90 selectors parsed and 0 unscoped, with all four emphasis selectors in the list — so the guard is judging the new rules, not skipping the file. The parser's existing test covered `from` / `to`; `structural.css` ships the comma-separated percentage form, so `tests/css-selectors.test.ts` gains that case. Confirmed rather than assumed, and pinned rather than left as a landmine for whoever adds the second animation. - `lib/tab-adornment` is added to `CONTRACTS` in `scripts/export-checks.mjs`, the allowlist for a `lib/` module a consumer may reach. It qualifies: types and pure functions, no imports of its own. ## The merge with #115 `origin/release/0.0.4` was merged in at `f9fffaf`. One conflict, and it was where the coordinator expected: `scripts/export-checks.mjs`, not a component. #115 changed `TerminalView` and `TerminalTabs` and added a `disposition` prop and a `DetachableTerminalTransport`; it did not touch `TabBar`, and `structural.css` auto-merged. Both sides of the conflict were additive, and both were taken: `RENDERER_SURFACE` gains `detachedSessions` beside `adornmentLabel`, and `CONTRACTS` gains `lib/terminal-sessions` beside `lib/tab-adornment`. The export surface check agrees with both. `verify:exports` now walks 12 renderer modules rather than 11, and names each: ``` ok renderer JavaScript exposes only the approved component surface ok dist/renderer/lib/terminal-sessions.js is generic ok dist/renderer/lib/tab-adornment.js is generic ok the import graph reaches past the entry ``` ## Deliberate failures Four, because "right logic, empty scope" is this repository's recurring false pass. **1. `check:contrast` sees the new pairs.** Set the light `--warning` to `#f0e0c0`. The run exited 1 and named both new pairs by their `where`: ``` :root[data-theme='light'] — 21 pairs checked, 0 skipped, 0 tokens missing contrast — legible tokens that do not contrast enough --warning on --bg-app 1.30 < 3 — attention tab marker --warning on --tab-active 1.08 < 3 — attention marker on the selected tab ``` Restored, and the pair count went 18 → 21, so the three are being measured rather than skipped. **2. axe sees the new stories.** Added `tabIndex={0}` beside the marker's `aria-hidden="true"`. `npm run storybook:check tabbar` went from `0 accessibility violations` to `6`, and flagged exactly the two new stories: ``` a11y layout-tabbar--with-emphasis a11y layout-tabbar--emphasis-selected ``` Reverted. This is the proof asked for: axe is evaluating the emphasis markup, not walking past it. **3. The stylesheet coverage check sees a missing emphasis.** Renamed `[data-emphasis='busy']` to `[data-emphasis='BUSTED']` in `structural.css`. `tests/tab-adornment.test.ts` failed with `structural.css draws busy`. Restored. **4. The package stylesheet guard sees the new class.** Removed `tab__emphasis` from `structural.css`. `tests/package-styles.test.ts` failed with `[ 'components/TabBar: tab__emphasis' ] to deeply equal []`. Restored. **A weakness found while doing 4, and not fixed here.** Renaming only the base `.sb-shell .tab__emphasis { }` rule, while the two `[data-emphasis]` descendant rules still mentioned the class, did **not** fail. `styledClasses` matches the class name anywhere in the file, so it cannot tell "styled" from "mentioned", and a partially-removed rule set passes. Same family as #51 and #65 — a check whose scope is narrower than its claim. Filed as #118, deliberately not fixed here. ## Verified Every number below is from a run **after** merging `origin/release/0.0.4` at `f9fffaf`, which brought in #95, #106, #110 and #115. | Command | Result | | --- | --- | | `npm run lint` | clean | | `npx tsc --noEmit` | clean, exit 0 | | `npm test` | **37 files, 520 tests**, all passed | | `npm run mutate` | **100.00**, `tab-adornment.ts` 45/45 killed | | `npm run check:contrast` | 21 pairs checked, 0 skipped, 0 missing, both schemes ok | | `npm run verify:package` | all packaging checks passed | | `npm run verify:exports` | all passed, **12 renderer modules** inspected | | `npm run verify:neutral` | neutral — 91 sources parsed, 100 scanned, 18 shipped | | `npm run verify:docs` | 18 documents, all documented names exist | | `npm run storybook:check` | **77 stories**, all rendered and played, 1 a11y violation | Re-run in full after a second merge, which brought in #107, #108 and #111: lint clean, `tsc` exit 0, **38 files / 536 tests**, mutation **100.00** (`tab-adornment.ts` 45/45), contrast 21 pairs / 0 skipped, neutral (93 sources parsed, 102 scanned, 18 shipped), 18 documents, packaging checks passed, 77 stories with the same single pre-existing violation. The whole Storybook suite was run this time, not only the tab strip. The single remaining violation is `controls-dialog--menu-keyboard` (`aria-hidden-focus x1`), which is pre-existing and in a component this branch does not touch — the diff is 13 files and none of them is a dialog. **Every one of the eleven `layout-tabbar--*` stories is `ok` with no violation**, so the tab strip has not regressed from its 1. **`npm run test:e2e` fails, and the failures are not mine.** Three specs abort with `No shell window appeared within 30 seconds`, and `19 did not run`: ``` 3 failed e2e/concierge.spec.ts:38:5 e2e/shell.spec.ts:585:7 e2e/terminal-window.spec.ts:32:5 ``` I reproduced the identical failure on `origin/release/0.0.4` with my branch checked out at a detached base and freshly packaged — same three specs, same line numbers, same message. It is not the tab strip. The reported *test names* differ between runs (Playwright attributes a worker crash to whichever test was in flight), which is why the three names above are not the same three in every log. ## Not verified - **The Windows and Linux rendering.** The 3px leading rule and the travelling bar were looked at on macOS only, in Storybook at 760px. - **`prefers-reduced-motion` as a user actually experiences it.** The parked frame is asserted from the keyframes text, not from a browser with the setting on. The reasoning is that `animation-duration: 0.01ms !important` leaves the element at whichever position `100%` names, and `0%` and `100%` are the same position — but no run confirms it. - **A real consumer importing the built `structural.css`.** `verify:exports` checks the artifact matches the source and every selector is scoped; nothing here rendered the emphasis inside a consumer's application with their own `--shell-*` values. - **The 19 e2e specs that did not run**, for the reason above. - **`npm run stills` / `npm run record`.** Not run; no capture fixture uses an emphasis. - **Hover and focus over an emphasised tab.** No story can freeze a CSS pseudo-class without a pseudo-states addon, which this repository does not have. The `States` story's convention — a strip to point at — was not extended to emphasis. - **That `README.md`'s peer table is right.** I updated the row by hand. `README.md` claims the table "cannot drift from what the code imports without failing a check", and that claim is not true: `verify:exports` reads the peers out of the manifest and the import graph, and nothing reads the table. A row left stale would pass. Out of scope here, but somebody should either check the table or stop claiming it is checked.
|
Filed #437 with the concrete recipe for this: the slot map for One correction to a shared assumption: the |
|
Closing as superseded by #440. This PR is based on the pre-MVP |
What
Defines and implements the first visible Maximal Electron workspace direction, stacked on #423.
This is intentionally based on maximal-electron's Figma/agent-fleet concept (
demo/stills/02-agent-detail.png), not the retired Tauri Settings/Dashboard composition.Foundation
ui/theme.ts--shell-*adapter without importing maximal-electron's paletteSpatial preview
The concept data is explicitly labeled and gated behind
import.meta.env.DEV && ?preview=workspace; packaged production cannot select it and still boots the live core/auth renderer.Launch locally:
cd client MAXIMAL_RENDERER_PREVIEW=workspace npm startDependency
Depends on stuffbucket/maximal-electron#35 / immutable commit
578870ac213d226a91bf6b428e26ebbab74b1c35, consumed through its built package exports—not source paths.Verification
check:fast(lint, typecheck, design checks)The rendered preview matches the intended three-panel composition: dominant list canvas, quiet rail, contextual inspector, and approval state. Live core-store wiring and Storybook/E2E state matrices follow in separate commits.