Skip to content

feat(client): define the Figma-style Electron workspace - #424

Closed
stuffbucket wants to merge 3 commits into
feat/macos-dmg-electronfrom
feat/electron-spatial-ui
Closed

feat(client): define the Figma-style Electron workspace#424
stuffbucket wants to merge 3 commits into
feat/macos-dmg-electronfrom
feat/electron-spatial-ui

Conversation

@stuffbucket

Copy link
Copy Markdown
Owner

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

  • promotes Maximal's canonical theme to neutral ui/theme.ts
  • generates identical token CSS for the legacy shell and Electron renderer
  • moves self-hosted Fraunces/Commissioner fonts to neutral ownership
  • lands the warm-bronze interaction retone while reserving crimson for identity
  • machine-checks generated CSS and standalone/site mirrors
  • adds the semantic --shell-* adapter without importing maximal-electron's palette

Spatial preview

  • custom titlebar with session tabs and native traffic-light clearance
  • persistent project/status rail
  • list-first center canvas
  • selection-driven right inspector
  • inline human approval gate
  • quiet status bar
  • in-window assistant overlay concept with approval/streaming states
  • full-size 1280×820 workspace with 880×560 minimum

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 start

Dependency

Depends on stuffbucket/maximal-electron#35 / immutable commit 578870ac213d226a91bf6b428e26ebbab74b1c35, consumed through its built package exports—not source paths.

Verification

  • client typecheck
  • Electron Forge package (arm64)
  • root check:fast (lint, typecheck, design checks)
  • dual token freshness
  • self-hosted font packaging
  • no raw hex/px/rem in preview TSX/CSS
  • real Electron render captured at 1280×820

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.

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.
stuffbucket added a commit to stuffbucket/maximal-electron that referenced this pull request Aug 6, 2026
…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.
@stuffbucket

Copy link
Copy Markdown
Owner Author

Filed #437 with the concrete recipe for this: the slot map for ShellLayout, the eleven --shell-* variables your ui/theme.ts needs to define, and a CI-runnable check that fails when one is missing.

One correction to a shared assumption: the demo/stills screenshot is a Playwright capture fixture (e2e/fixtures/demo-shell/, nine files, never published), not an importable component. The shell is a frame with named slots and that fixture is a worked example of filling them — which is good news, because it means your own inspector and canvas contents stay yours.

@stuffbucket

Copy link
Copy Markdown
Owner Author

Closing as superseded by #440. This PR is based on the pre-MVP feat/macos-dmg-electron line; applying its current delta would remove the main-owned typed control bridge, first-run and dashboard implementations, packaged E2E/unit coverage, HTTP(S)-only external URL validation, and would downgrade maximal-core from v0.6.3 to v0.2.0. The theme, font, token, and spatial-workspace concepts remain useful, but they must be integrated afresh from current main without replacing those shipped boundaries. #437 tracks that current workspace-composition follow-up.

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