From 552db56476dd29d773247d0d76a0cf97e870d973 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Sat, 10 Oct 2026 01:22:20 +0200 Subject: [PATCH 1/5] docs(core): spec and plan for indicator styles and Prompt tabs (#143) The owner's decisions of 2026-10-10 (mockups approved, "go ahead and build"): Classic and Prompt tab styles, Off / Minimal / Ring / Full marks, the flowing rainbow finish, the question breathe, the Full name flip rule. Records the core/app split: the marks' rules, motion and rows in the core behind an optional TermHostConfig.tabMarks so Prism is unchanged until it adopts them; the tab style and Prompt geometry in this app. Bumps core to 0.28.0 and the app to 0.35.0. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FHHaWKR4M5QtW7Wecyuk4t --- core/package.json | 2 +- .../plans/2026-10-10-indicator-styles.md | 212 ++++++++++++++ .../2026-10-10-indicator-styles-design.md | 267 ++++++++++++++++++ package-lock.json | 4 +- package.json | 2 +- 5 files changed, 483 insertions(+), 4 deletions(-) create mode 100644 docs/superpowers/plans/2026-10-10-indicator-styles.md create mode 100644 docs/superpowers/specs/2026-10-10-indicator-styles-design.md diff --git a/core/package.json b/core/package.json index ad9bde4..ee67365 100644 --- a/core/package.json +++ b/core/package.json @@ -1,6 +1,6 @@ { "name": "prism-term-core", - "version": "0.27.0", + "version": "0.28.0", "description": "What Prism Terminal and Prism share: the terminal (pty, shells, agent detection and indicator, themes, links, the panel, dictation) and the update chip with its window. TypeScript source, compiled by the host.", "license": "MIT", "private": true, diff --git a/docs/superpowers/plans/2026-10-10-indicator-styles.md b/docs/superpowers/plans/2026-10-10-indicator-styles.md new file mode 100644 index 0000000..bc00647 --- /dev/null +++ b/docs/superpowers/plans/2026-10-10-indicator-styles.md @@ -0,0 +1,212 @@ +# Plan: indicator styles and the Prompt tab style (#143) + +Spec: `docs/superpowers/specs/2026-10-10-indicator-styles-design.md`. Branch +`feat/143-indicator-styles`, worktree `.claude/worktrees/indicator-styles`. Versions bumped in the +first commit: core 0.27.0 to **0.28.0**, app 0.34.0 to **0.35.0** (both minor: features). + +Rules for every task: TDD for the pure parts (the failing test first, run it red, then the code); +no em-dashes; comments say WHY; `core/` stays lint-walled (relative imports, no `window.prism`); +`npm test`, `npm run typecheck`, `npm run lint` green at the end of each task. E2E: `npm run e2e -- +`, parked, ONE PT e2e process at a time. Never close `PrismTerminalStable`. + +## Task 1. Pure: the rainbow and the colour floor (core) + +Files: `core/renderer/lib/markColours.ts`, `core/renderer/lib/markColours.test.ts`. + +- Tests first: + - `ICON_RAINBOW` is the seven mockup colours, in order. + - `floorMark(c, grounds, 3)` leaves a colour that already clears 3:1 on every ground untouched + (Volt: all seven, lowest 3.89). + - On Paper's grounds (`#f6f4ee`, its 4% and 11% segments) every result clears 3:1 on all three, + and keeps its hue (hue within 2 degrees); the mockup's table holds (lowest 3.01). + - On a dark mid-grey ground (Cinder `#383c44`) a short colour is LIGHTENED, not darkened. + - A colour that can never reach the floor comes back as the nearest it got, never throws. + - `rainbowGradient(cols, 'x')` loops (first colour repeated at the end); `'y'` is 180deg. + - `opaqueOver('#d8ff2680', '#050706')` is the composite, 6 digits. +- Code: reuse `contrastRatio` / `luminance` (`termAnsi.ts`) and `parseColour` (`colour.ts`). +- Verify: `npx vitest run core/renderer/lib/markColours.test.ts`. + +## Task 2. Pure: the name flip rule (core) + +Files: `core/renderer/lib/nameInk.ts`, `nameInk.test.ts`. + +- Tests first, the owner's cases from the issue comment and the r4 mockup: + - Volt (text `#eef2e6`, dark): working `#d8ff26` flips to `#0b0b0b`; question `#3b82f6`, + failed `#ff3b5c` and the badge `#383c44` keep the text. + - Volt with a pale question `#cfe4ff` flips; light theme (text `#1d1f1a`) on working `#2a2c30` + flips to `#ffffff`, on question `#2563eb` keeps the text. + - Exactly 2:1 keeps the text (the rule is "under 2:1"). + - Dark or light comes from the GROUND passed in, never from the text. +- Verify: vitest on the file. + +## Task 3. Pure: which mark a tab wears (core) + +Files: `core/renderer/lib/tabMark.ts`, `tabMark.test.ts`. + +- Tests first: one row per cell of the spec's section 3 table (indicator x state x active x + tabStyle), plus rainbow off (`done` is `line`/`edge` in `done`, Full's done fill in `done`, no + badge), Off (no working mark, attention marks as Minimal), Ring on the active tab, Full's active + tab is Minimal's mark, and `state: null` is `none` everywhere. +- Code: `resolveTabMark({ indicator, tabStyle: 'flat' | 'prompt', state, active, rainbow })` returns + `{ place, colour, motion }`. `tabStyle` is the core's word (`flat`); this app maps `classic` to it. +- Verify: vitest on the file. + +## Task 4. The stores: indicator reader and the rainbow switch (core) + +Files: `core/renderer/host.ts` (+ `host.test.ts`), `core/renderer/lib/termLook.ts` +(+ `termLook.test.ts`), `core/README.md` (the new field in the contract). + +- Tests first: + - `readIndicator('full', ALL, 'minimal')` is `'full'` (a stored Full reads as the NEW Full here: + the migration is the meaning, no rewrite); `readIndicator('ring', OLD, 'minimal')` is + `'minimal'`; `null`, `''` and `'loud'` read as the fallback. + - With no `tabMarks` on the host, `agentIndicator()` never answers `'ring'` even when stored. + - `applyCustomExtras({ indicator: 'ring' })` on a host without Ring writes nothing for it. + - `agentRainbow()` is on by default, off only for `'0'`; `resetTermExtras` leaves it alone. +- Code: `AgentIndicator` gains `'ring'`; `TermHostConfig.tabMarks?` (optional, documented as an + owner-approved declared difference); `hostIndicators()` helper; `agentRainbow` / + `setAgentRainbow` / `useAgentRainbow`. +- Verify: vitest on both files; `npm run typecheck`. + +## Task 5. The motion, once (core) + +Files: `core/renderer/styles/marks.css` (new), `src/renderer/src/index.css` (`@import` it; the old +`p-agent-run` block moves into it under the same class name, so the `indicator` and `attention` +e2e selectors keep working), `core/README.md` (a host imports the file). + +- Keyframes and classes: `p-agent-run`, `p-mark-grow` (1.6 s, the four legs), `p-mark-breathe` + (2 s, cosine-sampled), `p-mark-flow-x` / `p-mark-flow-y` (8 s / 7 s, tile from a CSS variable), + `p-mark-ride` (1.4 s), and the reduced-motion block (spec section 5). +- Test: `core/renderer/styles/marks.test.ts` reads the file as text and asserts every class the + resolver can name has a rule and a reduced-motion override (a missing keyframe is otherwise a + still mark nobody notices). +- Verify: vitest; `npm run build` (Vite resolves the import). + +## Task 6. The flat-tab marks component (core) + +Files: `core/renderer/components/TabMark.tsx`, `TabMark.test.tsx` (render with +`react-dom/server`, as other core component tests do). + +- Draws `run`, `line`, `ring`, `fill` (+ the badge and its rainbow foot, + the ride) from a + resolved mark and its colours; every element `aria-hidden`, `pointer-events-none`; attributes + `data-mark`, `data-mark-motion`, `data-attention` (kept: the e2e reads it), `data-rainbow`. +- Tests: each place renders its element and data attributes; a `fill` carries the name ink as a + CSS variable, not per frame. +- Verify: vitest; lint (the core wall). + +## Task 7. Settings rows (core) + +Files: `core/renderer/settings/markOptions.ts` (new, `MARK_OPTIONS`, one flat entry: +`agent-rainbow`), `options.ts` (keywords of `agent-indicator` only), `sections/AgentMarksSection.tsx`, +`layout/icons.ts` (`rainbow`, `prompt`), `options.test.ts` (the list and the section held together; +`settingsCopy` checks on the new sub and label). + +- The indicator's Segmented shows `hostIndicators()`; INDICATOR_SUB gains `ring` and the new + `full` sub where the host draws the new marks (the old sub stays for a host without `tabMarks`). +- The rainbow row renders only where `tabMarks?.rainbow`, after `agent-done-on`. +- Verify: vitest (`options.test.ts`, the settings copy tests); typecheck. + +## Task 8. The tab style setting (app) + +Files: `src/renderer/src/lib/tabStylePrefs.ts` (+ test), `components/settings/appOptions.ts`, +`components/settings/AppearancePage.tsx`, `components/settings/settingsIndex.ts`, its test. + +- Tests first: default `classic`; `prompt` stored reads `prompt`; anything else reads `classic`; + `appOptions` order puts `tab-style` right after `tab-width`; the index finds it by "chevron". +- Verify: vitest; typecheck. + +## Task 9. Chrome tokens for the segments (app) + +Files: `src/renderer/src/lib/chromeTheme.ts` (+ its tests and snapshot), `index.css` `:root` +fallbacks. + +- `--p-seg` / `--p-seg-on`: `--p-text` mixed into the SOLID strip ground at 4% / 11%. +- Recompute the `:root` fallbacks from `chromeTokens` for `prism` (CLAUDE.md rule); the snapshot + test changes only by the two new tokens. +- Verify: vitest (`chromeTheme*.test.ts`). + +## Task 10. The Classic strip (app) + +Files: `src/renderer/src/components/TabStrip.tsx`, `termHost.ts`. + +- Remove the tab `border-r`, `loud`, `onTint` and the brain slot. Each tab: `resolveTabMark`, then + the core's `TabMark`; Ring's spinner sits where the resume ring sits; a Full fill paints the tab + and sets `--mark-ink` for the label and the X. +- Colours: `useAgentColors()`; the rainbow from `rainbowOn([--p-tabs, --p-seg, --p-seg-on])`, + recomputed on a chrome change; the flip ink from `nameInk` against the solid ground. +- `termHost.ts`: `tabMarks: { indicators: ['off', 'minimal', 'ring', 'full'], rainbow: true }`. +- Verify: typecheck, lint, vitest; `npm run e2e -- indicator`, `-- attention` (updated in task 13). + +## Task 11. Pure: Prompt geometry (app) + +Files: `src/renderer/src/lib/promptGeometry.ts` (+ test). + +- Tests first: for given segment boxes, the edge band's left, width and `--o` (ARROW 12, GAP 2.5, + TUCK 1; the last segment without the next TUCK); the rule's clip polygon (slant run + `ARROW * 2 / (height / 2)`, SEAM 0.5, no notch cut on the first segment). Values checked against + the mockup's `placeEdges()`. +- Also `lib/tabDrop`: a test that drop slots stay right with segments overlapping by 9.5 px. + +## Task 12. The Prompt strip (app) + +Files: `src/renderer/src/components/PromptEdges.tsx` (new), `TabStrip.tsx`. + +- Segments: clip-path arrow and notch, `--p-seg` / `--p-seg-on`, overlap, padding (spec 4); fixed + and dynamic widths; works in the title row (`inTitleRow`). +- `PromptEdges`: one band per marked segment and the active rule, placed from a ResizeObserver, + moved never rebuilt, riding the carried tab's `translateX`; the band's inner element carries the + motion class; working on the tab in front wears `--p-accent-hi`. +- Data attributes: `data-tab-style` on the strip, `data-prompt-edge` (state) and `data-prompt-rule`. +- Verify: typecheck, lint; `npm run e2e -- tabStyle` (task 13). + +## Task 13. E2E (app), one scenario at a time + +File: `tools/e2e/run.mjs`. + +- `indicator`: Minimal out of the box, its run the accent (kept); Full now fills a BACKGROUND + working tab (`data-mark="fill"`) and never the active one; no `[data-activity]` brain anywhere. +- `attention`: the question line breathes (`animation-name` is `p-mark-breathe`), the finished line + flows the rainbow (`data-rainbow`), with Rainbow finished off it is the finished colour; lines + only on tabs not looked at (kept). +- NEW `tabStyle`: the row is right after Tab width; Prompt stored; segments clipped, overlapping by + 9.5 px; a working background tab's edge band sits flush in the gap (its box between the two + segments' boxes) and grows; the active tab's rule present; no tab has a right border in either + style; widths fixed and dynamic; screenshots `.e2e-shots/tabs-classic.png`, + `tabs-prompt.png`, `tabs-prompt-light.png` (Paper). +- NEW `indicatorStyles`: Ring shows a spinner beside a working tab's name and Minimal's line after; + Full's name ink by the flip rule, measured on Volt (working flips) and Paper; with + `emulateMedia({ reducedMotion: 'reduce' })` no mark has a running animation; screenshots per + style. +- `colourPicker`: the half-alpha working colour's Full tab is a background tab now, opaque, its name + by the flip rule (2:1). +- `edges`: no tab separator to measure any more; it asserts none exists and measures the rest. +- `options`, `settingsSearch`: the new rows (the lists drive both). +- LOOK at every new screenshot against the mockups' `overview.png` before calling it done. +- Verify: `npm run e2e` whole, once, at the end. + +## Task 14. Prism stays green (read only) + +- In a worktree of the Prism repo (`/.claude/worktrees/core-check`, removed after), point + `prism-term-core` at this branch's `core/` and run Prism's `npm test -- appOptions`, typecheck and + `npm run e2e -- "terminal options"`. Nothing is committed in Prism. +- Expected: green, the Agents page unchanged (no Ring, no rainbow row), Full still Prism's fill. + +## Task 15. Docs + +- `docs/regression-rules.md`: rewrite "FINISHED AND QUESTION ARE LINES" and "The indicator is + MINIMAL by default" for the four choices, the rainbow, the breathe and the two tab styles; a new + entry "Prompt tabs: the edge is the mark" with the geometry's measurements. +- `docs/two-apps.md`: the marks in the core, `tabMarks`, `MARK_OPTIONS` and why it is not in + `TERMINAL_OPTIONS`. +- `core/README.md`: `tabMarks`, `styles/marks.css`. +- `README.md`: the indicator row of the feature table, Prompt tabs. +- CLAUDE.md: one line under the core list if needed, nothing more (lean). + +## Task 16. Ship + +- Full `npm test`, typecheck, lint, `npm run e2e`; `npm run package`; install the branch build per + CLAUDE.md (kill `PrismTerminal` and `PrismTerminal-Setup*` ONLY, never `PrismTerminalStable`), + report the installed version. +- PR `feat(core): indicator styles, the rainbow finish and Prompt tabs (#143)`: says it is a core + change (Prism unchanged until it adopts `tabMarks`), names the new `TermHostConfig` field and the + Off / Classic naming for the owner's word, attaches the screenshots. Ask "merge?" once. diff --git a/docs/superpowers/specs/2026-10-10-indicator-styles-design.md b/docs/superpowers/specs/2026-10-10-indicator-styles-design.md new file mode 100644 index 0000000..0d1578b --- /dev/null +++ b/docs/superpowers/specs/2026-10-10-indicator-styles-design.md @@ -0,0 +1,267 @@ +# Indicator styles and the Prompt tab style (#143) + +Date: 2026-10-10. Issue: #143. Branch: `feat/143-indicator-styles`. +Status: approved by the owner through the mockups ("go ahead and build", 2026-10-10). + +Mockups, the source of truth for the look (read their CSS, not only the pictures): + +- `research/prism-terminal/mockups/2026-10-10-prompt-style/index.html` (+ `overview.png`): the + Prompt tab style, its arrow-edge indicator, the approved working pulse, the flowing rainbow edge. +- `research/prism-terminal/mockups/2026-10-10-indicators-r4/index.html` (+ `overview.png`): Minimal, + Ring and Full (the mockup's "C. Solid") with the name-colour rule. +- The rainbow WASH strips in both (a, b and c) are DROPPED. + +## 1. What the owner decided + +In the owner's words, 2026-10-10 (the request that started this build): + +> "Let's drop the tab style where the indicators style for completion where the tab is fully filled +> out in that rainbow color. Let's just keep the like that border that moves slowly. The pulsation +> effect is also now exactly what I want it. And I like the gentle pulse too for questions. Make +> sure that's also applied to the minimal style for normal tabs. And yeah, I think you can +> basically go ahead and build if you want." + +So: no rainbow wash anywhere; finished is the flowing rainbow line or edge; the working pulse on the +Prompt edge is the approved one; the question's gentle pulse applies everywhere, Minimal on normal +(Classic) tabs included. + +On the issue, 2026-10-10 ("Full (Solid): one name colour, owner's rule"): + +> "Every tab name in Full uses the theme's own text colour, on every tab, idle ones included ... A +> filled tab changes its name to the opposite colour (black on a dark theme, white on a light theme) +> only when the theme's text colour would read under 2:1 on that state's full fill colour ... The +> choice is made once per state colour, from the fill at full strength. It never changes per +> animation frame." + +From the issue body (2026-10-09): remove the small icon (the brain) inside a working tab; the active +tab must stay obvious in every state; a lighter style that is only the resume ring/spinner. Round 2: +"Ring v2. The spinning ring stays, but only while the agent is working. Finished, question and failed +... use Minimal's lines"; "Rainbow finished, an option for every style ... It must still read as +finished ... by its shape too and not only its colour." + +### The decisions, as built + +| | Decision | +|---|---| +| Tab styles | A new setting, this app's own: **Classic** (today's strip) and **Prompt** (chevron segments). | +| Separators | None between tabs in either style: the `border-r` on every tab goes. | +| Indicator choices | **Off, Minimal, Ring, Full.** Today's Full (the accent fill that hid the active tab) and the brain icon go. | +| Minimal, Classic | Working: today's running bar. Finished, question, failed: a straight 3 px bottom line in their colour. No dashes, no double lines. | +| Minimal, Prompt | The arrow edge to the right of the segment IS the mark. Working: the approved pulse. Finished, question, failed: the whole edge in their colour. | +| Ring | The resume ring beside the name while working, on any tab; every other state is the tab style's Minimal mark. | +| Full | Every marked tab NOT in front is filled solid (opaque) in its state colour. The tab in front is never filled and shows Minimal's mark. Finished is the icon's dark badge with the flowing rainbow along its foot. | +| Name colour in Full | Theme text everywhere; a fill flips it to the opposite only below 2:1, decided per state colour. | +| Question | A gentle pulse in every style: fades all the way to the ground and back, 2 s. | +| Finished | The icon's rainbow, flowing slowly (line 8 s a lap, edge 7 s), each colour moved to the 3:1 floor. A **Rainbow finished** switch, ON by default; off is today's finished colour. | +| Failed | Steady red. Working wears the accent, as today. | +| Reduced motion | Everything still: rainbow static, working pulse at full, the run a full line, question solid, the ring a still arc. | +| Kept | Lines only on tabs not looked at (the attention rules), the taskbar badge, the Finished / Question / Failed switches. | + +**Off stays.** The owner's list names Minimal, Ring and Full; Off was not mentioned and removing it +would silently change what a user who chose it sees. It stays as the first choice. (Owner may say +otherwise; it is one entry in a list.) + +**Classic, not "Current".** The owner called it "Current (today's strip)". A choice cannot be named +Current once there are two, so the label is **Classic** (stored `classic`). One string to change if +the owner prefers another word. + +## 2. The core / app split + +How Prism consumes the indicator today (Prism `952e309`, read only): + +- Prism draws its OWN tab strip (`src/renderer/src/components/TabStrip.tsx`). From the core it takes + `useAgentIndicator` (termLook), `useAgentColors`, `useAgentIndicator` (the rules hook), and the + settings section `AgentMarksSection` (Agents page). +- Its strip knows `'off' | 'minimal' | 'full'`, where `full` is the old fill. +- Its gate (`tools/e2e/run.mjs`, `termOptionsScenario`) reads `options.ts` as TEXT and asserts the + Agents page shows exactly the non-`onlyWhere` rows of that list, clicks "Full" in + `agent-indicator` and expects `data-agent="full"`. Its unit test `appOptions.test.ts` asserts + `ROW_ORDER` holds every id in `TERMINAL_OPTIONS` (onlyWhere rows included). + +So a new row in `TERMINAL_OPTIONS` would turn Prism's unit test red at the next core bump, and a new +row drawn by `AgentMarksSection` would turn its gate red. The core-release automation merges a bump +only when Prism's checks are green, so either would stall the pipeline. The split: + +### In the core (shared, because Prism must look and behave the same once it adopts the marks) + +1. **The indicator store** (`core/renderer/lib/termLook.ts`): `AgentIndicator` becomes + `'off' | 'minimal' | 'ring' | 'full'`. The stored value is read through a pure validator, + `readIndicator(raw, allowed, fallback)`, against what the HOST draws (below). A stored `full` + reads as `full` (in this app that is now the new Full; nothing is rewritten). A stored value the + host does not draw (`ring` in Prism) reads as the host's default. `applyCustomExtras` writes a + saved Custom's `indicator` only through the same validator (it wrote it unchecked). +2. **The rainbow switch** (`termLook`): `prism.term.agentRainbow`, on unless `'0'` (the Finished / + Question / Failed switches' shape). Not theme-bound: a theme pick never touches it. +3. **The pure rules**, each with its own test file: + - `lib/markColours.ts`: `ICON_RAINBOW` (the seven colours of `build/icon-source.png`, as the + mockups sampled them: `#12cee5 #2179fa #945af5 #e84fd2 #fb7f6c #f0a934 #e8d021`), `floorMark` + (a colour moved to a contrast floor against EVERY ground it can sit on: darkened toward black + on a light ground and lightened toward white on a dark one, keeping its hue), `rainbowOn` + (the seven, floored), `rainbowGradient` (the looping gradient string, along x or y), + `opaqueOver` (a see-through colour composited on the solid ground, since Full is opaque). + - `lib/nameInk.ts`: the owner's flip rule. `nameInk(fill, text, groundIsDark)` is `text` when + `contrast(text, fill) >= 2`, else `#0b0b0b` on a dark theme and `#ffffff` on a light one. + Dark or light is MEASURED from the ground, never read off a name (regression rule 15). + - `lib/tabMark.ts`: `resolveTabMark({ indicator, tabStyle, state, active, rainbow })` answers + WHICH mark a tab wears: `none | run | line | edge | ring | fill`, its colour role + (`working | rule | done | rainbow | badge | question | failed`) and its motion + (`run | grow | breathe | flow | ride | still`). Both styles and every rule of section 1 live + here, so the strip only draws what it is told. +4. **The motion, once** (`core/renderer/styles/marks.css`, new; imported by each host's + `index.css`): the keyframes and classes for the run, the edge grow, the question breathe, the + rainbow flow, the Full working ride and the reduced-motion rules. The core has had no CSS file; + a host that does not import it simply gets still marks. +5. **The flat-tab marks component** (`core/renderer/components/TabMark.tsx`, props only): draws a + `run`, `line`, `ring` or `fill` (with the badge and its rainbow foot, and the riding bar) from + `resolveTabMark`'s answer. Prism's strip can mount it when it adopts the marks. +6. **The declared difference** (`core/renderer/host.ts`): a new OPTIONAL field + + ```ts + /** What the host's tab strip draws (#143). Absent: Off, Minimal and Full, where Full is the + * host's own filled tab, and no Rainbow finished row (Prism, until it adopts the marks). */ + tabMarks?: { indicators: readonly AgentIndicator[]; rainbow: boolean } + ``` + + Optional, so Prism's `termHost.ts` still typechecks unchanged and Prism's look, rows and gate + are untouched until the owner decides Prism adopts the marks. Adding a `TermHostConfig` field is + an owner decision: this one is recorded here and named in the PR for the owner's yes. +7. **The settings rows** (`core/renderer/settings/sections/AgentMarksSection.tsx`): the indicator's + choices come from `tabMarks?.indicators` (Off, Minimal, Full where absent); its subtext per + choice. The **Rainbow finished** row is drawn only where `tabMarks?.rainbow`, right after "Mark + tabs when an agent finishes". It is listed in a NEW core list, `settings/markOptions.ts` + (`MARK_OPTIONS`, flat one-line entries like the others), NOT in `TERMINAL_OPTIONS`: the pattern + `helpOptions.ts` set ("a row there would fail Prism's parity check until Prism wires the + popup"). `agent-indicator` stays in `TERMINAL_OPTIONS` with its id, label and key unchanged + (Prism's gate reads them); only its keywords grow. + +### In this app (`src/`) + +1. **The tab style setting**: `lib/tabStylePrefs.ts` (`prism.window.tabStyle`, `classic` default, + anything else reads as `classic`), the row `tab-style` in `appOptions.ts` (Appearance > Window, + right after Tab width), its `settingsIndex.ts` entry, drawn by the Appearance page. +2. **The strip** (`components/TabStrip.tsx`): no separators; the old Full, `onTint` and the brain + slot removed; each tab asks `resolveTabMark` and mounts the core's `TabMark` (Classic) or hands + its mark to the Prompt edges (Prompt). +3. **Prompt** (`components/PromptEdges.tsx` + pure `lib/promptGeometry.ts`): the segment shapes, + the edge bands and the active tab's top rule, as section 4 describes. +4. **The chrome tokens** (`lib/chromeTheme.ts`): `--p-seg` and `--p-seg-on`, the segment grounds + (the theme text mixed into `--p-tabs` at 4% and 11%, the mockups' values), from the SOLID ground + so acrylic does not halve them. The `:root` fallbacks in `index.css` are recomputed (CLAUDE.md: + they are `chromeTokens` output for the `prism` preset). +5. **The host config** (`termHost.ts`): `tabMarks: { indicators: ['off', 'minimal', 'ring', 'full'], rainbow: true }`. + +## 3. The marks, state by state + +`state` is what TabStrip already works out, unchanged: working (any tab, active included), else +question > failed > done, each only on a tab not looked at and behind its switch. Colours are +`useAgentColors()` as today; `rule` is `--p-accent-hi`. + +| indicator | state | Classic, not in front | Classic, in front | Prompt, not in front | Prompt, in front | +|---|---|---|---|---|---| +| Minimal | working | run (working) | run (working) | edge grow (working) | edge grow (rule colour, joins the top rule) | +| Minimal | done | line flow (rainbow) | same | edge flow (rainbow) | same | +| Minimal | question | line breathe (question) | same | edge breathe | same | +| Minimal | failed | line still (failed) | same | edge still | same | +| Ring | working | ring beside the name | same | ring beside the name, no edge | same | +| Ring | other | as Minimal | as Minimal | as Minimal | as Minimal | +| Full | working | fill (working) + ride in the name ink | Minimal's run | fill on the segment + ride | Minimal's edge grow | +| Full | done | fill (badge `#383c44`) + rainbow foot line, flow | Minimal's | segment badge + foot line | Minimal's | +| Full | question | fill breathe (name ink fixed) | Minimal's | segment fill breathe | Minimal's | +| Full | failed | fill still | Minimal's | segment fill still | Minimal's | +| Off | working | none | none | none | none | +| Off | other | as Minimal | as Minimal | as Minimal | as Minimal | + +- **Rainbow finished off**: `done` is the finished colour, still; Full's done fill is then the + finished colour (no badge), its name by the flip rule. +- **Off** keeps today's meaning: no working mark; the attention marks still show behind their + switches. +- **Full's fill is opaque**: a see-through working colour (#112) is composited over + `--p-bg-solid` first. The name ink is `nameInk(fill at full strength, --p-text, dark)`, chosen + once per state colour, so the breathing question's name never changes mid-fade. +- **The ride** (Full, working, not in front): a 2 px bar, 40% of the tab, riding along the foot, + 3 px above the bottom, in the name ink (`ride`, 1.4 s), as the mockup's `.sf::after`. +- **The badge**: the icon's dark ground as the mockup has it, `#383c44`. The rainbow on it is + floored against the badge (3:1), not the strip. + +## 4. Prompt geometry (from the mockup, MEASURED there) + +- A segment's arrow is 12 px deep; the next segment's notch is parallel (12 px), the segments + overlap by 9.5 px (`margin-right: -9.5px`), so the ground-coloured chevron between two segments, + the **edge**, is 2.5 px across (2 px square to the slant, the top rule's own width). +- Padding `0 22px 0 14px`; the first segment has no notch (`padding-left: 10px`); the last keeps + `margin-right: 4px` before the +. Fixed width: `flex: 0 1 122px`, min 72 px. Dynamic width: the + label sizes the segment as Classic does, plus the arrow's room. +- Segments are `--p-seg`; the tab in front is `--p-seg-on`, its name bold. +- **The edge band** is drawn in the STRIP, behind the segments, from 1 px inside this segment to + 1 px inside the next, so both segments' own shapes cut it and it fills the gap flush, tip to both + corners. The last segment's band stops on the notch line it would have. Bands are placed from the + measured boxes (ResizeObserver on the strip and each segment) and MOVED, never rebuilt, so a + resize does not restart an animation. During a carry a band rides its tab's `translateX`. +- **The top rule** of the tab in front is drawn in the strip above the segments, cut along the + arrow's slant at its right end and the notch's at its left, running 0.5 px past the slant (the + mockup's SEAM: inside the segment the clip left a one-pixel darker hairline, MEASURED there at + about 80% brightness at 4x). +- **Working** grows the edge: empty 0.2 s, from the tip out to both corners 0.6 s, full 0.2 s, back + 0.6 s (`grow`, 1.6 s, `cubic-bezier(.45, 0, .55, 1)` per leg); a solid fill scaled from the tip's + row, the chevron clip shaping it, so both arms reach their corners on the same frame. On the tab + in front it wears the rule's colour, so at full length rule and edge read as one line bending + down. +- **Finished**: the rainbow runs down the edge, the whole ribbon in the edge's height, 7 s a lap. +- The hit area of a segment is its clip shape; the notch area belongs to the next tab. + +## 5. Motion + +| name | what | timing | +|---|---|---| +| `run` | today's running bar (`p-agent-run`) | 1.25 s, as today | +| `grow` | the Prompt edge pulse | 1.6 s loop: hold 0.2, grow 0.6, hold 0.2, shrink 0.6 | +| `breathe` | question: opacity 1 to 0 and back, cosine-sampled linear keyframes | 2 s | +| `flow` | rainbow: the gradient slides one tile per lap, the mark itself never moves | line 8 s (tile = 114 px), edge 7 s (tile = the edge's height) | +| `ride` | Full working bar in the name ink | 1.4 s | +| `spin` | Ring | 1 s, as the resume ring | + +`prefers-reduced-motion: reduce`: no animation at all; the run and the ride are full width, the +edge is full, the question is fully on, the rainbow stands still, the ring is a still arc with two +coloured sides. + +## 6. Colour floors + +- Every mark is held to **3:1** against EVERY ground it can sit on: `--p-tabs` (the bare strip) and, + under Prompt, `--p-seg` and `--p-seg-on`. Working, finished, question and failed already come + floored from `useAgentColors`; the rainbow is floored per colour by `floorMark` (on Paper the + mockup darkened every colour that fell short, keeping its hue: lowest 3.01:1). +- The Full badge's rainbow is floored against `#383c44`. +- Full's names follow the flip rule (2:1), the owner's rule, not 4.5:1: legibility on Full is the + owner's call and the mockup's numbers stand (Volt: working flips, 17.1:1; question 3.2:1, failed + 3.1:1 and the badge 9.7:1 keep the theme text). + +## 7. Settings + +| Row | Where | Values | Subtext (at most eight plain words) | +|---|---|---|---| +| `tab-style` (app) | Appearance > Window, after Tab width | Classic, Prompt | "Flat tabs, or arrows like a prompt." | +| `agent-indicator` (core) | Agents > Tab marks | Off, Minimal, Ring, Full | per choice: "No mark while an agent works." / "A line under the tab while it works." / "A spinner by the name while it works." / "Tabs you are not on fill with colour." | +| `agent-rainbow` (core, `MARK_OPTIONS`) | Agents > Tab marks, after "Mark tabs when an agent finishes" | switch, on | "The colours of the app icon, flowing." | + +All three are in Find a setting (`settingsIndex.ts`) with keywords (tab-style: "chevron arrow +segment shape powerline"; agent-indicator adds "ring spinner solid fill"; agent-rainbow: "colors +gradient finished done icon"). The label of the rainbow row: "Rainbow finished mark". New tile +icons: `prompt` (a chevron) and `rainbow` (an arc) in `layout/icons.ts`. + +## 8. What does not change + +- `useAgentIndicator` (the rules hook): who is working, done, asked or failed is untouched. +- The attention rules, the taskbar badge and its count, the switches. +- Prism: its strip, rows, defaults and gate. The core bump changes nothing it draws. +- The resume spinner (`data-tab-loading`) while a tab resumes at launch. + +## 9. Risks + +- **Prism's gate at the bump.** Held by: no new `TERMINAL_OPTIONS` row, the optional host field, + the indicator reader refusing values the host does not draw. Verified before the PR by running + Prism's `appOptions` unit test and its `termOptions` gate against this core in a Prism worktree + (read only, nothing committed there). +- **Prompt hit-testing and drag.** clip-path shapes the target; the carry and the drop slots + measure the boxes, which overlap by 9.5 px under Prompt; `lib/tabDrop` gets a test for that. +- **A parallel branch** (`fix/144-question-stays`) changes question logic in `useAgentIndicator`; + this work does not touch that file, so a rebase is mechanical. diff --git a/package-lock.json b/package-lock.json index 265e25e..2bdd9e1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "prism-terminal", - "version": "0.34.0", + "version": "0.35.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "prism-terminal", - "version": "0.34.0", + "version": "0.35.0", "license": "MIT", "dependencies": { "@xterm/addon-fit": "^0.11.0", diff --git a/package.json b/package.json index 9f09cc1..04ea6d1 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "prism-terminal", "productName": "Prism Terminal", - "version": "0.34.0", + "version": "0.35.0", "description": "A tabbed Windows terminal for AI CLIs.", "main": "./out/main/index.js", "author": "Max", From c37dbf2b6aaff9ba1dc83e437e9b35ff85e36d16 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Sat, 10 Oct 2026 02:08:18 +0200 Subject: [PATCH 2/5] feat(core): indicator styles, the rainbow finish and Prompt tabs (#143) The pure rules (markColours, nameInk, tabMark, markPalette), the marks' motion in core/renderer/styles/marks.css, the TabMark component, Ring and the rainbow switch, tabMarks on TermHostConfig, the Tab style setting and the Classic and Prompt strips. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FHHaWKR4M5QtW7Wecyuk4t --- core/package.json | 2 + core/renderer/components/TabMark.test.ts | 71 +++ core/renderer/components/TabMark.tsx | 91 ++++ core/renderer/components/markClasses.ts | 24 + core/renderer/host.ts | 24 +- core/renderer/lib/markColours.test.ts | 59 +++ core/renderer/lib/markColours.ts | 61 +++ core/renderer/lib/markPalette.test.ts | 41 ++ core/renderer/lib/markPalette.ts | 78 ++++ core/renderer/lib/nameInk.test.ts | 42 ++ core/renderer/lib/nameInk.ts | 22 + core/renderer/lib/tabMark.test.ts | 82 ++++ core/renderer/lib/tabMark.ts | 69 +++ core/renderer/lib/termLook.test.ts | 55 +++ core/renderer/lib/termLook.ts | 57 ++- core/renderer/settings/coreIndex.ts | 11 +- core/renderer/settings/layout/icons.ts | 4 + core/renderer/settings/markOptions.ts | 27 ++ core/renderer/settings/options.test.ts | 18 +- core/renderer/settings/options.ts | 2 +- .../settings/sections/AgentMarksSection.tsx | 47 +- core/renderer/settings/sections/opts.ts | 5 +- core/renderer/styles/marks.css | 97 ++++ core/renderer/styles/marks.test.ts | 36 ++ src/renderer/src/components/TabStrip.tsx | 434 ++++++++++-------- .../components/settings/AppearancePage.tsx | 13 + .../components/settings/appOptions.test.ts | 14 +- .../src/components/settings/appOptions.ts | 1 + .../src/components/settings/settingsIndex.ts | 6 +- src/renderer/src/index.css | 20 +- .../src/lib/chromeTheme.opacity.test.ts | 6 +- src/renderer/src/lib/chromeTheme.test.ts | 23 + src/renderer/src/lib/chromeTheme.ts | 15 +- src/renderer/src/lib/promptGeometry.test.ts | 47 ++ src/renderer/src/lib/promptGeometry.ts | 67 +++ src/renderer/src/lib/tabDrop.test.ts | 29 ++ src/renderer/src/lib/tabStylePrefs.test.ts | 35 ++ src/renderer/src/lib/tabStylePrefs.ts | 37 ++ src/renderer/src/termHost.ts | 3 + 39 files changed, 1544 insertions(+), 231 deletions(-) create mode 100644 core/renderer/components/TabMark.test.ts create mode 100644 core/renderer/components/TabMark.tsx create mode 100644 core/renderer/components/markClasses.ts create mode 100644 core/renderer/lib/markColours.test.ts create mode 100644 core/renderer/lib/markColours.ts create mode 100644 core/renderer/lib/markPalette.test.ts create mode 100644 core/renderer/lib/markPalette.ts create mode 100644 core/renderer/lib/nameInk.test.ts create mode 100644 core/renderer/lib/nameInk.ts create mode 100644 core/renderer/lib/tabMark.test.ts create mode 100644 core/renderer/lib/tabMark.ts create mode 100644 core/renderer/settings/markOptions.ts create mode 100644 core/renderer/styles/marks.css create mode 100644 core/renderer/styles/marks.test.ts create mode 100644 src/renderer/src/lib/promptGeometry.test.ts create mode 100644 src/renderer/src/lib/promptGeometry.ts create mode 100644 src/renderer/src/lib/tabStylePrefs.test.ts create mode 100644 src/renderer/src/lib/tabStylePrefs.ts diff --git a/core/package.json b/core/package.json index ee67365..3d2c9ba 100644 --- a/core/package.json +++ b/core/package.json @@ -18,6 +18,8 @@ "./renderer/settings/options": "./renderer/settings/options.ts", "./renderer/settings/*": "./renderer/settings/*.tsx", "./renderer/settings/dictationOptions": "./renderer/settings/dictationOptions.ts", + "./renderer/settings/markOptions": "./renderer/settings/markOptions.ts", + "./renderer/styles/*": "./renderer/styles/*", "./tools/*": "./tools/*", "./claude-plugin/*": "./claude-plugin/*" }, diff --git a/core/renderer/components/TabMark.test.ts b/core/renderer/components/TabMark.test.ts new file mode 100644 index 0000000..5235d71 --- /dev/null +++ b/core/renderer/components/TabMark.test.ts @@ -0,0 +1,71 @@ +import { createElement } from 'react' +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it } from 'vitest' +import { resolveTabMark, type MarkState } from '../lib/tabMark' +import { TabMark } from './TabMark' + +const draw = ( + indicator: 'minimal' | 'ring' | 'full', + state: MarkState, + extra: { active?: boolean; foot?: string; ink?: string } = {} +): string => + renderToStaticMarkup( + createElement(TabMark, { + mark: resolveTabMark({ indicator, tabStyle: 'flat', state, active: !!extra.active, rainbow: true }), + state, + background: '#123456', + foot: extra.foot, + ink: extra.ink + }) + ) + +describe('the flat tab mark (#143)', () => { + it('draws the running bar while working, hidden from the reader', () => { + const html = draw('minimal', 'working') + expect(html).toContain('data-mark="run"') + expect(html).toContain('p-agent-run') + expect(html).toContain('aria-hidden="true"') + expect(html).not.toContain('data-attention') + }) + + it('draws a 3 px line for a question, breathing, with its attention kept for the e2e', () => { + const html = draw('minimal', 'question') + expect(html).toContain('data-mark="line"') + expect(html).toContain('data-attention="question"') + expect(html).toContain('p-mark-breathe') + expect(html).toContain('h-[3px]') + }) + + it('flows the rainbow on a finished line and says so', () => { + const html = draw('minimal', 'done') + expect(html).toContain('data-rainbow=""') + expect(html).toContain('p-mark-flow-x') + expect(html).toContain('data-attention="done"') + }) + + it('spins a ring for Ring while working', () => { + const html = draw('ring', 'working') + expect(html).toContain('data-mark="ring"') + expect(html).toContain('p-mark-spin') + }) + + it('fills a Full tab not in front, the name ink a CSS variable, not per frame', () => { + const html = draw('full', 'working', { ink: '#0b0b0b' }) + expect(html).toContain('data-mark="fill"') + expect(html).toContain('--mark-ink:#0b0b0b') + expect(html).toContain('p-mark-ride') + const q = draw('full', 'question', { ink: '#eeeeee' }) + expect(q).toContain('p-mark-breathe') + expect(q).toContain('--mark-ink:#eeeeee') + }) + + it('puts the rainbow along a finished badge\'s foot', () => { + const html = draw('full', 'done', { foot: 'linear-gradient(90deg, red, blue, red)' }) + expect(html).toContain('data-mark-foot') + expect(html).toContain('p-mark-flow-x') + }) + + it('draws nothing for a Full tab in front beyond Minimal\'s mark', () => { + expect(draw('full', 'working', { active: true })).toContain('data-mark="run"') + }) +}) diff --git a/core/renderer/components/TabMark.tsx b/core/renderer/components/TabMark.tsx new file mode 100644 index 0000000..cd68dd1 --- /dev/null +++ b/core/renderer/components/TabMark.tsx @@ -0,0 +1,91 @@ +import type { JSX } from 'react' +import type { MarkState, TabMark as Mark } from '../lib/tabMark' +import { motionClass } from './markClasses' + +/** + * A TAB'S AGENT MARK, on a flat tab (#143). Props only: the strip asks + * `resolveTabMark` WHAT to draw and resolves its colour; this draws it. + * + * - `run`: the working bar running along the foot. + * - `line`: a 3 px line along the foot (finished, question, failed). + * - `ring`: the spinner, an inline element the strip puts beside the name. + * - `fill`: the whole tab, solid, under the name (Full, a tab not in front); + * with `foot` the rainbow along its foot (the finished badge), with a `ride` + * motion the bar riding along it in the name's ink. + * - `edge`: a Prompt segment's arrow edge, which the host's Prompt strip draws + * itself; nothing here. + * + * Every element is `aria-hidden` and lets the pointer through; the tab's own + * attributes say its state to tests and to nobody else. + */ +export function TabMark({ + mark, + state, + background, + foot, + ink +}: { + mark: Mark + state: MarkState | null + /** The mark's colour, or the rainbow's gradient. */ + background: string + /** The rainbow along a finished fill's foot. */ + foot?: string + /** The name's ink on a fill, chosen once per state colour (`nameInk`). */ + ink?: string +}): JSX.Element | null { + const attention = state && state !== 'working' ? state : undefined + const rainbow = mark.colour === 'rainbow' || mark.colour === 'badge' ? '' : undefined + const common = { + 'data-mark': mark.place, + 'data-mark-motion': mark.motion ?? undefined, + 'data-attention': attention, + 'data-rainbow': rainbow, + 'aria-hidden': true + } + switch (mark.place) { + case 'run': + return ( + + + + ) + case 'line': + return ( + + ) + case 'ring': + return ( + + ) + case 'fill': + return ( + + {foot && ( + + )} + {mark.motion === 'ride' && ( + + )} + + ) + default: + return null + } +} diff --git a/core/renderer/components/markClasses.ts b/core/renderer/components/markClasses.ts new file mode 100644 index 0000000..90922d8 --- /dev/null +++ b/core/renderer/components/markClasses.ts @@ -0,0 +1,24 @@ +import type { MarkMotion } from '../lib/tabMark' + +/** The class in `styles/marks.css` that moves each motion. `flow` has one per + * axis (a line flows along x, a Prompt edge down y); `still` moves nothing. */ +export const MOTION_CLASS: Record = { + run: 'p-agent-run', + grow: 'p-mark-grow', + breathe: 'p-mark-breathe', + flow: 'p-mark-flow-x p-mark-flow-y', + ride: 'p-mark-ride', + spin: 'p-mark-spin', + still: '' +} + +/** The class for a motion on a mark running along `axis`. */ +export function motionClass(motion: MarkMotion | null, axis: 'x' | 'y' = 'x'): string { + if (!motion) return '' + if (motion === 'flow') return axis === 'x' ? 'p-mark-flow-x' : 'p-mark-flow-y' + return MOTION_CLASS[motion] +} + +/** The icon's dark ground (#143, the mockup's `#383c44`): Full's finished fill, + * with the rainbow along its foot. */ +export const MARK_BADGE = '#383c44' diff --git a/core/renderer/host.ts b/core/renderer/host.ts index 52474d8..2cf370d 100644 --- a/core/renderer/host.ts +++ b/core/renderer/host.ts @@ -55,7 +55,14 @@ export interface TermApi { termOpenPath?(cwd: string, text: string, mode: 'open' | 'reveal'): void } -export type AgentIndicator = 'off' | 'minimal' | 'full' +/** How a tab marks its agent (#143 added `ring`). What a host's strip can + * DRAW is its `tabMarks.indicators`; a stored value outside it reads as the + * host's default (`readIndicator`). */ +export type AgentIndicator = 'off' | 'minimal' | 'ring' | 'full' + +/** The choices a host that has not declared `tabMarks` draws: its own Off, + * Minimal and Full (Prism, whose Full is its own filled tab). */ +export const LEGACY_INDICATORS: readonly AgentIndicator[] = ['off', 'minimal', 'full'] export interface TermHostConfig { api: TermApi @@ -162,6 +169,16 @@ export interface TermHostConfig { * gives a file, Explorer for a folder, never a program run). */ openPath?(target: { abs: string; kind: 'file' | 'dir'; mode: 'open' | 'reveal' }): boolean + + /** + * WHAT THE HOST'S TAB STRIP DRAWS (#143; owner decision, 2026-10-10, named + * in its PR). `indicators`: the choices the Agents page offers and the store + * accepts, in order; `rainbow`: the strip draws the rainbow finish, so the + * "Rainbow finished mark" row is offered. Absent: Off, Minimal and Full, + * where Full is the host's own filled tab, and no rainbow row (Prism, until + * it adopts the core's marks), so an update changes nothing it draws. + */ + tabMarks?: { indicators: readonly AgentIndicator[]; rainbow: boolean } } let host: TermHostConfig | null = null @@ -207,6 +224,11 @@ export const hostOwnsWindowAcrylic = (): boolean => host?.acrylic.kind === 'wind /** Hear the host's window colours change (`onChromeChange`); a no-op where the * host has none, or none has spoken. */ export const onHostChromeChange = (cb: () => void): (() => void) => host?.onChromeChange?.(cb) ?? (() => {}) +/** The indicator choices the host's strip draws (`tabMarks`), the legacy three + * where it has not declared them or no host has spoken. */ +export const hostIndicators = (): readonly AgentIndicator[] => host?.tabMarks?.indicators ?? LEGACY_INDICATORS +/** Whether the host's strip draws the rainbow finish (`tabMarks.rainbow`). */ +export const hostRainbow = (): boolean => host?.tabMarks?.rainbow ?? false /** For tests, and for a host that tears down. */ export function resetTermCore(): void { diff --git a/core/renderer/lib/markColours.test.ts b/core/renderer/lib/markColours.test.ts new file mode 100644 index 0000000..70b3d91 --- /dev/null +++ b/core/renderer/lib/markColours.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest' +import { ICON_RAINBOW, floorMark, opaqueOver, rainbowGradient, rainbowOn } from './markColours' +import { contrastRatio, mixHex } from './termAnsi' +import { rgbToHsl, parseColour } from './colour' + +const minOn = (c: string, grounds: string[]): number => Math.min(...grounds.map((g) => contrastRatio(c, g))) +const hue = (c: string): number => rgbToHsl(parseColour(c)!).h + +// Volt and Paper as the mockups computed them: the strip, and the two Prompt +// segments (the text mixed into the strip at 4% and 11%). +const VOLT = ['#050706', mixHex('#050706', '#eef2e6', 0.04), mixHex('#050706', '#eef2e6', 0.11)] +const PAPER = ['#f6f4ee', mixHex('#f6f4ee', '#2a2620', 0.04), mixHex('#f6f4ee', '#2a2620', 0.11)] + +describe('the rainbow of the app icon (#143)', () => { + it('is the seven colours of build/icon-source.png, in order', () => { + expect(ICON_RAINBOW).toEqual(['#12cee5', '#2179fa', '#945af5', '#e84fd2', '#fb7f6c', '#f0a934', '#e8d021']) + }) + + it('leaves every colour alone where it already clears 3:1 (Volt)', () => { + for (const c of ICON_RAINBOW) expect(floorMark(c, VOLT, 3)).toBe(c) + expect(Math.min(...rainbowOn(VOLT).map((c) => minOn(c, VOLT)))).toBeGreaterThan(3.8) + }) + + it('darkens what falls short on Paper until it clears 3:1 on every ground, keeping its hue', () => { + for (const c of ICON_RAINBOW) { + const m = floorMark(c, PAPER, 3) + expect(minOn(m, PAPER), `${c} -> ${m}`).toBeGreaterThanOrEqual(3) + const dh = Math.abs(hue(m) - hue(c)) + expect(Math.min(dh, 360 - dh), `${c} -> ${m}`).toBeLessThanOrEqual(2) + } + // The mockup's own table: the closest is just over the floor, not far past it. + expect(Math.min(...rainbowOn(PAPER).map((c) => minOn(c, PAPER)))).toBeLessThan(3.2) + }) + + it('LIGHTENS a short colour on a dark mid grey, never darkens it', () => { + const cinder = ['#383c44'] + const m = floorMark('#2179fa', cinder, 3) + expect(minOn(m, cinder)).toBeGreaterThanOrEqual(3) + expect(parseColour(m)!.r).toBeGreaterThan(parseColour('#2179fa')!.r) + }) + + it('hands back the nearest it got when the floor cannot be reached, and never throws', () => { + const grey = ['#777777'] + expect(() => floorMark('#7a7a7a', grey, 21)).not.toThrow() + const m = floorMark('#7a7a7a', grey, 21) + expect(minOn(m, grey)).toBeGreaterThan(minOn('#7a7a7a', grey)) + }) + + it('loops its gradient, along x or down y', () => { + expect(rainbowGradient(['#111111', '#222222'], 'x')).toBe('linear-gradient(90deg, #111111, #222222, #111111)') + expect(rainbowGradient(['#111111', '#222222'], 'y')).toBe('linear-gradient(180deg, #111111, #222222, #111111)') + }) + + it('makes a see-through colour opaque over the solid ground, for Full', () => { + expect(opaqueOver('#d8ff2680', '#050706')).toMatch(/^#[0-9a-f]{6}$/) + expect(opaqueOver('#d8ff2680', '#050706')).toBe(mixHex('#050706', '#d8ff26', 0x80 / 255)) + expect(opaqueOver('#d8ff26', '#050706')).toBe('#d8ff26') + }) +}) diff --git a/core/renderer/lib/markColours.ts b/core/renderer/lib/markColours.ts new file mode 100644 index 0000000..c521968 --- /dev/null +++ b/core/renderer/lib/markColours.ts @@ -0,0 +1,61 @@ +import { composite } from './colour' +import { contrastRatio, mixHex } from './termAnsi' + +/** + * THE RAINBOW FINISH (#143; owner, 2026-10-10: "keep the like that border that + * moves slowly"). A finished tab wears the app icon's own ribbon, flowing. The + * seven colours are `build/icon-source.png`'s, as the approved mockups sampled + * them, in the order the ribbon runs. + */ +export const ICON_RAINBOW = ['#12cee5', '#2179fa', '#945af5', '#e84fd2', '#fb7f6c', '#f0a934', '#e8d021'] as const + +/** The lowest contrast of `c` over every ground it can sit on. */ +const worstOn = (c: string, grounds: readonly string[]): number => + Math.min(...grounds.map((g) => contrastRatio(c, g))) + +/** + * A mark's colour moved to a contrast floor against EVERY ground it can sit on + * (the bare strip and, under Prompt, both segment shades). Mixed towards black + * on a light ground and towards white on a dark one, so it keeps its hue: a + * linear mix with black or white only scales the distance from it. The least + * step that clears the floor, in 2% steps as the mockups measured (on Paper + * the lowest landed at 3.01:1). A floor no step reaches gets the step that came + * nearest, never an error. + */ +export function floorMark(c: string, grounds: readonly string[], floor = 3): string { + if (!grounds.length || worstOn(c, grounds) >= floor) return c + // Judged on the darkest ground for a light-or-dark answer that holds for all. + const dark = grounds.reduce((a, g) => (contrastRatio('#ffffff', g) < contrastRatio('#ffffff', a) ? a : g), grounds[0]) + const to = contrastRatio('#ffffff', dark) >= contrastRatio('#000000', dark) ? '#ffffff' : '#000000' + let best = c + let bestScore = worstOn(c, grounds) + for (let i = 1; i <= 50; i += 1) { + const m = mixHex(c, to, i * 0.02) + const score = worstOn(m, grounds) + if (score >= floor) return m + if (score > bestScore) { + best = m + bestScore = score + } + } + return best +} + +/** The seven, each floored on these grounds. */ +export function rainbowOn(grounds: readonly string[], floor = 3): string[] { + return ICON_RAINBOW.map((c) => floorMark(c, grounds, floor)) +} + +/** The ribbon as a gradient that LOOPS (its first colour again at the end), so + * a tile slid by its own length is seamless: along a line (`x`) or down an + * edge (`y`). */ +export function rainbowGradient(cols: readonly string[], axis: 'x' | 'y'): string { + return `linear-gradient(${axis === 'x' ? '90deg' : '180deg'}, ${[...cols, cols[0]].join(', ')})` +} + +/** A see-through colour as the opaque one it shows over the solid ground: Full + * fills a tab SOLID (owner, 2026-10-10), so a half-alpha working colour (#112) + * is laid on the window's own ground first. */ +export function opaqueOver(c: string, ground: string): string { + return composite(c, ground) +} diff --git a/core/renderer/lib/markPalette.test.ts b/core/renderer/lib/markPalette.test.ts new file mode 100644 index 0000000..02c655d --- /dev/null +++ b/core/renderer/lib/markPalette.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest' +import { MARK_BADGE } from '../components/markClasses' +import { markPalette } from './markPalette' +import { contrastRatio, mixHex } from './termAnsi' + +// Volt as the r4 mockup computed it. +const VOLT = { working: '#d8ff26', finished: '#22c55e', question: '#3b82f6', failed: '#ff3b5c' } +const GROUND = '#050706' +const TEXT = '#eef2e6' +const SEGS = [GROUND, mixHex(GROUND, TEXT, 0.04), mixHex(GROUND, TEXT, 0.11)] + +describe('the marks palette (#143)', () => { + it('leaves Volt\'s bright marks as they are, and floors every line to 3:1 on every ground', () => { + const p = markPalette({ colours: VOLT, grounds: SEGS, solidGround: GROUND, text: TEXT, rainbow: true }) + expect(p.line.working).toBe('#d8ff26') + for (const c of Object.values(p.line)) for (const g of SEGS) expect(contrastRatio(c, g)).toBeGreaterThanOrEqual(3) + }) + + it('names Volt\'s Full inks by the owner\'s rule: only working flips', () => { + const p = markPalette({ colours: VOLT, grounds: SEGS, solidGround: GROUND, text: TEXT, rainbow: true }) + expect(p.ink).toEqual({ working: '#0b0b0b', done: TEXT, question: TEXT, failed: TEXT }) + }) + + it('fills finished with the dark badge only while the rainbow is on', () => { + expect(markPalette({ colours: VOLT, grounds: SEGS, solidGround: GROUND, text: TEXT, rainbow: true }).fill.done).toBe(MARK_BADGE) + expect(markPalette({ colours: VOLT, grounds: SEGS, solidGround: GROUND, text: TEXT, rainbow: false }).fill.done).toBe('#22c55e') + }) + + it('fills SOLID: a half-alpha working colour is laid on the ground first', () => { + const p = markPalette({ colours: { ...VOLT, working: '#d8ff2680' }, grounds: SEGS, solidGround: GROUND, text: TEXT, rainbow: true }) + expect(p.fill.working).toMatch(/^#[0-9a-f]{6}$/) + expect(p.fill.working).toBe(mixHex(GROUND, '#d8ff26', 0x80 / 255)) + }) + + it('loops both rainbow gradients', () => { + const p = markPalette({ colours: VOLT, grounds: SEGS, solidGround: GROUND, text: TEXT, rainbow: true }) + expect(p.rainbowX.startsWith('linear-gradient(90deg, #12cee5')).toBe(true) + expect(p.rainbowY.startsWith('linear-gradient(180deg, #12cee5')).toBe(true) + expect(p.rainbowX.endsWith('#12cee5)')).toBe(true) + }) +}) diff --git a/core/renderer/lib/markPalette.ts b/core/renderer/lib/markPalette.ts new file mode 100644 index 0000000..c2bd346 --- /dev/null +++ b/core/renderer/lib/markPalette.ts @@ -0,0 +1,78 @@ +import { MARK_BADGE } from '../components/markClasses' +import { floorMark, opaqueOver, rainbowGradient, rainbowOn } from './markColours' +import { groundIsDark, nameInk } from './nameInk' +import type { MarkState } from './tabMark' + +/** + * EVERY COLOUR A STRIP'S MARKS WEAR (#143), worked out once from what the + * window paints. Pure, so both apps' strips colour the marks the same way. + * + * - The LINE colours (a run, a line, a Prompt edge) are the agent colours + * (`useAgentColors`), made opaque on the solid ground and held to 3:1 on + * every ground the mark can sit on: the bare strip, and under Prompt both + * segment shades. Where they already clear it nothing moves. + * - The rainbow is the icon's seven, floored the same way, as a looping + * gradient along a line and down an edge. + * - The FILL colours are Full's: SOLID (owner, 2026-10-10), so a see-through + * working colour (#112) is laid on the ground first, and NOT floored: the + * fill is the state's own colour. Finished with the rainbow on is the icon's + * dark badge, its rainbow floored against the badge. + * - The name INK on each fill follows the owner's rule (`nameInk`), once per + * state colour. + */ +export interface MarkPalette { + line: Record + rainbowX: string + rainbowY: string + fill: Record + ink: Record + /** The rainbow along the finished badge's foot. */ + badgeFoot: string +} + +export function markPalette({ + colours, + grounds, + solidGround, + text, + rainbow +}: { + colours: { working: string; finished: string; question: string; failed: string } + /** Every ground a mark can sit on, opaque. */ + grounds: readonly string[] + /** The window's own opaque ground. */ + solidGround: string + /** The theme's text colour. */ + text: string + rainbow: boolean +}): MarkPalette { + const solid = { + working: opaqueOver(colours.working, solidGround), + done: opaqueOver(colours.finished, solidGround), + question: opaqueOver(colours.question, solidGround), + failed: opaqueOver(colours.failed, solidGround) + } + const line = { + working: floorMark(solid.working, grounds), + done: floorMark(solid.done, grounds), + question: floorMark(solid.question, grounds), + failed: floorMark(solid.failed, grounds) + } + const seven = rainbowOn(grounds) + const fill = { ...solid, done: rainbow ? MARK_BADGE : solid.done } + const dark = groundIsDark(solidGround) + const ink = { + working: nameInk(fill.working, text, dark), + done: nameInk(fill.done, text, dark), + question: nameInk(fill.question, text, dark), + failed: nameInk(fill.failed, text, dark) + } + return { + line, + rainbowX: rainbowGradient(seven, 'x'), + rainbowY: rainbowGradient(seven, 'y'), + fill, + ink, + badgeFoot: rainbowGradient(rainbowOn([MARK_BADGE]), 'x') + } +} diff --git a/core/renderer/lib/nameInk.test.ts b/core/renderer/lib/nameInk.test.ts new file mode 100644 index 0000000..85c9d6a --- /dev/null +++ b/core/renderer/lib/nameInk.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest' +import { NAME_FLIP, groundIsDark, nameInk } from './nameInk' +import { contrastRatio } from './termAnsi' + +// The owner's rule for Full (#143, 2026-10-10): every name is the theme's text +// colour; a fill flips it to the opposite only below 2:1 on that state's FULL +// colour. The cases are the issue comment's and the r4 mockup's. + +const VOLT_TEXT = '#eef2e6' +const LIGHT_TEXT = '#1d1f1a' + +describe('the name on a Full tab', () => { + it('on Volt flips only the yellow green working fill', () => { + expect(nameInk('#d8ff26', VOLT_TEXT, true)).toBe('#0b0b0b') + expect(nameInk('#3b82f6', VOLT_TEXT, true)).toBe(VOLT_TEXT) + expect(nameInk('#ff3b5c', VOLT_TEXT, true)).toBe(VOLT_TEXT) + expect(nameInk('#383c44', VOLT_TEXT, true)).toBe(VOLT_TEXT) + }) + + it('flips on a very pale question fill, and white on a light theme\'s dark grey working', () => { + expect(nameInk('#cfe4ff', VOLT_TEXT, true)).toBe('#0b0b0b') + expect(nameInk('#2a2c30', LIGHT_TEXT, false)).toBe('#ffffff') + expect(nameInk('#2563eb', LIGHT_TEXT, false)).toBe(LIGHT_TEXT) + }) + + it('keeps the text at exactly the threshold: the rule is UNDER 2:1', () => { + // A grey whose ratio with white is 2 or a hair above. + let g = 0xc0 + while (contrastRatio('#ffffff', `#${g.toString(16).repeat(3)}`) < NAME_FLIP) g -= 1 + const fill = `#${g.toString(16).repeat(3)}` + expect(contrastRatio('#ffffff', fill)).toBeGreaterThanOrEqual(2) + expect(nameInk(fill, '#ffffff', true)).toBe('#ffffff') + }) + + it('takes dark or light from the GROUND, never from the text', () => { + // A pale text on a fill it vanishes on: the opposite follows the ground. + expect(nameInk('#f0f0f0', '#ffffff', true)).toBe('#0b0b0b') + expect(nameInk('#f0f0f0', '#ffffff', false)).toBe('#ffffff') + expect(groundIsDark('#050706')).toBe(true) + expect(groundIsDark('#f6f4ee')).toBe(false) + }) +}) diff --git a/core/renderer/lib/nameInk.ts b/core/renderer/lib/nameInk.ts new file mode 100644 index 0000000..0c35976 --- /dev/null +++ b/core/renderer/lib/nameInk.ts @@ -0,0 +1,22 @@ +import { contrastRatio, luminance } from './termAnsi' + +/** Under this, the theme's text on a fill is gone and the name flips. */ +export const NAME_FLIP = 2 + +/** Dark or light, MEASURED off the ground (regression rule 15), with the + * chrome's own threshold (`chromeTokens`: light above 0.4). */ +export const groundIsDark = (ground: string): boolean => luminance(ground) <= 0.4 + +/** + * THE NAME ON A FULL TAB (#143; owner, 2026-10-10: "Every tab name in Full + * uses the theme's own text colour ... A filled tab changes its name to the + * opposite colour ... only when the theme's text colour would read under 2:1 + * on that state's full fill colour"). Decided once per state colour, from the + * fill at full strength, so a breathing question's name never changes as its + * fill fades. The opposite is black on a dark theme and white on a light one, + * and which theme it is comes from the GROUND, never from the text. + */ +export function nameInk(fill: string, text: string, darkGround: boolean): string { + if (contrastRatio(text, fill) >= NAME_FLIP) return text + return darkGround ? '#0b0b0b' : '#ffffff' +} diff --git a/core/renderer/lib/tabMark.test.ts b/core/renderer/lib/tabMark.test.ts new file mode 100644 index 0000000..501546c --- /dev/null +++ b/core/renderer/lib/tabMark.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from 'vitest' +import type { AgentIndicator } from '../host' +import { resolveTabMark, type MarkState, type MarkTabStyle } from './tabMark' + +const m = ( + indicator: AgentIndicator, + tabStyle: MarkTabStyle, + state: MarkState | null, + active: boolean, + rainbow = true +): string => { + const r = resolveTabMark({ indicator, tabStyle, state, active, rainbow }) + return `${r.place}/${r.colour ?? '-'}/${r.motion ?? '-'}` +} + +// One line per cell of the spec's section 3 table. +describe('which mark a tab wears (#143)', () => { + it('Minimal, flat', () => { + for (const on of [false, true]) { + expect(m('minimal', 'flat', 'working', on)).toBe('run/working/run') + expect(m('minimal', 'flat', 'done', on)).toBe('line/rainbow/flow') + expect(m('minimal', 'flat', 'question', on)).toBe('line/question/breathe') + expect(m('minimal', 'flat', 'failed', on)).toBe('line/failed/still') + } + }) + + it('Minimal, Prompt: the edge, and the tab in front grows in the rule colour', () => { + expect(m('minimal', 'prompt', 'working', false)).toBe('edge/working/grow') + expect(m('minimal', 'prompt', 'working', true)).toBe('edge/rule/grow') + for (const on of [false, true]) { + expect(m('minimal', 'prompt', 'done', on)).toBe('edge/rainbow/flow') + expect(m('minimal', 'prompt', 'question', on)).toBe('edge/question/breathe') + expect(m('minimal', 'prompt', 'failed', on)).toBe('edge/failed/still') + } + }) + + it('Ring: the spinner while working, in front or not, in either style; else Minimal', () => { + for (const s of ['flat', 'prompt'] as const) + for (const on of [false, true]) { + expect(m('ring', s, 'working', on)).toBe('ring/working/spin') + expect(m('ring', s, 'question', on)).toBe(m('minimal', s, 'question', on)) + expect(m('ring', s, 'done', on)).toBe(m('minimal', s, 'done', on)) + expect(m('ring', s, 'failed', on)).toBe(m('minimal', s, 'failed', on)) + } + }) + + it('Full fills every marked tab NOT in front, in either style', () => { + for (const s of ['flat', 'prompt'] as const) { + expect(m('full', s, 'working', false)).toBe('fill/working/ride') + expect(m('full', s, 'done', false)).toBe('fill/badge/flow') + expect(m('full', s, 'question', false)).toBe('fill/question/breathe') + expect(m('full', s, 'failed', false)).toBe('fill/failed/still') + } + }) + + it('Full never fills the tab in front: it shows Minimal\'s mark', () => { + for (const s of ['flat', 'prompt'] as const) + for (const st of ['working', 'done', 'question', 'failed'] as const) + expect(m('full', s, st, true)).toBe(m('minimal', s, st, true)) + }) + + it('Off: no working mark, the attention marks as Minimal', () => { + for (const s of ['flat', 'prompt'] as const) + for (const on of [false, true]) { + expect(m('off', s, 'working', on)).toBe('none/-/-') + for (const st of ['done', 'question', 'failed'] as const) expect(m('off', s, st, on)).toBe(m('minimal', s, st, on)) + } + }) + + it('with the rainbow off, finished is the finished colour, still, and Full has no badge', () => { + expect(m('minimal', 'flat', 'done', false, false)).toBe('line/done/still') + expect(m('minimal', 'prompt', 'done', false, false)).toBe('edge/done/still') + expect(m('full', 'flat', 'done', false, false)).toBe('fill/done/still') + expect(m('full', 'prompt', 'done', true, false)).toBe('edge/done/still') + }) + + it('a tab with no state wears nothing, in every style', () => { + for (const ind of ['off', 'minimal', 'ring', 'full'] as const) + for (const s of ['flat', 'prompt'] as const) + for (const on of [false, true]) expect(m(ind, s, null, on)).toBe('none/-/-') + }) +}) diff --git a/core/renderer/lib/tabMark.ts b/core/renderer/lib/tabMark.ts new file mode 100644 index 0000000..3259852 --- /dev/null +++ b/core/renderer/lib/tabMark.ts @@ -0,0 +1,69 @@ +import type { AgentIndicator } from '../host' + +/** + * WHICH MARK A TAB WEARS (#143; spec `docs/superpowers/specs/2026-10-10- + * indicator-styles-design.md`, section 3). Pure: the indicator style, the tab + * style, the tab's state and whether it is in front go in; WHAT to draw, in + * WHICH colour role, with WHICH motion comes out. Every rule of the owner's + * decisions lives here, so a strip only draws what it is told. + * + * - Minimal: working is the running bar (flat) or the growing arrow edge + * (Prompt); finished, question and failed are a 3 px line (flat) or the whole + * edge (Prompt) in their colour. + * - Ring: the spinner beside the name while working; everything else Minimal's. + * - Full: a tab NOT in front is filled solid in its state colour (finished: the + * icon's dark badge with the rainbow along its foot); the tab in front is + * never filled and shows Minimal's mark, so it stays obvious in every state. + * - Off: no working mark; the attention marks as Minimal. + * - Question breathes everywhere, failed is still, finished flows the rainbow + * (or stands still in the finished colour with the rainbow off). + */ + +export type MarkState = 'working' | 'done' | 'question' | 'failed' +/** The core's word for a tab style: `flat` (this app's Classic) or `prompt`. */ +export type MarkTabStyle = 'flat' | 'prompt' +export type MarkPlace = 'none' | 'run' | 'line' | 'edge' | 'ring' | 'fill' +export type MarkColour = 'working' | 'rule' | 'done' | 'rainbow' | 'badge' | 'question' | 'failed' +export type MarkMotion = 'run' | 'grow' | 'breathe' | 'flow' | 'ride' | 'spin' | 'still' + +export interface TabMark { + place: MarkPlace + colour: MarkColour | null + motion: MarkMotion | null +} + +export const NO_MARK: TabMark = { place: 'none', colour: null, motion: null } + +export function resolveTabMark({ + indicator, + tabStyle, + state, + active, + rainbow +}: { + indicator: AgentIndicator + tabStyle: MarkTabStyle + state: MarkState | null + active: boolean + rainbow: boolean +}): TabMark { + if (!state) return NO_MARK + const prompt = tabStyle === 'prompt' + const filled = indicator === 'full' && !active + if (state === 'working') { + if (indicator === 'off') return NO_MARK + if (indicator === 'ring') return { place: 'ring', colour: 'working', motion: 'spin' } + if (filled) return { place: 'fill', colour: 'working', motion: 'ride' } + // On the tab in front the growing edge wears the top rule's colour, so at + // full length rule and edge read as one line bending down. + if (prompt) return { place: 'edge', colour: active ? 'rule' : 'working', motion: 'grow' } + return { place: 'run', colour: 'working', motion: 'run' } + } + const motion: MarkMotion = state === 'question' ? 'breathe' : state === 'done' && rainbow ? 'flow' : 'still' + if (filled) { + const colour: MarkColour = state === 'done' ? (rainbow ? 'badge' : 'done') : state + return { place: 'fill', colour, motion } + } + const colour: MarkColour = state === 'done' ? (rainbow ? 'rainbow' : 'done') : state + return { place: prompt ? 'edge' : 'line', colour, motion } +} diff --git a/core/renderer/lib/termLook.test.ts b/core/renderer/lib/termLook.test.ts index 580f1b8..4991ab6 100644 --- a/core/renderer/lib/termLook.test.ts +++ b/core/renderer/lib/termLook.test.ts @@ -249,3 +249,58 @@ describe('colours with alpha', () => { expect(termGroundAlpha()).toBeCloseTo(0x80 / 255) }) }) + +describe('the indicator choices a host draws (#143)', () => { + const host = (tabMarks?: TermHostConfig['tabMarks']): TermHostConfig => + ({ + api: {} as TermHostConfig['api'], + defaults: { theme: 'prism', acrylic: false, indicator: 'minimal', agentColor: '', agentDoneColor: '' }, + followsHostStyle: false, + paintsGround: true, + themedAgentColors: () => ({ working: '#5b5bd6', finished: '#22c55e' }), + acrylic: { kind: 'style' }, + ownsKey: () => false, + ...(tabMarks ? { tabMarks } : {}) + }) as TermHostConfig + const ALL = ['off', 'minimal', 'ring', 'full'] as const + const OLD = ['off', 'minimal', 'full'] as const + + it('reads a stored value the host draws as itself, a stored Full as the (new) Full', () => { + expect(termLook.readIndicator('full', ALL, 'minimal')).toBe('full') + expect(termLook.readIndicator('ring', ALL, 'minimal')).toBe('ring') + expect(termLook.readIndicator('ring', OLD, 'minimal')).toBe('minimal') + for (const raw of [null, '', 'loud', 3]) expect(termLook.readIndicator(raw, ALL, 'minimal')).toBe('minimal') + }) + + it('never answers Ring in a host without tabMarks, even when stored', () => { + configureTermCore(host()) + localStorage.setItem('prism.term.agentIndicator', 'ring') + expect(agentIndicator()).toBe('minimal') + configureTermCore(host({ indicators: ALL, rainbow: true })) + expect(agentIndicator()).toBe('ring') + resetTermCore() + }) + + it('does not write a saved Custom Ring over the choice where the host draws none', () => { + configureTermCore(host()) + setAgentIndicator('off') + applyCustomExtras({ bg: '#000000', fg: '#ffffff', cursor: '#ffffff', ansi: {}, indicator: 'ring' }) + expect(localStorage.getItem('prism.term.agentIndicator')).toBe('off') + configureTermCore(host({ indicators: ALL, rainbow: true })) + applyCustomExtras({ bg: '#000000', fg: '#ffffff', cursor: '#ffffff', ansi: {}, indicator: 'ring' }) + expect(agentIndicator()).toBe('ring') + resetTermCore() + }) + + it('keeps the rainbow on by default, off only for 0, and a theme pick leaves it alone', () => { + expect(termLook.agentRainbow()).toBe(true) + localStorage.setItem('prism.term.agentRainbow', 'nonsense') + expect(termLook.agentRainbow()).toBe(true) + termLook.setAgentRainbow(false) + expect(localStorage.getItem('prism.term.agentRainbow')).toBe('0') + resetTermExtras() + expect(termLook.agentRainbow()).toBe(false) + termLook.setAgentRainbow(true) + expect(termLook.agentRainbow()).toBe(true) + }) +}) diff --git a/core/renderer/lib/termLook.ts b/core/renderer/lib/termLook.ts index 353e88c..487f345 100644 --- a/core/renderer/lib/termLook.ts +++ b/core/renderer/lib/termLook.ts @@ -1,5 +1,13 @@ import { useSyncExternalStore } from 'react' -import { followsHostStyle, hostDefaults, hostGround, hostOwnsWindowAcrylic, onHostChromeChange, type AgentIndicator } from '../host' +import { + followsHostStyle, + hostDefaults, + hostGround, + hostIndicators, + hostOwnsWindowAcrylic, + onHostChromeChange, + type AgentIndicator +} from '../host' import { liveThemeId } from './termThemeRetired' import { alphaOf, parseColour, toStored, withAlpha } from './colour' @@ -135,14 +143,24 @@ export const LEGACY_OPACITY_KEY = OPACITY_KEY const AGENT_IND_KEY = 'prism.term.agentIndicator' -/** How a working agent shows on its tab: not at all, a line under the tab, - * or the whole tab turning. MINIMAL is the default (owner, 2026-09-18; it was - * full in Prism and in the first build): the line says it, and a filled tab - * is the loud version you opt into. Idle always looks default; only WORKING - * paints. */ +/** + * A stored indicator as the host can draw it (#143): a value among `allowed` + * reads as itself, anything else (never set, nonsense, or a choice this host's + * strip does not draw, such as Ring in Prism) as `fallback`. A stored `full` + * reads as `full`: where the host draws the new marks that IS the new Full, + * the migration being the meaning, so nothing is rewritten. + */ +export function readIndicator(raw: unknown, allowed: readonly AgentIndicator[], fallback: AgentIndicator): AgentIndicator { + return typeof raw === 'string' && (allowed as readonly string[]).includes(raw) ? (raw as AgentIndicator) : fallback +} + +/** How an agent shows on its tab: not at all, a line under the tab (Minimal), + * a spinner by the name (Ring, #143), or the tabs you are not on filled + * (Full). MINIMAL is the default (owner, 2026-09-18; it was full in Prism and + * in the first build): the line says it, and a filled tab is the loud version + * you opt into. */ export function agentIndicator(): AgentIndicator { - const v = localStorage.getItem(AGENT_IND_KEY) - return v === 'full' || v === 'minimal' || v === 'off' ? v : hostDefaults().indicator + return readIndicator(localStorage.getItem(AGENT_IND_KEY), hostIndicators(), hostDefaults().indicator) } export function setAgentIndicator(v: AgentIndicator): void { @@ -178,6 +196,22 @@ export function setAgentQuestionOn(on: boolean): void { notify() } +const RAINBOW_KEY = 'prism.term.agentRainbow' + +/** + * THE RAINBOW FINISH (#143; owner, 2026-10-10): a finished tab wears the app + * icon's colours, flowing. A switch, ON by default; off is the finished + * colour, still. Not a theme setting: a theme pick never touches it. Drawn + * only where the host's strip draws it (`tabMarks.rainbow`). + */ +export function agentRainbow(): boolean { + return localStorage.getItem(RAINBOW_KEY) !== '0' +} +export function setAgentRainbow(on: boolean): void { + localStorage.setItem(RAINBOW_KEY, on ? '1' : '0') + notify() +} + const FAILED_ON_KEY = 'prism.term.agentFailedOn' const HOOKS_KEY = 'prism.term.agentHooks' @@ -322,7 +356,9 @@ export function applyCustomExtras(t: CustomTermTheme | null): void { if (!t) return // The font and its size belong to no theme (2026-09-28): an older save that // carries them does not put them back. - if (t.indicator) localStorage.setItem(AGENT_IND_KEY, t.indicator) + // Through the same reader as the store (#143): a saved Ring in a host that + // draws none is not written over the user's choice. + if (t.indicator && hostIndicators().includes(t.indicator)) localStorage.setItem(AGENT_IND_KEY, t.indicator) // A saved setup that followed the theme goes back to following it. // Validated as every other writer is: a colour that is not one follows the // theme rather than landing in storage unchecked (#112). @@ -469,6 +505,9 @@ export function useAgentDoneOn(): boolean { export function useAgentQuestionOn(): boolean { return useSyncExternalStore(sub, agentQuestionOn) } +export function useAgentRainbow(): boolean { + return useSyncExternalStore(sub, agentRainbow) +} export function useAgentFailedOn(): boolean { return useSyncExternalStore(sub, agentFailedOn) } diff --git a/core/renderer/settings/coreIndex.ts b/core/renderer/settings/coreIndex.ts index f6ada2d..2bdbb2d 100644 --- a/core/renderer/settings/coreIndex.ts +++ b/core/renderer/settings/coreIndex.ts @@ -1,6 +1,7 @@ import { DIAGNOSTICS_OPTIONS } from './diagnosticsOptions' import { dictationOptionIds } from './dictationOptions' import { HELP_OPTIONS } from './helpOptions' +import { MARK_OPTIONS } from './markOptions' import { terminalOptionIds } from './options' import type { SettingsIndexEntry } from './layout/search' import { SETTINGS_SECTIONS, type SettingsSectionId } from './sectionIds' @@ -18,16 +19,22 @@ export function coreSettingsIndex({ pageOf, nvidia, help = true, - diagnostics = false + diagnostics = false, + marks = false }: { pageOf: (section: SettingsSectionId) => string nvidia: boolean help?: boolean /** The Diagnostics page (#140): only in a host that wired the log. */ diagnostics?: boolean + /** The tab marks' own rows (#143): only in a host whose strip draws them, + * where the Agents page draws them, after the Finished switch. */ + marks?: boolean }): SettingsIndexEntry[] { const ids = [ - ...terminalOptionIds({ windowAcrylic: false }), + ...terminalOptionIds({ windowAcrylic: false }).flatMap((id) => + id === 'agent-done-on' && marks ? [id, ...MARK_OPTIONS.map((o) => o.id)] : [id] + ), ...dictationOptionIds({ nvidia }), ...(help ? HELP_OPTIONS.map((o) => o.id) : []), ...(diagnostics ? DIAGNOSTICS_OPTIONS.map((o) => o.id) : []) diff --git a/core/renderer/settings/layout/icons.ts b/core/renderer/settings/layout/icons.ts index 807fe7b..f7ba572 100644 --- a/core/renderer/settings/layout/icons.ts +++ b/core/renderer/settings/layout/icons.ts @@ -33,6 +33,10 @@ export const SETTING_ICONS = { fail: 'M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18zM12 7.5v5.5M12 16.5h.01', taskbar: 'M3 15h18v5H3zM6.5 17.5h.01M10 17.5h4M17 4.5a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5z', hook: 'M8 3v6a4 4 0 0 0 8 0V3M12 13v3a5 5 0 0 1-5 5', + // The tab marks (#143): an arc for the rainbow finish, a chevron for the + // Prompt tab style. + rainbow: 'M3 17a9 9 0 0 1 18 0M6.5 17a5.5 5.5 0 0 1 11 0M10 17a2 2 0 0 1 4 0', + prompt: 'M3 6h11l5 6-5 6H3l5-6z', hand: 'M8 13V5.5a1.5 1.5 0 0 1 3 0V12M11 11.5V4.5a1.5 1.5 0 0 1 3 0V12M14 11.5V6.5a1.5 1.5 0 0 1 3 0V14a6 6 0 0 1-6 6h-1a5 5 0 0 1-4.2-2.3L4.5 14a1.5 1.5 0 0 1 2.5-1.6L8 14', key: 'M3 6h18v12H3zM7 10h.01M11 10h.01M15 10h.01M7 14h10', mic: 'M12 3a3 3 0 0 0-3 3v5a3 3 0 0 0 6 0V6a3 3 0 0 0-3-3ZM5.5 11a6.5 6.5 0 0 0 13 0M12 17.5V21', diff --git a/core/renderer/settings/markOptions.ts b/core/renderer/settings/markOptions.ts new file mode 100644 index 0000000..2bc6781 --- /dev/null +++ b/core/renderer/settings/markOptions.ts @@ -0,0 +1,27 @@ +import type { SettingsSectionId } from './sectionIds' + +/** + * THE TAB MARKS' OWN OPTIONS (#143), the deal `options.ts` makes for the + * terminal's rows: each renders as `data-pref=""`, and each app's e2e + * asserts its page shows this list where its host draws the marks. + * + * A list of its own rather than rows in `TERMINAL_OPTIONS`, for the reason + * `helpOptions.ts` gave: Prism's gate reads THAT file as text and its unit test + * orders every id in it, so a row added there would turn Prism red at the next + * core bump although Prism's strip does not draw the rainbow. A row here is + * drawn only where the host declares it (`tabMarks.rainbow`). + */ +export interface MarkOption { + id: string + label: string + type: 'switch' + key: string + section: SettingsSectionId + icon: string + sub: string + keywords?: string +} + +export const MARK_OPTIONS: readonly MarkOption[] = [ + { id: 'agent-rainbow', label: 'Rainbow finished mark', type: 'switch', key: 'prism.term.agentRainbow', section: 'marks', icon: 'rainbow', sub: 'The colours of the app icon, flowing.', keywords: 'colors gradient finished done icon' } +] diff --git a/core/renderer/settings/options.test.ts b/core/renderer/settings/options.test.ts index f4ddcf2..654566e 100644 --- a/core/renderer/settings/options.test.ts +++ b/core/renderer/settings/options.test.ts @@ -5,6 +5,7 @@ import { labelProblem, copyProblem, subTooLong } from '../../shared/settingsCopy import { DIAGNOSTICS_OPTIONS } from './diagnosticsOptions' import { DICTATION_OPTIONS, dictationOptionIds } from './dictationOptions' import { HELP_OPTIONS } from './helpOptions' +import { MARK_OPTIONS } from './markOptions' import { isSettingIcon } from './layout/icons' import { TERMINAL_OPTIONS, terminalOptionIds } from './options' import { SETTINGS_SECTIONS } from './sectionIds' @@ -35,8 +36,10 @@ const rendered = new Set( [...sections.matchAll(/<(?:Pref|SettingRow)\s+id="([a-z-]+)"|data-pref="([a-z-]+)"/g)].map((m) => m[1] ?? m[2]) ) -const ALL = [...TERMINAL_OPTIONS, ...DICTATION_OPTIONS, ...HELP_OPTIONS, ...DIAGNOSTICS_OPTIONS] -const LIST_FILES = ['options.ts', 'dictationOptions.ts', 'helpOptions.ts', 'diagnosticsOptions.ts'] +// The marks' own rows (#143) are drawn right after the Finished switch. +const TERMINAL_AND_MARKS = TERMINAL_OPTIONS.flatMap((o) => (o.id === 'agent-done-on' ? [o, ...MARK_OPTIONS] : [o])) +const ALL = [...TERMINAL_AND_MARKS, ...DICTATION_OPTIONS, ...HELP_OPTIONS, ...DIAGNOSTICS_OPTIONS] +const LIST_FILES = ['options.ts', 'markOptions.ts', 'dictationOptions.ts', 'helpOptions.ts', 'diagnosticsOptions.ts'] describe('the terminal options list', () => { it('names every row the shared sections render, and nothing they do not', () => { @@ -71,6 +74,14 @@ describe('the terminal options list', () => { expect(TERMINAL_OPTIONS.some((o) => o.id.startsWith('help'))).toBe(false) }) + it("keeps the tab marks' rows in a list of their own, under prism.term (#143)", () => { + // NOT in TERMINAL_OPTIONS: Prism's gate reads that file as text and orders + // every id in it, and Prism's strip does not draw the rainbow. + expect(MARK_OPTIONS.map((o) => o.id)).toEqual(['agent-rainbow']) + for (const o of MARK_OPTIONS) expect(o.key.startsWith('prism.term.')).toBe(true) + expect(TERMINAL_OPTIONS.some((o) => MARK_OPTIONS.some((m) => m.id === o.id || m.key === o.key))).toBe(false) + }) + it('offers the GPU row only where an NVIDIA adapter is present', () => { const all = dictationOptionIds({ nvidia: true }) const without = dictationOptionIds({ nvidia: false }) @@ -86,6 +97,7 @@ describe('the terminal options list', () => { "term-font=prism.term.fontPct", "agent-indicator=prism.term.agentIndicator", "agent-done-on=prism.term.agentDoneOn", + "agent-rainbow=prism.term.agentRainbow", "agent-question-on=prism.term.agentQuestionOn", "agent-failed-on=prism.term.agentFailedOn", "agent-hooks=prism.term.agentHooks", @@ -155,6 +167,8 @@ describe('the grouped cards fields of the lists (2026-10-05)', () => { const list = f === 'options.ts' ? TERMINAL_OPTIONS + : f === 'markOptions.ts' + ? MARK_OPTIONS : f === 'dictationOptions.ts' ? DICTATION_OPTIONS : f === 'diagnosticsOptions.ts' diff --git a/core/renderer/settings/options.ts b/core/renderer/settings/options.ts index a59a159..72eee45 100644 --- a/core/renderer/settings/options.ts +++ b/core/renderer/settings/options.ts @@ -42,7 +42,7 @@ export const TERMINAL_OPTIONS: readonly TerminalOption[] = [ { id: 'term-shell', label: 'Default shell', type: 'choice', key: 'prism.term.shell', section: 'shell', icon: 'shell', sub: 'New terminals start with this shell.', keywords: 'powershell pwsh cmd command prompt bash wsl' }, { id: 'term-font-family', label: 'Terminal font', type: 'choice', key: 'prism.term.font', section: 'text', icon: 'font', sub: 'The typeface inside every terminal.', keywords: 'typeface face monospace cascadia consolas' }, { id: 'term-font', label: 'Terminal text size', type: 'choice', key: 'prism.term.fontPct', section: 'text', icon: 'size', sub: 'Text size for every terminal.', keywords: 'font zoom bigger smaller scale' }, - { id: 'agent-indicator', label: 'Agent working indicator', type: 'choice', key: 'prism.term.agentIndicator', section: 'marks', icon: 'working', sub: 'A line under the tab while it works.', keywords: 'claude codex busy minimal full mark' }, + { id: 'agent-indicator', label: 'Agent working indicator', type: 'choice', key: 'prism.term.agentIndicator', section: 'marks', icon: 'working', sub: 'A line under the tab while it works.', keywords: 'claude codex busy minimal full mark ring spinner solid fill' }, { id: 'agent-done-on', label: 'Mark tabs when an agent finishes', type: 'switch', key: 'prism.term.agentDoneOn', section: 'marks', icon: 'done', sub: 'Stays until you open the tab.', keywords: 'finished complete done indicator' }, { id: 'agent-question-on', label: 'Mark tabs when an agent asks', type: 'switch', key: 'prism.term.agentQuestionOn', section: 'marks', icon: 'ask', sub: 'Stays until you answer or open it.', keywords: 'question waiting answer indicator' }, // Claude Code's own word through its hooks (#131): a Failed mark, and the diff --git a/core/renderer/settings/sections/AgentMarksSection.tsx b/core/renderer/settings/sections/AgentMarksSection.tsx index 5ae0d4b..a51b9a7 100644 --- a/core/renderer/settings/sections/AgentMarksSection.tsx +++ b/core/renderer/settings/sections/AgentMarksSection.tsx @@ -1,13 +1,16 @@ import type { JSX, ReactNode } from 'react' +import { hostIndicators, hostRainbow, termHost } from '../../host' import { setAgentDoneOn, setAgentFailedOn, setAgentIndicator, setAgentQuestionOn, + setAgentRainbow, useAgentDoneOn, useAgentFailedOn, useAgentIndicator, useAgentQuestionOn, + useAgentRainbow, type AgentIndicator } from '../../lib/termLook' import { Segmented, Switch } from '../fields' @@ -15,45 +18,65 @@ import { SettingRow } from '../layout/SettingRow' import { SettingsSection } from '../layout/SettingsSection' import { opt, sectionTitle } from './opts' +/** Each choice's subtext where the host draws the core's marks (#143). */ const INDICATOR_SUB: Record = { off: 'No mark while an agent works.', minimal: 'A line under the tab while it works.', - full: 'The whole tab fills while it works.' + ring: 'A spinner by the name while it works.', + full: 'Tabs you are not on fill with colour.' } +/** Full's subtext in a host that draws its OWN filled tab (Prism, no + * `tabMarks`): what it has always said there. */ +const LEGACY_FULL_SUB = 'The whole tab fills while it works.' + +const INDICATOR_NAME: Record = { off: 'Off', minimal: 'Minimal', ring: 'Ring', full: 'Full' } /** * HOW A TAB MARKS ITS AGENT (2026-10-05, the grouped cards redesign): off, a - * line under the tab, or the whole tab filled while it works, and the three - * marks a tab keeps while you are not looking (2026-09-28; #131 for Failed). - * The indicator's style belongs to no theme: a theme pick never resets it. - * `after`: the host's own rows about the same marks (Prism Terminal: the - * taskbar count). + * line under the tab, a spinner by the name (Ring, #143, where the host draws + * it), or the tabs you are not on filled; the rainbow finish where the host + * draws it (#143); and the three marks a tab keeps while you are not looking + * (2026-09-28; #131 for Failed). The indicator's style belongs to no theme: a + * theme pick never resets it. `after`: the host's own rows about the same + * marks (Prism Terminal: the taskbar count). */ export function AgentMarksSection({ after }: { after?: ReactNode } = {}): JSX.Element { const volume = useAgentIndicator() const doneOn = useAgentDoneOn() const questionOn = useAgentQuestionOn() const failedOn = useAgentFailedOn() + const rainbowOn = useAgentRainbow() + // What the host's strip draws, read at render: the host speaks first. + const choices = hostIndicators() + const ownMarks = !!termHost().tabMarks const ind = opt('agent-indicator') const done = opt('agent-done-on') + const rainbow = opt('agent-rainbow') const question = opt('agent-question-on') const failed = opt('agent-failed-on') return ( - + setAgentIndicator(v as AgentIndicator)} - options={[ - { id: 'off', name: 'Off' }, - { id: 'minimal', name: 'Minimal' }, - { id: 'full', name: 'Full' } - ]} + options={choices.map((id) => ({ id, name: INDICATOR_NAME[id] }))} /> + {/* THE RAINBOW FINISH (#143), only where the host's strip draws it. */} + {hostRainbow() && ( + + + + )} diff --git a/core/renderer/settings/sections/opts.ts b/core/renderer/settings/sections/opts.ts index ea6b6c9..98840fa 100644 --- a/core/renderer/settings/sections/opts.ts +++ b/core/renderer/settings/sections/opts.ts @@ -2,6 +2,7 @@ import { followsHostStyle, hostOwnsWindowAcrylic } from '../../host' import { DIAGNOSTICS_OPTIONS, type DiagnosticsOption } from '../diagnosticsOptions' import { DICTATION_OPTIONS, type DictationOption } from '../dictationOptions' import { HELP_OPTIONS, type HelpOption } from '../helpOptions' +import { MARK_OPTIONS, type MarkOption } from '../markOptions' import { TERMINAL_OPTIONS, type TerminalOption } from '../options' import { SETTINGS_SECTIONS, type SettingsSectionId } from '../sectionIds' @@ -9,10 +10,10 @@ import { SETTINGS_SECTIONS, type SettingsSectionId } from '../sectionIds' // resting subtext are read from its list entry, so the page and Find a setting // can never word a row two ways. -type AnyOption = TerminalOption | DictationOption | HelpOption | DiagnosticsOption +type AnyOption = TerminalOption | DictationOption | HelpOption | DiagnosticsOption | MarkOption const byId = new Map( - [...TERMINAL_OPTIONS, ...DICTATION_OPTIONS, ...HELP_OPTIONS, ...DIAGNOSTICS_OPTIONS].map((o) => [o.id, o]) + [...TERMINAL_OPTIONS, ...MARK_OPTIONS, ...DICTATION_OPTIONS, ...HELP_OPTIONS, ...DIAGNOSTICS_OPTIONS].map((o) => [o.id, o]) ) /** A core option by id. Throws on a typo, which the unit suite then finds. */ diff --git a/core/renderer/styles/marks.css b/core/renderer/styles/marks.css new file mode 100644 index 0000000..472aaa5 --- /dev/null +++ b/core/renderer/styles/marks.css @@ -0,0 +1,97 @@ +/* THE TAB MARKS' MOTION, written once (#143). Each host imports this file + (Prism Terminal's index.css; Prism once it adopts the marks); a host that + does not simply gets still marks. `lib/tabMark.ts` names a motion, the strip + puts its class on the mark, and `marks.test.ts` holds every motion to a rule + here AND a reduced-motion rule below: a missing keyframe is otherwise a still + mark nobody notices. Timings are the approved mockups' + (research/prism-terminal/mockups/2026-10-10-*). */ + +/* run: the Minimal working bar, a loading tab's line (owner pick, 2026-08-23). */ +@keyframes p-agent-run { + 0% { left: -42% } + 100% { left: 100% } +} +.p-agent-run { animation: p-agent-run 1.25s ease-in-out infinite } + +/* grow: the Prompt edge while working (owner, 2026-10-10: "the pulsation + effect is also now exactly what I want"). Empty 0.2 s, from the tip out to + both corners 0.6 s, full 0.2 s, back 0.6 s. A solid fill scaled from the + tip's row; the chevron's clip shapes it, so both arms reach their corners on + the same frame. */ +@keyframes p-mark-grow { + 0%, 12.5% { transform: scaleY(0); animation-timing-function: cubic-bezier(.45, 0, .55, 1) } + 50%, 62.5% { transform: scaleY(1); animation-timing-function: cubic-bezier(.45, 0, .55, 1) } + 100% { transform: scaleY(0) } +} +.p-mark-grow { transform-origin: 50% 50%; animation: p-mark-grow 1.6s linear infinite } + +/* breathe: a question fades all the way to the ground and back, 2 s (owner: + "I like the gentle pulse too for questions"). Linear keyframes sampled from + a cosine: smooth, and a Full tab's name never follows the fade. */ +@keyframes p-mark-breathe { + 0% { opacity: 1 } 10% { opacity: .905 } 20% { opacity: .655 } 30% { opacity: .345 } 40% { opacity: .095 } + 50% { opacity: 0 } 60% { opacity: .095 } 70% { opacity: .345 } 80% { opacity: .655 } 90% { opacity: .905 } 100% { opacity: 1 } +} +.p-mark-breathe { animation: p-mark-breathe 2s linear infinite } + +/* flow: the rainbow slides one whole tile per lap, so the mark itself never + moves, only its colours travel along it. A line 8 s a lap (tile 114 px, one + tab), an edge 7 s (tile = the edge's height). */ +@keyframes p-mark-flow-x { + from { background-position: 0 0 } + to { background-position: var(--tile-x, 114px) 0 } +} +@keyframes p-mark-flow-y { + from { background-position: 0 0 } + to { background-position: 0 var(--tile-y, 32px) } +} +.p-mark-flow-x { + background-size: var(--tile-x, 114px) 100%; + background-repeat: repeat-x; + animation: p-mark-flow-x 8s linear infinite; +} +.p-mark-flow-y { + background-size: 100% var(--tile-y, 32px); + background-repeat: repeat-y; + animation: p-mark-flow-y 7s linear infinite; +} + +/* ride: Full's working tab, a 2 px bar riding along its foot in the name's + own ink (`--mark-ink`), 40% of the tab, 1.4 s. */ +@keyframes p-mark-ride { + from { background-position: 200% 0 } + to { background-position: -100% 0 } +} +.p-mark-ride { + background: linear-gradient(90deg, transparent 0 30%, var(--mark-ink) 30% 70%, transparent 70%) no-repeat; + background-size: 200% 100%; + animation: p-mark-ride 1.4s cubic-bezier(.45, 0, .55, 1) infinite; +} + +/* spin: Ring's spinner beside the name, the resume ring's own second. */ +@keyframes p-mark-spin { + to { transform: rotate(360deg) } +} +.p-mark-spin { animation: p-mark-spin 1s linear infinite } + +/* REDUCED MOTION: everything still. The run and the ride are a full line, a + working edge rests full, the question fully on, the rainbow stands still, + the ring a still arc with two coloured sides. `!important` and a class + selector, so the window's own "*" rule (a 0.01 ms single run, which would + END a grow at empty) never reaches a mark. */ +@media (prefers-reduced-motion: reduce) { + .p-agent-run, + .p-mark-grow, + .p-mark-breathe, + .p-mark-flow-x, + .p-mark-flow-y, + .p-mark-ride, + .p-mark-spin { + animation: none !important; + } + .p-agent-run { left: 0; width: 100% } + .p-mark-grow { transform: none } + .p-mark-breathe { opacity: 1 } + .p-mark-ride { background: var(--mark-ink) } + .p-mark-spin { border-right-color: var(--mark-c) } +} diff --git a/core/renderer/styles/marks.test.ts b/core/renderer/styles/marks.test.ts new file mode 100644 index 0000000..e6dff21 --- /dev/null +++ b/core/renderer/styles/marks.test.ts @@ -0,0 +1,36 @@ +import { readFileSync } from 'fs' +import { join } from 'path' +import { describe, expect, it } from 'vitest' +import { MOTION_CLASS } from '../components/markClasses' + +// Every motion the resolver can name has its class here, its keyframes, and a +// reduced-motion rule: a missing keyframe is a still mark nobody notices, and a +// mark left to the window's "*" rule ends a grow at empty. + +const css = readFileSync(join(__dirname, 'marks.css'), 'utf8') +const reduced = css.slice(css.indexOf('@media (prefers-reduced-motion: reduce)')) + +describe('the marks stylesheet (#143)', () => { + it('has a class and keyframes for every motion', () => { + for (const [motion, cls] of Object.entries(MOTION_CLASS)) { + if (!cls) continue + for (const c of cls.split(' ')) { + expect(css, `${motion}: .${c}`).toMatch(new RegExp(`\\.${c}\\s*\\{[^}]*animation:\\s*${c}`)) + expect(css, `${motion}: @keyframes ${c}`).toContain(`@keyframes ${c}`) + } + } + }) + + it('stills every one of them under reduced motion, with !important', () => { + for (const cls of Object.values(MOTION_CLASS).filter(Boolean)) { + for (const c of (cls as string).split(' ')) expect(reduced, c).toContain(`.${c}`) + } + expect(reduced).toMatch(/animation:\s*none\s*!important/) + }) + + it('keeps a working edge FULL, the run and the ride whole, the question on', () => { + expect(reduced).toMatch(/\.p-mark-grow\s*\{\s*transform:\s*none/) + expect(reduced).toMatch(/\.p-agent-run\s*\{\s*left:\s*0;\s*width:\s*100%/) + expect(reduced).toMatch(/\.p-mark-breathe\s*\{\s*opacity:\s*1/) + }) +}) diff --git a/src/renderer/src/components/TabStrip.tsx b/src/renderer/src/components/TabStrip.tsx index 02dbff1..3fd7dc1 100644 --- a/src/renderer/src/components/TabStrip.tsx +++ b/src/renderer/src/components/TabStrip.tsx @@ -1,5 +1,6 @@ import { useEffect, + useLayoutEffect, useRef, useState, type JSX, @@ -8,14 +9,26 @@ import { } from 'react' import { tabLabels, type Tab } from '../lib/tabs' import { DictationTabMark } from '@core/renderer/components/DictationTabMark' -import { useAgentDoneOn, useAgentFailedOn, useAgentIndicator, useAgentQuestionOn } from '@core/renderer/lib/termLook' +import { TabMark } from '@core/renderer/components/TabMark' +import { motionClass } from '@core/renderer/components/markClasses' +import { + useAgentDoneOn, + useAgentFailedOn, + useAgentIndicator, + useAgentQuestionOn, + useAgentRainbow +} from '@core/renderer/lib/termLook' import { failedLabel } from '@core/renderer/lib/agentHookSignal' import { useAgentColors } from '@core/renderer/lib/agentColors' -import { inkOn } from '@core/renderer/lib/colour' +import { composite } from '@core/renderer/lib/colour' +import { markPalette } from '@core/renderer/lib/markPalette' +import { resolveTabMark, type MarkColour, type MarkState } from '@core/renderer/lib/tabMark' import { pinnedRoots, plusMenuList, recentLabels, recentRoots, togglePin } from '@core/renderer/lib/recentRoots' import { ContextMenu } from './ContextMenu' import { MenuIcon } from './MenuIcon' import { useTabWidth } from '../lib/tabWidthPrefs' +import { useTabStyle } from '../lib/tabStylePrefs' +import { OVERLAP, edgeBand, ruleClip, segmentClip } from '../lib/promptGeometry' import { dropSlot, type Lane } from '../lib/tabDrop' /** @@ -116,19 +129,47 @@ export function TabStrip({ }): JSX.Element | null { const indicator = useAgentIndicator() const width = useTabWidth() + // The tab style (#143): Classic, the flat strip, or Prompt's chevrons. + const prompt = useTabStyle() === 'prompt' // The user's pick where there is one, else the theme's accent and green. - const { working: agentColor, finished: doneColor, question: questionColor, failed: failedColor } = useAgentColors() + const colours = useAgentColors() const doneOn = useAgentDoneOn() const questionOn = useAgentQuestionOn() const failedOn = useAgentFailedOn() - // Full mode fills the tab with the colour. Text biases WHITE: strict - // contrast maths picks black on a mid orange or indigo, but white on a - // saturated fill is the look; black only wins on genuinely light fills - // (contrast vs black of 12 is a ~0.55 luminance threshold). A SEE-THROUGH - // working colour (#112) is judged as laid on the strip's own opaque ground, - // where it takes whichever of the two reads better (the core's `inkOn`). - const onTint = (c: string): string => - inkOn(c, getComputedStyle(document.documentElement).getPropertyValue('--p-bg-solid').trim()) + const rainbow = useAgentRainbow() + // EVERY MARK'S COLOUR (#143), from what the window paints NOW: App paints + // the chrome synchronously on every look change, before this re-renders. + // Held to 3:1 on every ground a mark can sit on: the strip, and under Prompt + // both segment shades (composited, since on glass they are see-through). + const root = getComputedStyle(document.documentElement) + const read = (name: string, fallback: string): string => root.getPropertyValue(name).trim() || fallback + const solidGround = read('--p-bg-solid', '#0b0b0f') + const grounds = prompt + ? [solidGround, composite(read('--p-seg', solidGround), solidGround), composite(read('--p-seg-on', solidGround), solidGround)] + : [solidGround] + const palette = markPalette({ colours, grounds, solidGround, text: read('--p-text', '#e7e7ee'), rainbow }) + /** A mark's colour role as a CSS background: the rainbow runs along a line + * (`x`) or down a Prompt edge (`y`); the rule is the active rule's token. */ + const paint = (role: MarkColour | null, axis: 'x' | 'y'): string => { + switch (role) { + case 'working': + return palette.line.working + case 'rule': + return 'var(--p-accent-hi)' + case 'done': + return palette.line.done + case 'rainbow': + return axis === 'x' ? palette.rainbowX : palette.rainbowY + case 'question': + return palette.line.question + case 'failed': + return palette.line.failed + case 'badge': + return palette.fill.done + default: + return 'transparent' + } + } // A tab being carried (#71 follow-up): the strip animates it rather than // drawing a hairline - the tab lifts out and its neighbours slide across to // open the gap it would drop into, which is what "picked up" looks like. @@ -259,6 +300,20 @@ export function TabStrip({ if (dropAt <= carry.from && i >= dropAt && i < carry.from) return carry.width return 0 } + // The strip's height, for the Prompt rule's cut along the slant and the + // rainbow's tile down an edge: 32 px under a title bar, the title row's + // height without one (#91). + const [stripH, setStripH] = useState(32) + const hasTabs = tabs.length > 0 + useLayoutEffect(() => { + const el = strip.current + if (!el) return + const measure = (): void => setStripH(el.clientHeight || 32) + measure() + const ro = new ResizeObserver(measure) + ro.observe(el) + return () => ro.disconnect() + }, [hasTabs]) if (!tabs.length) return null const labels = tabLabels(tabs) // Middle-click closes, the way every tab strip does. `auxclick` rather than @@ -296,187 +351,63 @@ export function TabStrip({ // icon slot and an X, each with a cursor of its own, and letting them // answer for themselves made it flicker under the moving pointer. data-tab-strip + data-tab-style={prompt ? 'prompt' : 'classic'} className={`${dragInFlight ? 'no-drag' : 'drag'} p-styled-font relative flex ${inTitleRow ? 'min-w-0 flex-1' : 'h-8 shrink-0 border-b border-[var(--p-divider)]'} items-stretch gap-0 overflow-x-auto bg-[var(--p-tabs)] pr-1 text-[12px] transition-[background-color,border-color] duration-[550ms] [transition-timing-function:cubic-bezier(.16,1,.3,1)] ${ carry?.live ? 'cursor-grabbing [&_*]:cursor-grabbing' : '' }`} > {tabs.map((t, i) => { const on = t.id === activeId - // The agent indicator: paints while the agent is genuinely working - // (the working colour) or finished behind another tab (the finished - // colour, until visited) - the whole tab filled (full) or the brain - // icon plus edge bar tinted (minimal). Idle shows nothing. - // A tab IS its shell here, so the tab's id is the session's. + // The agent's state on this tab. A tab IS its shell here, so the tab's + // id is the session's. Working shows on any tab, the one in front + // included; the other three only on a tab nobody is looking at (App's + // sets), each behind its own switch. const working = indicator !== 'off' && agentIds.has(t.id) && workingIds.has(t.id) // THE ATTENTION MARKS (2026-09-28; owner: a finished indicator, "a // static colour like a green border on the bottom ... until you click - // the tab", and "a question indicator ... blue"). A static line along - // the bottom, the same in Minimal and Full, each behind its own - // switch; a question outranks a plain finish. Full's fill is for - // WORKING alone now: the finished fill it used to have is this line. + // the tab", and "a question indicator ... blue"): a question outranks + // a plain finish. const question = questionOn && !working && questionIds.has(t.id) // FAILED (#131): a turn that ended on an error, said by Claude Code's // own hook. Under a question, over a plain finish. const failed = failedOn && !working && !question && !!failedIds?.has(t.id) const done = doneOn && !working && !question && !failed && doneIds.has(t.id) - const mark = question ? questionColor : failed ? failedColor : done ? doneColor : null - const tint = working ? agentColor : mark - const loud = working && indicator === 'full' - return ( -
Dynamic (the DEFAULT) puts back the - // strip from before, each tab as wide as its name up to 14rem - // (the cap is on the label), shrinking only when out of room. - data-tab-fixed={width === 'fixed' || undefined} - data-tab-dynamic={width === 'dynamic' || undefined} - className={`no-drag group relative flex items-center gap-1.5 border-r border-[color:var(--p-divider)] px-2.5 transition-colors ${ - // Dynamic has a floor too (owner, 2026-09-28: "so it's not too - // small when there's a tab with only one letter ... maybe like 4 - // letters"): never narrower than its own content, whose label - // is at least four characters wide. - width === 'fixed' ? 'min-w-[64px] flex-[0_1_114px]' : 'min-w-min shrink' - } ${ - loud - ? '' - : on - ? // THE SECONDARY COLOUR (owner, 2026-09-03): the strip, the - // tabs at rest and this one are all --p-tabs, one surface - // with the title bar. --p-tab-active is kept as a token - // (it equals --p-tabs on an opaque window and paints - // nothing on an acrylic one, where a second coat would be - // a fill by accident) and the active tab is told by its - // ink, not a fill. - 'bg-[var(--p-tab-active)] text-[var(--p-text)]' - : // --p-hover, not a white film: the theme may be a light - // one, where white over paper is no hover at all. - 'text-[var(--p-dim)] hover:bg-[var(--p-hover)] hover:text-[var(--p-text)]' - }`} - style={{ - ...(loud && tint ? { background: tint, color: onTint(tint) } : {}), - transform: `translateX(${carry?.id === t.id ? carry.dx : slide(i)}px)`, - zIndex: carry?.id === t.id ? 5 : undefined, - // What you carry is a COPY - Chromium's own drag snapshot - so - // the tab you picked up stays exactly where it was, solid, and - // the strip only really rearranges when the drop lands. The - // neighbours sliding open the gap are the preview. - transition: - carry && carry.id !== t.id ? 'transform 170ms cubic-bezier(.23,1,.32,1)' : undefined - }} - // The WHOLE tab is the click target, not just the label: the - // padding, the icon slot and the slack around a short name all - // pick the tab. The close button stops propagation to opt out. - onClick={() => { - // A press that travelled is a drag, not a pick. - if (dragging.current) { - dragging.current = false - return - } - onPick(t.id) - }} - onAuxClick={(e) => auxClose(e, t.id)} - // The tab's own menu (2026-08-30). The wrapper owns it, not the - // inner button: the padding and the icon slot are part of the - // target, the same reasoning the click handler gives. A right - // click cannot start a carry - onTabPointerDown ignores button 2. - onContextMenu={(e) => { - e.preventDefault() - setTabMenu({ - x: e.clientX, - y: e.clientY, - id: t.id, - cwd: t.kind === 'settings' ? '' : t.cwd - }) - }} - // Tabs reorder by dragging (#70): the half of the tab the pointer - // is over decides which side of it the dragged tab lands. - data-tab - onPointerDown={(e) => onTabPointerDown(e, t.id, i)} - onPointerMove={onTabPointerMove} - onPointerUp={onTabPointerUp} - onPointerCancel={() => carry && endDrag()} - > - {/* The active mark: an accent rule along the top. It yields while - the working fill is up - two signals on one tab would fight. */} - {on && !loud && } - {/* Minimal mark: a bar running along the BOTTOM edge while the - agent works, the way a loading tab reads. It sits under the - label rather than beside it, so a narrow tab loses none of its - name to it. */} - {mark && ( - - )} - {working && indicator === 'minimal' && ( - - - - )} - {/* A permanent icon slot: the brain appears in it while the - agent works or waits unseen, and it is transparent otherwise - - so the tab NEVER changes width while an agent runs. - FULL ALONE (owner, 2026-09-09). Minimal is the animated bar and - nothing else: it was the bar AND the brain, which is two marks - for the one thing minimal says, and the louder of the two is - the icon - so the quiet volume read almost as loud as the other - one. A mode that can never fill the slot does not reserve it - either, which is why the slot itself goes with the icon; the - widths only settle differently, and only when the setting is - deliberately changed. */} - {indicator === 'full' && ( - - {tint && ( - - - - )} - - )} + const state: MarkState | null = working ? 'working' : question ? 'question' : failed ? 'failed' : done ? 'done' : null + // WHICH MARK, from the core's one rule (#143): the strip only draws it. + const mark = resolveTabMark({ indicator, tabStyle: prompt ? 'prompt' : 'flat', state, active: on, rainbow }) + const filled = mark.place === 'fill' && state !== null + const ink = filled ? palette.ink[state] : undefined + const first = i === 0 + const last = i === tabs.length - 1 + // FULL: ONE NAME COLOUR (owner, 2026-10-10: "Every tab name in Full + // uses the theme's own text colour, on every tab, idle ones included"). + // Elsewhere the tab in front is told by its brighter ink, as always. + const nameInk = on || indicator === 'full' ? 'text-[var(--p-text)]' : 'text-[var(--p-dim)] hover:text-[var(--p-text)]' + // What a flat tab's own mark draws (run, line, fill); the ring sits by + // the name, and a Prompt edge is drawn by the segment's band. + const flatMark = + state && (mark.place === 'run' || mark.place === 'line' || mark.place === 'fill') ? ( + + ) : null + const body = ( + <> + {flatMark} {t.kind !== 'settings' && } {loadingIds?.has(t.id) && ( )} + {/* RING (#143): the spinner beside the name, only while working. */} + {mark.place === 'ring' && } + + ) + // The handlers and the carry are the same in both styles: the whole + // tab is the click target, a press that travels is a drag (#70). + const shared = { + 'data-agent': state ? indicator : undefined, + 'data-agent-state': state ?? undefined, + 'data-agent-present': agentIds.has(t.id) ? '' : undefined, + // EVERY TAB IS ONE WIDTH (owner, 2026-09-21), AND IT IS A SETTING + // (#56): Dynamic, the default, each tab as wide as its name up to + // 14rem, or Fixed, all one width, shrinking equally when out of room. + 'data-tab-fixed': width === 'fixed' || undefined, + 'data-tab-dynamic': width === 'dynamic' || undefined, + 'data-tab-active': on || undefined, + onClick: () => { + // A press that travelled is a drag, not a pick. + if (dragging.current) { + dragging.current = false + return + } + onPick(t.id) + }, + onAuxClick: (e: MouseEvent) => auxClose(e, t.id), + // The tab's own menu (2026-08-30). The wrapper owns it, not the inner + // button: the padding and the mark slots are part of the target. A + // right click cannot start a carry: onTabPointerDown ignores button 2. + onContextMenu: (e: MouseEvent) => { + e.preventDefault() + setTabMenu({ x: e.clientX, y: e.clientY, id: t.id, cwd: t.kind === 'settings' ? '' : t.cwd }) + }, + 'data-tab': true, + onPointerDown: (e: PointerEvent) => onTabPointerDown(e, t.id, i), + onPointerMove: onTabPointerMove, + onPointerUp: onTabPointerUp, + onPointerCancel: () => carry && endDrag() + } + const motion = { + transform: `translateX(${carry?.id === t.id ? carry.dx : slide(i)}px)`, + zIndex: carry?.id === t.id ? 5 : undefined, + // What you carry is a COPY - Chromium's own drag snapshot - so the + // tab you picked up stays exactly where it was, solid, and the strip + // only really rearranges when the drop lands. The neighbours sliding + // open the gap are the preview. + transition: carry && carry.id !== t.id ? 'transform 170ms cubic-bezier(.23,1,.32,1)' : undefined + } + if (prompt) { + // PROMPT (#143): a chevron segment whose arrow EDGE is the mark. The + // wrapper is the flex item and the carry; it lets the pointer + // through, so a click lands on the segment's own SHAPE and the notch + // belongs to the tab whose arrow fills it. The band sits BEHIND the + // segment and is cut by this segment and the next one, so it fills + // the gap flush, tip to both corners (lib/promptGeometry). + const band = edgeBand(last) + const rule = ruleClip(stripH, first) + return ( +
+ {mark.place === 'edge' && state && ( + + + + )} +
+ {body} +
+ {/* The tab in front's top rule, cut along the slant and running + half a pixel past it, above the segment and its band. */} + {on && ( + + )} +
+ ) + } + return ( +
+ {/* The active mark: an accent rule along the top. Full never fills + the tab in front (#143), so the rule is always there. */} + {on && } + {body}
) })} diff --git a/src/renderer/src/components/settings/AppearancePage.tsx b/src/renderer/src/components/settings/AppearancePage.tsx index 7ed455b..02bf2b8 100644 --- a/src/renderer/src/components/settings/AppearancePage.tsx +++ b/src/renderer/src/components/settings/AppearancePage.tsx @@ -10,6 +10,7 @@ import { customTermTheme, onTermLookChange, termThemeId, useTermAcrylic } from ' import { presetAccent, resolveTermTheme } from '@core/renderer/lib/termTheme' import { setWindowEdges, useWindowEdges } from '../../lib/edgesPrefs' import { setTabWidth, useTabWidth, type TabWidth } from '../../lib/tabWidthPrefs' +import { setTabStyle, useTabStyle, type TabStyle } from '../../lib/tabStylePrefs' import { setTitleBarMode, useTitleBarMode } from '../../lib/titleBarPrefs' import { setWindowAccent, useWindowAccent } from '../../lib/accentPrefs' import { onWindowBackgroundChange, setWindowBackground, useWindowBackground, windowBackground } from '../../lib/backgroundPrefs' @@ -50,6 +51,12 @@ const TAB_WIDTH_OPTIONS: Array<{ id: TabWidth; name: string }> = [ { id: 'fixed', name: 'Fixed' } ] +// Classic first: it is the default, the strip as it always was. +const TAB_STYLE_OPTIONS: Array<{ id: TabStyle; name: string }> = [ + { id: 'classic', name: 'Classic' }, + { id: 'prompt', name: 'Prompt' } +] + /** What the THEME in force would give the window, which is what a swatch * shows while nothing is chosen: computed the way the window computes it * (chromeTokens), not read back off the page, so it cannot lag a repaint. @@ -162,8 +169,10 @@ function WindowColours(): JSX.Element { export function AppearancePage(): JSX.Element { const edges = useWindowEdges() const width = useTabWidth() + const style = useTabStyle() const titleBar = useTitleBarMode() const tab = appOpt('tab-width') + const shape = appOpt('tab-style') const bar = appOpt('title-bar') const edge = appOpt('window-edges') return ( @@ -172,6 +181,10 @@ export function AppearancePage(): JSX.Element { + {/* The tab style (#143): the flat strip, or Prompt's chevrons. */} + + + {/* A switch over the same store the segmented control wrote: on is `shown`, the default, the window as it always was (#91). */} diff --git a/src/renderer/src/components/settings/appOptions.test.ts b/src/renderer/src/components/settings/appOptions.test.ts index ac29a05..84949c8 100644 --- a/src/renderer/src/components/settings/appOptions.test.ts +++ b/src/renderer/src/components/settings/appOptions.test.ts @@ -4,12 +4,13 @@ import { describe, expect, it } from 'vitest' import { DIAGNOSTICS_OPTIONS } from '@core/renderer/settings/diagnosticsOptions' import { DICTATION_OPTIONS } from '@core/renderer/settings/dictationOptions' import { HELP_OPTIONS } from '@core/renderer/settings/helpOptions' +import { MARK_OPTIONS } from '@core/renderer/settings/markOptions' import { isSettingIcon } from '@core/renderer/settings/layout/icons' import { TERMINAL_OPTIONS } from '@core/renderer/settings/options' import { APP_OPTIONS, APP_SECTIONS } from './appOptions' import { ROW_ORDER, SETTINGS_PAGES, settingsIndex } from './settingsIndex' -const CORE = [...TERMINAL_OPTIONS, ...DICTATION_OPTIONS, ...HELP_OPTIONS, ...DIAGNOSTICS_OPTIONS] +const CORE = [...TERMINAL_OPTIONS, ...MARK_OPTIONS, ...DICTATION_OPTIONS, ...HELP_OPTIONS, ...DIAGNOSTICS_OPTIONS] describe("this app's own settings rows", () => { it('have unique ids, none of them a core row', () => { @@ -32,6 +33,7 @@ describe("this app's own settings rows", () => { expect(APP_OPTIONS.map((o) => `${o.id}=${Array.isArray(o.store) ? o.store.join('+') : String(o.store)}`)).toMatchInlineSnapshot(` [ "tab-width=prism.window.tabWidth", + "tab-style=prism.window.tabStyle", "title-bar=prism.window.titleBar", "window-edges=prism.window.edges", "window-background=prism.window.background", @@ -70,9 +72,19 @@ describe('Find a setting', () => { expect(at['window-accent']).toBe('appearance/Theme') expect(at['term-shell']).toBe('terminal/Shell') expect(at['taskbar-badge']).toBe('agents/Tab marks') + expect(at['agent-rainbow']).toBe('agents/Tab marks') + expect(at['tab-style']).toBe('appearance/Window') expect(at['agent-hooks']).toBe('agents/Claude Code') expect(at['dictation-enabled']).toBe('dictation/') expect(at['app-version']).toBe('about/') expect(at['diag-verbose']).toBe('diagnostics/') }) + + it('finds the tab style by its shape and puts it right after Tab width (#143)', () => { + const ids = settingsIndex(true).map((e) => e.id) + expect(ids.indexOf('tab-style')).toBe(ids.indexOf('tab-width') + 1) + expect(ids.indexOf('agent-rainbow')).toBe(ids.indexOf('agent-done-on') + 1) + const style = settingsIndex(true).find((e) => e.id === 'tab-style') + expect(style?.keywords).toContain('chevron') + }) }) diff --git a/src/renderer/src/components/settings/appOptions.ts b/src/renderer/src/components/settings/appOptions.ts index 75b1446..fbe48e3 100644 --- a/src/renderer/src/components/settings/appOptions.ts +++ b/src/renderer/src/components/settings/appOptions.ts @@ -27,6 +27,7 @@ export interface AppOption { export const APP_OPTIONS: readonly AppOption[] = [ { id: 'tab-width', label: 'Tab width', sub: 'Sized to the name, or all equal.', section: 'window', page: 'appearance', icon: 'tabs', keywords: 'size wide narrow equal fixed dynamic', store: ['prism.window.tabWidth'] }, + { id: 'tab-style', label: 'Tab style', sub: 'Flat tabs, or arrows like a prompt.', section: 'window', page: 'appearance', icon: 'prompt', keywords: 'chevron arrow segment shape powerline classic', store: ['prism.window.tabStyle'] }, { id: 'title-bar', label: 'Show title bar', sub: 'When off, tabs share the top row.', section: 'window', page: 'appearance', icon: 'titlebar', keywords: 'caption top frame hide hidden', store: ['prism.window.titleBar'] }, { id: 'window-edges', label: 'Panel edges', sub: 'Lines between panels and around the window.', section: 'window', page: 'appearance', icon: 'edges', keywords: 'border lines hairline outline faint solid', store: ['prism.window.edges'] }, { id: 'window-background', label: 'Background colour', sub: 'Behind the text in window and terminal.', section: 'theme', page: 'appearance', icon: 'viewer', keywords: 'ground backdrop color transparent alpha opacity', store: ['prism.window.background'] }, diff --git a/src/renderer/src/components/settings/settingsIndex.ts b/src/renderer/src/components/settings/settingsIndex.ts index a53e011..21bb013 100644 --- a/src/renderer/src/components/settings/settingsIndex.ts +++ b/src/renderer/src/components/settings/settingsIndex.ts @@ -38,9 +38,9 @@ const PAGE_OF: Record = { /** Every row in the order the pages draw them, which is the order Find a * setting lists matches in. A test holds this to the lists. */ export const ROW_ORDER = [ - 'tab-width', 'title-bar', 'window-edges', 'term-theme', 'window-background', 'window-accent', 'term-acrylic', + 'tab-width', 'tab-style', 'title-bar', 'window-edges', 'term-theme', 'window-background', 'window-accent', 'term-acrylic', 'term-shell', 'newtab-mode', 'explorer-verb', 'term-font-family', 'term-font', 'help-enabled', - 'agent-indicator', 'agent-done-on', 'agent-question-on', 'agent-failed-on', 'taskbar-badge', 'agent-hooks', + 'agent-indicator', 'agent-done-on', 'agent-rainbow', 'agent-question-on', 'agent-failed-on', 'taskbar-badge', 'agent-hooks', 'agent-color', 'agent-done-color', 'agent-question-color', 'dictation-enabled', 'dictation-mode', 'dictation-hotkey', 'dictation-mic', 'dictation-language', 'dictation-pause-media', 'dictation-sounds', 'dictation-model', 'dictation-gpu', @@ -51,7 +51,7 @@ export const ROW_ORDER = [ /** The index Find a setting reads: the core's rows drawn here, and this * app's own, in page order. */ export function settingsIndex(nvidia: boolean): SettingsIndexEntry[] { - const core = coreSettingsIndex({ pageOf: (s) => PAGE_OF[s], nvidia, diagnostics: true }) + const core = coreSettingsIndex({ pageOf: (s) => PAGE_OF[s], nvidia, diagnostics: true, marks: true }) const own: SettingsIndexEntry[] = APP_OPTIONS.map((o) => ({ id: o.id, page: o.page, diff --git a/src/renderer/src/index.css b/src/renderer/src/index.css index 43fede0..1feead6 100644 --- a/src/renderer/src/index.css +++ b/src/renderer/src/index.css @@ -1,4 +1,8 @@ @import 'tailwindcss'; +/* The tab marks' motion (#143): the running bar, the Prompt edge's grow, the + question's breathe, the rainbow's flow, Full's ride and Ring's spin, with + their reduced-motion rules. The core's file, written once for both apps. */ +@import '../../../core/renderer/styles/marks.css'; /* THE CORE IS OUTSIDE THE FOLDER TAILWIND SCANS (#20). Tailwind looks for class names from the renderer root down, and the terminal lives in core/ at the repo root, so a utility used ONLY there is never generated - silently. It @@ -11,20 +15,6 @@ color-scheme: dark; } -/* The MINIMAL agent indicator (owner pick, 2026-08-23): an indeterminate bar - running along the bottom edge of a tab while its agent works - the way a - loading browser tab reads. Working only; the finished colour belongs to the - full indicator alone. */ -@keyframes p-agent-run { - 0% { left: -42% } - 100% { left: 100% } -} -.p-agent-run { animation: p-agent-run 1.25s ease-in-out infinite } -@media (prefers-reduced-motion: reduce) { - /* No travelling bar for anyone who asked for stillness: the edge simply - carries the colour instead. */ - .p-agent-run { left: 0; width: 100%; animation: none } -} html, body, #root { @@ -64,6 +54,8 @@ body, --p-track: #5a5a5e; --p-sel-bg: #4aa5f0; --p-tree-folder: #71b6f0; + --p-seg: #141418; + --p-seg-on: #232328; /* Shape and type. No theme changes these, so they live here and nowhere else. --p-radius-sm is SHARED, not styled: small controls keep one shape diff --git a/src/renderer/src/lib/chromeTheme.opacity.test.ts b/src/renderer/src/lib/chromeTheme.opacity.test.ts index 3fa4467..28db682 100644 --- a/src/renderer/src/lib/chromeTheme.opacity.test.ts +++ b/src/renderer/src/lib/chromeTheme.opacity.test.ts @@ -11,10 +11,12 @@ import { TERM_PRESETS, presetAccent, resolveTermTheme } from '@core/renderer/lib const THEMES = ['pt-default', 'prism', 'github', 'dracula', 'paper'] const toAlpha = (n: number): number => Math.round((n / 100) * 255) / 255 /** The tokens as the old code published them: `--p-accent-solid` is new in - * #114, and for an opaque accent it is `--p-accent` itself (asserted here). */ + * #114, and for an opaque accent it is `--p-accent` itself (asserted here); + * the Prompt segments' two (#143) are new tokens, not a change to any old one. */ const before = (vars: Record): Record => { - const { ['--p-accent-solid']: solid, ...rest } = vars + const { ['--p-accent-solid']: solid, ['--p-seg']: seg, ['--p-seg-on']: segOn, ...rest } = vars expect(solid).toBe(vars['--p-accent']) + expect(seg && segOn).toBeTruthy() return rest } diff --git a/src/renderer/src/lib/chromeTheme.test.ts b/src/renderer/src/lib/chromeTheme.test.ts index 6d6c6a4..96cecae 100644 --- a/src/renderer/src/lib/chromeTheme.test.ts +++ b/src/renderer/src/lib/chromeTheme.test.ts @@ -243,3 +243,26 @@ describe('a see-through accent', () => { } }) }) + +describe('the Prompt segments (#143)', () => { + it('are the text over the strip at 4% and 11%, solid on an opaque window', () => { + const v = chromeTokens(resolveTermTheme('prism')).vars + expect(v['--p-seg']).toBe(v['--p-side-flat']) + expect(contrastRatio(v['--p-seg-on'], v['--p-bg'])).toBeGreaterThan(contrastRatio(v['--p-seg'], v['--p-bg'])) + }) + + it('on glass are the text at that alpha, so a segment is one step over the sheet', () => { + const v = chromeTokens(resolveTermTheme('prism'), 0.6).vars + expect(v['--p-seg']).toBe(v['--p-text'] + '0a') + expect(v['--p-seg-on']).toBe(v['--p-text'] + '1c') + expect(composite(v['--p-seg'], v['--p-bg-solid'])).toBe(chromeTokens(resolveTermTheme('prism')).vars['--p-seg']) + }) + + it('match the :root fallbacks index.css carries for the default prism preset', async () => { + const { readFileSync } = await import('node:fs') + const { join } = await import('node:path') + const css = readFileSync(join(__dirname, '..', 'index.css'), 'utf8') + const v = chromeTokens(resolveTermTheme('prism')).vars + for (const name of ['--p-seg', '--p-seg-on']) expect(css).toContain(`${name}: ${v[name]};`) + }) +}) diff --git a/src/renderer/src/lib/chromeTheme.ts b/src/renderer/src/lib/chromeTheme.ts index d89851e..a3f1f8e 100644 --- a/src/renderer/src/lib/chromeTheme.ts +++ b/src/renderer/src/lib/chromeTheme.ts @@ -25,6 +25,9 @@ const FALLBACK_FG = '#e7e7ee' // on the same colour, and the active tab is told from the rest by its INK. const TEXT_FLOOR = 4.5 const QUIET_FLOOR = 3 +/** How much text a Prompt segment carries over the strip, and the tab in front. */ +export const SEG_MIX = 0.04 +export const SEG_ON_MIX = 0.11 /** Every COLOUR token the components read. Radius, font and size tokens are * index.css's, since no theme changes them. */ @@ -53,7 +56,9 @@ export const CHROME_COLOUR_TOKENS = [ '--p-control', '--p-preview', '--p-sel-bg', - '--p-tree-folder' + '--p-tree-folder', + '--p-seg', + '--p-seg-on' ] as const export interface ChromeTokens { @@ -225,7 +230,13 @@ export function chromeTokens( '--p-sel-bg': selBg, // The folder in the + menu's list of places: the accent's brighter cut, // already held to the non-text floor on the panel it is drawn on. - '--p-tree-folder': accentHi + '--p-tree-folder': accentHi, + // THE PROMPT SEGMENTS (#143): the theme's text laid on the strip at 4%, and + // at 11% for the tab in front (the approved mockups' values). Solid on an + // opaque window; on glass the same text at that ALPHA, so a segment is the + // same step over the strip's sheet and never a second coat of the ground. + '--p-seg': glass ? fg + alphaHex(SEG_MIX) : mixHex(bg, fg, SEG_MIX), + '--p-seg-on': glass ? fg + alphaHex(SEG_ON_MIX) : mixHex(bg, fg, SEG_ON_MIX) } return { mode, vars } } diff --git a/src/renderer/src/lib/promptGeometry.test.ts b/src/renderer/src/lib/promptGeometry.test.ts new file mode 100644 index 0000000..ad5a615 --- /dev/null +++ b/src/renderer/src/lib/promptGeometry.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest' +import { ARROW, GAP, OVERLAP, SEAM, TUCK, edgeBand, ruleClip, segmentClip } from './promptGeometry' + +// Checked against the mockup's placeEdges(): band left = right - ARROW - TUCK, +// width = ARROW + o, o = TUCK + GAP + (next ? TUCK : 0). +describe('the Prompt geometry (#143)', () => { + it('overlaps two segments by the arrow less the gap: 9.5 px', () => { + expect(OVERLAP).toBe(9.5) + expect([ARROW, GAP, TUCK, SEAM]).toEqual([12, 2.5, 1, 0.5]) + }) + + it('places a band from 1 px inside this segment to 1 px inside the next', () => { + const b = edgeBand(false) + expect(b.o).toBe(4.5) + expect(b.width).toBe(16.5) + // A 122 px segment: the mockup's left is 122 - 12 - 1 = 109, so right sits at 125.5. + const segment = 122 + // CSS `right` is measured inward, so a negative one reaches past the box. + const left = segment - b.right - b.width + expect(left).toBe(segment - ARROW - TUCK) + expect(segment - b.right).toBe(125.5) + // Along the top row the gap runs from this arrow's corner (segment - 12) to + // the next segment's start (segment - 9.5); the band covers it with a TUCK + // to spare at each end. + expect(left + TUCK).toBe(segment - ARROW) + expect(left + b.o - TUCK).toBe(segment - OVERLAP) + }) + + it('stops the last band on the notch line a next segment would have', () => { + const b = edgeBand(true) + expect(b.o).toBe(3.5) + expect(b.width).toBe(15.5) + expect(b.clip).toBe('polygon(0 0, 3.5px 0, 15.5px 50%, 3.5px 100%, 0 100%, 12px 50%)') + }) + + it('cuts the active rule along the slant, half a pixel past it, no notch on the first', () => { + const r = ruleClip(32, false) + expect(r.d).toBe(1.5) + expect(r.clip).toBe('polygon(0 0, calc(100% - 11.5px) 0, calc(100% - 10px) 100%, 1.5px 100%)') + expect(ruleClip(32, true).clip.endsWith(', 0px 100%)')).toBe(true) + }) + + it('gives the first segment no notch', () => { + expect(segmentClip(true)).not.toContain('12px 50%') + expect(segmentClip(false)).toContain('12px 50%') + }) +}) diff --git a/src/renderer/src/lib/promptGeometry.ts b/src/renderer/src/lib/promptGeometry.ts new file mode 100644 index 0000000..afa65ef --- /dev/null +++ b/src/renderer/src/lib/promptGeometry.ts @@ -0,0 +1,67 @@ +/** + * THE PROMPT TAB STYLE'S GEOMETRY (#143), MEASURED in the approved mockup + * (research/prism-terminal/mockups/2026-10-10-prompt-style, `placeEdges()`). + * + * Each segment's arrow is 12 px deep; the next segment's notch is PARALLEL to + * it, and the two overlap by 9.5 px, so the ground-coloured chevron between + * them, the EDGE, is 2.5 px across (2 px square to the slant, the top rule's + * own width). The edge is the agent mark: a band drawn BEHIND the segments, + * from 1 px inside this segment to 1 px inside the next, so both segments' + * own shapes cut it and it fills the gap flush, from the tip to both corners. + * + * Pure numbers and CSS strings: the strip draws them, a test holds them. + */ + +/** How deep a segment's arrow (and the next one's notch) is. */ +export const ARROW = 12 +/** The ground-coloured gap between two segments, across. */ +export const GAP = 2.5 +/** How far the edge band reaches under each segment it meets. */ +export const TUCK = 1 +/** How far the active tab's top rule runs past the arrow's slant. Inside the + * segment its clip left a one-pixel darker hairline where rule met band + * (MEASURED in the mockup, about 80% brightness at 4x); half a pixel over + * covers it and reads as the rule's own anti-aliased end. */ +export const SEAM = 0.5 +/** The overlap of two neighbouring segments: the arrow less the gap. */ +export const OVERLAP = ARROW - GAP + +/** A segment's shape: the arrow on the right and, unless it is the first, the + * notch on the left that the previous arrow sits in. */ +export function segmentClip(first: boolean): string { + return first + ? `polygon(0 0, calc(100% - ${ARROW}px) 0, 100% 50%, calc(100% - ${ARROW}px) 100%, 0 100%)` + : `polygon(0 0, calc(100% - ${ARROW}px) 0, 100% 50%, calc(100% - ${ARROW}px) 100%, 0 100%, ${ARROW}px 50%)` +} + +/** + * The edge band of a segment, placed in the SEGMENT's own box: it starts + * `ARROW + TUCK` in from the segment's right end (1 px inside its arrow) and + * reaches `o` past the arrow line, 1 px inside the next segment's notch. The + * last segment has no neighbour: its band stops exactly on the notch line a + * next one would have. + */ +export function edgeBand(last: boolean): { right: number; width: number; o: number; clip: string } { + const o = TUCK + GAP + (last ? 0 : TUCK) + return { + // From the segment's right edge, the band's right edge sits o - TUCK out. + right: -(o - TUCK), + width: ARROW + o, + o, + clip: `polygon(0 0, ${o}px 0, ${o + ARROW}px 50%, ${o}px 100%, 0 100%, ${ARROW}px 50%)` + } +} + +/** + * The tab in front's 2 px top rule, cut along the arrow's own slant at its + * right end and the notch's at its left (none on the first segment), running + * SEAM past the slant. `d` is the slant's run over the rule's 2 px. + */ +export function ruleClip(height: number, first: boolean): { d: number; clip: string } { + const d = (ARROW * 2) / (height / 2) + const l = first ? 0 : d + return { + d, + clip: `polygon(0 0, calc(100% - ${ARROW - SEAM}px) 0, calc(100% - ${ARROW - d - SEAM}px) 100%, ${l}px 100%)` + } +} diff --git a/src/renderer/src/lib/tabDrop.test.ts b/src/renderer/src/lib/tabDrop.test.ts index 0bec6c2..ea17619 100644 --- a/src/renderer/src/lib/tabDrop.test.ts +++ b/src/renderer/src/lib/tabDrop.test.ts @@ -44,3 +44,32 @@ describe('dropSlot', () => { expect(dropSlot([], 0, 40)).toBe(0) }) }) + +// PROMPT TABS OVERLAP (#143): each segment's arrow sits 9.5 px into the next +// one's notch, so the boxes the drag freezes overlap. The slots stay right. +describe('dropSlot with Prompt segments overlapping by 9.5 px', () => { + const overlapping = (...widths: number[]): Lane[] => { + let x = 0 + return widths.map((width) => { + const lane = { left: x, width, mid: x + width / 2 } + x += width - 9.5 + return lane + }) + } + const l = overlapping(122, 122, 122) + + it('stays put without a move, and inside the overlap', () => { + expect(dropSlot(l, 1, 0)).toBe(1) + expect(dropSlot(l, 1, -9.5)).toBe(1) + expect(dropSlot(l, 1, 9.5)).toBe(1) + }) + + it('passes a neighbour once its middle is passed, either way', () => { + // Lane 1 starts at 112.5; lane 0's middle is 61, so 52 px left passes it. + expect(dropSlot(l, 1, -51)).toBe(1) + expect(dropSlot(l, 1, -52)).toBe(0) + // Its right edge (234.5) must pass lane 2's middle (286). + expect(dropSlot(l, 1, 51)).toBe(1) + expect(dropSlot(l, 1, 52)).toBe(3) + }) +}) diff --git a/src/renderer/src/lib/tabStylePrefs.test.ts b/src/renderer/src/lib/tabStylePrefs.test.ts new file mode 100644 index 0000000..cfc8485 --- /dev/null +++ b/src/renderer/src/lib/tabStylePrefs.test.ts @@ -0,0 +1,35 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { onTabStyleChange, setTabStyle, tabStyle } from './tabStylePrefs' + +describe('tabStylePrefs (#143)', () => { + beforeEach(() => localStorage.clear()) + + it('is Classic until somebody chooses, so nobody\'s window changes with the update', () => { + expect(tabStyle()).toBe('classic') + }) + + it('remembers both choices under its own key', () => { + for (const s of ['prompt', 'classic'] as const) { + setTabStyle(s) + expect(tabStyle()).toBe(s) + expect(localStorage.getItem('prism.window.tabStyle')).toBe(s) + } + }) + + it('reads a word it does not know as Classic, and never stores one', () => { + localStorage.setItem('prism.window.tabStyle', 'powerline') + expect(tabStyle()).toBe('classic') + setTabStyle('round' as never) + expect(localStorage.getItem('prism.window.tabStyle')).toBe('classic') + }) + + it('tells its listeners on a change, and stops when asked', () => { + const heard = vi.fn() + const off = onTabStyleChange(heard) + setTabStyle('prompt') + expect(heard).toHaveBeenCalledTimes(1) + off() + setTabStyle('classic') + expect(heard).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/renderer/src/lib/tabStylePrefs.ts b/src/renderer/src/lib/tabStylePrefs.ts new file mode 100644 index 0000000..845731b --- /dev/null +++ b/src/renderer/src/lib/tabStylePrefs.ts @@ -0,0 +1,37 @@ +import { useSyncExternalStore } from 'react' + +// THE TAB STYLE (#143; owner, 2026-10-10): Classic, the flat strip as it has +// always been and the DEFAULT, so nobody's window changes with the update; or +// Prompt, chevron segments whose arrow edge is the agent mark. THIS APP'S OWN +// setting: the strip is the app's shell, not the terminal's, so its key is not +// `prism.term.*`. Read defensively: anything unknown is Classic. The owner +// called the first one "Current"; a choice cannot be named Current once there +// are two, so it is Classic, one word to change if he prefers another. + +export type TabStyle = 'classic' | 'prompt' + +const KEY = 'prism.window.tabStyle' + +let listeners: Array<() => void> = [] + +export function validTabStyle(v: unknown): TabStyle { + return v === 'prompt' ? 'prompt' : 'classic' +} + +export function tabStyle(): TabStyle { + return validTabStyle(localStorage.getItem(KEY)) +} + +export function setTabStyle(style: TabStyle): void { + localStorage.setItem(KEY, validTabStyle(style)) + listeners.forEach((l) => l()) +} + +export function onTabStyleChange(cb: () => void): () => void { + listeners.push(cb) + return () => { + listeners = listeners.filter((l) => l !== cb) + } +} + +export const useTabStyle = (): TabStyle => useSyncExternalStore(onTabStyleChange, tabStyle) diff --git a/src/renderer/src/termHost.ts b/src/renderer/src/termHost.ts index 68be951..57070d2 100644 --- a/src/renderer/src/termHost.ts +++ b/src/renderer/src/termHost.ts @@ -49,6 +49,9 @@ configureTermCore({ // Nothing else owns the window, so the terminal setting switches its material. acrylic: { kind: 'window', supported: () => window.prism.acrylicSupported() }, paintsGround: true, + // THE TAB MARKS (#143; owner, 2026-10-10): this strip draws the core's + // marks, so the Agents page offers all four styles and the rainbow finish. + tabMarks: { indicators: ['off', 'minimal', 'ring', 'full'], rainbow: true }, // DICTATION (#13). A press only counts over a shell, and App already hands // the controller null when the start screen or Settings is in front, so // there is nothing further for this app to refuse. From 86567b41c213ee34bb4e1de9075ea73230f9ff06 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Sat, 10 Oct 2026 02:19:00 +0200 Subject: [PATCH 3/5] test(e2e): tab styles and indicator styles measured, no tab separators (#143) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FHHaWKR4M5QtW7Wecyuk4t --- tools/e2e/run.mjs | 444 ++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 408 insertions(+), 36 deletions(-) diff --git a/tools/e2e/run.mjs b/tools/e2e/run.mjs index b2260fb..392f235 100644 --- a/tools/e2e/run.mjs +++ b/tools/e2e/run.mjs @@ -166,11 +166,11 @@ const tabLabels = (page) => * so a row moving between pages is one line here. */ const PREF_PAGE = { - 'tab-width': 'appearance', 'title-bar': 'appearance', 'window-edges': 'appearance', 'term-theme': 'appearance', + 'tab-width': 'appearance', 'tab-style': 'appearance', 'title-bar': 'appearance', 'window-edges': 'appearance', 'term-theme': 'appearance', 'window-background': 'appearance', 'window-accent': 'appearance', 'term-acrylic': 'appearance', 'term-shell': 'terminal', 'newtab-mode': 'terminal', 'explorer-verb': 'terminal', 'term-font-family': 'terminal', 'term-font': 'terminal', 'help-enabled': 'terminal', - 'agent-indicator': 'agents', 'agent-done-on': 'agents', 'agent-question-on': 'agents', 'agent-failed-on': 'agents', + 'agent-indicator': 'agents', 'agent-done-on': 'agents', 'agent-rainbow': 'agents', 'agent-question-on': 'agents', 'agent-failed-on': 'agents', 'taskbar-badge': 'agents', 'agent-hooks': 'agents', 'agent-color': 'agents', 'agent-done-color': 'agents', 'agent-question-color': 'agents', 'dictation-enabled': 'dictation', 'dictation-mode': 'dictation', 'dictation-hotkey': 'dictation', 'dictation-mic': 'dictation', @@ -288,6 +288,106 @@ function ourSpeechServers() { } } +/** + * THE MOCKUPS' STRIP (#143): six tabs, named as the mockups name them, for the + * tab style and indicator scenarios. Returned as launch folders. + */ +function markedFolders(w) { + const root = dirname(w.alpha) + return ['PrismTerminal', 'notes', 'api', 'web', 'infra', 'docs'].map((n) => { + const d = join(root, n) + mkdirSync(d, { recursive: true }) + return d + }) +} + +/** Claude Code's hook sequence, as `agentHooks` writes it. */ +const hookSay = (state, extra = '') => `[Console]::Write([char]27 + ']777;prism-agent;state=${state}${extra}' + [char]7)` + +/** + * Put the six tabs into the mockups' states, left to right: in front and + * working, idle, working, finished, question, failed. Shells stand in for + * Claude through its hooks; the three endings land while the first tab is in + * front, so they are marks on tabs nobody is looking at. + */ +async function markStrip(page, ok) { + await until(async () => (await tabLabels(page)).length === 6, 30000) + const tab = (i) => page.locator('[data-tab]').nth(i) + await polled(page) + for (const i of [0, 2, 3, 4, 5]) { + await tab(i).click() + await typeLine(page, hookSay('working')) + } + await tab(3).click() + await typeLine(page, `Start-Sleep -Milliseconds 9000; ${hookSay('done')}`) + await tab(4).click() + await typeLine(page, `Start-Sleep -Milliseconds 8000; ${hookSay('question')}`) + await tab(5).click() + await typeLine(page, `Start-Sleep -Milliseconds 7000; ${hookSay('failed', ';kind=rate_limit')}; ${hookSay('failed')}`) + await tab(0).click() + const want = JSON.stringify(['working', null, 'working', 'done', 'question', 'failed']) + const states = () => page.evaluate(() => [...document.querySelectorAll('[data-tab]')].map((t) => t.getAttribute('data-agent-state'))) + ok(!!(await until(async () => JSON.stringify(await states()) === want, 25000, 100)), `six tabs in the mockups' states (${JSON.stringify(await states())})`) +} + +/** End with no agent mid-answer, so closing asks nothing. */ +async function quietStrip(page) { + await backToFirst(page) + const working = () => page.evaluate(() => [...document.querySelectorAll('[data-tab]')].findIndex((t) => t.getAttribute('data-agent-state') === 'working')) + for (let n = 0; n < 6; n += 1) { + const i = await working() + if (i < 0) return + await page.locator('[data-tab]').nth(i).click() + await typeLine(page, hookSay('done')) + await until(async () => (await working()) !== i, 5000, 50) + } +} + +/** Put the first tab in front, then close Settings if it is open: closed while + * in front it would hand the front to its neighbour, the last shell, which + * then counts as looked at. And say the window is not focused, as a parked + * window is not, so no click here counts as looking at a mark. */ +async function backToFirst(page) { + await page.evaluate(() => window.dispatchEvent(new Event('blur'))) + await page.locator('[data-tab]').nth(0).click() + const settings = page.locator('[data-tab]').filter({ hasText: 'Settings' }) + if ((await settings.count()) > 0) { + await settings.first().locator('[data-tab-close]').click({ force: true }) + await until(async () => (await settings.count()) === 0, 4000, 50) + } + await page.locator('[data-tab]').nth(0).click() +} + +/** Pick a segmented choice on a settings row. */ +async function setPref(page, id, seg) { + await (await gotoPref(page, id)).locator(`[data-seg="${seg}"]`).click() +} + +/** Pick a theme from the wall. */ +async function pickTheme(page, id) { + await gotoPref(page, 'term-theme') + await page.locator(`[data-term-card="${id}"]`).first().click() + await sleep(300) +} + +/** The tab strip alone, into .e2e-shots. */ +async function shotStrip(page, name) { + await sleep(150) + // Frozen as the mockups' own shots froze them (`#shot`): a working edge + // inside its full hold, a question still mostly on, the rest part way. + await page.evaluate(() => + document.getAnimations().forEach((a) => { + const n = a.animationName ?? '' + if (!/^p-(mark|agent)/.test(n)) return + const d = Number(a.effect?.getTiming().duration) || 0 + a.pause() + a.currentTime = d * (n === 'p-mark-grow' ? 0.56 : n === 'p-mark-breathe' ? 0.12 : 0.45) + }) + ) + await page.locator('[data-tab-strip]').screenshot({ path: resolve(process.cwd(), `.e2e-shots/${name}.png`) }).catch(() => {}) + await page.evaluate(() => document.getAnimations().forEach((a) => /^p-(mark|agent)/.test(a.animationName ?? '') && a.play())) +} + const scenarios = { /** New terminal opens in the user's own folder with nothing asked (the * default), Ctrl+T does the same from inside a shell, and "ask" asks. */ @@ -371,15 +471,31 @@ const scenarios = { }) ok(mark.mode === 'minimal', `the indicator is minimal out of the box (${mark.mode})`) ok(!!mark.bar && mark.bar === mark.accent, `and its line is the theme's accent (${mark.bar} vs ${mark.accent})`) - // The finished mark is Full's alone: turn it up, the way a user would. + ok(await page.evaluate(() => !document.querySelector('[data-activity]')), 'no brain icon inside a working tab any more (#143)') + // FULL (#143): every marked tab NOT in front is filled solid; the tab in + // front never is, and shows Minimal's run instead. await page.locator('[data-title-settings]').click() // On Agents, with the marks and their colours (2026-10-05). await gotoPref(page, 'agent-indicator') + ok( + (await page.locator('[data-pref="agent-indicator"] [data-seg]').allTextContents()).join('|') === 'Off|Minimal|Ring|Full', + 'the indicator offers Off, Minimal, Ring and Full' + ) await page.locator('[data-pref="agent-indicator"] [data-seg="full"]').click() + await page.locator('[data-tab]').nth(0).click() + ok( + !!(await until(() => page.evaluate(() => !!document.querySelector('[data-agent-state="working"]:not([data-tab-active]) [data-mark="fill"]')))), + 'Full fills a working tab that is not in front' + ) await page.locator('[data-tab]').nth(1).click() ok( - await until(() => page.evaluate(() => document.querySelector('[data-agent-state="working"]')?.getAttribute('data-agent') === 'full')), - 'Full fills the tab instead' + !!(await until(() => + page.evaluate(() => { + const el = document.querySelector('[data-agent-state="working"]') + return !!el && el.hasAttribute('data-tab-active') && !el.querySelector('[data-mark="fill"]') && !!el.querySelector('[data-mark="run"]') + }) + )), + 'and never the tab in front, which shows the run' ) // Walk away, then let it finish behind our back. await page.locator('[data-tab]').nth(0).click() @@ -462,9 +578,16 @@ const scenarios = { ok(!!(await until(async () => (await state(0)) === 'question', 10000, 50)), 'a question asked on a background tab marks it') const q = await page.evaluate(() => { const line = document.querySelectorAll('[data-tab]')[0].querySelector('[data-attention]') - return { kind: line?.getAttribute('data-attention'), height: line ? Math.round(line.getBoundingClientRect().height) : 0 } + return { + kind: line?.getAttribute('data-attention'), + height: line ? Math.round(line.getBoundingClientRect().height) : 0, + motion: line ? getComputedStyle(line).animationName : null + } }) ok(q.kind === 'question' && q.height === 3, `as a line along the bottom (${JSON.stringify(q)})`) + // THE GENTLE PULSE (#143; owner, 2026-10-10: "I like the gentle pulse too + // for questions. Make sure that's also applied to the minimal style"). + ok(q.motion === 'p-mark-breathe', `that breathes (${q.motion})`) await page.locator('[data-tab-strip]').screenshot({ path: resolve(process.cwd(), '.e2e-shots/attention-question.png') }).catch(() => {}) ok(!!(await until(async () => (await badge()) === '1 tab needs a look', 4000, 50)), `and the taskbar badge counts it (${await badge()})`) // AS CRISP AS THE OTHER APPS' (#108; owner, 2026-10-01, beside @@ -487,6 +610,27 @@ const scenarios = { await tab(1).click() ok(!!(await until(async () => (await state(2)) === 'done', 10000, 50)), 'an agent that finishes on a background tab marks it') ok(!!(await until(async () => (await badge()) === '1 tab needs a look', 4000, 50)), 'and is counted on the taskbar') + // THE RAINBOW FINISH (#143): the icon's colours, flowing, on by default. + const fin = () => + page.evaluate(() => { + const l = document.querySelectorAll('[data-tab]')[2].querySelector('[data-attention="done"]') + if (!l) return null + const cs = getComputedStyle(l) + return { rainbow: l.hasAttribute('data-rainbow'), image: cs.backgroundImage, colour: cs.backgroundColor, motion: cs.animationName } + }) + const rb = await fin() + ok(!!rb && rb.rainbow && rb.image.startsWith('linear-gradient') && rb.motion === 'p-mark-flow-x', `the finished line is the flowing rainbow (${JSON.stringify(rb)})`) + await page.locator('[data-tab-strip]').screenshot({ path: resolve(process.cwd(), '.e2e-shots/attention-rainbow.png') }).catch(() => {}) + await gotoPref(page, 'agent-rainbow') + const rbSwitch = page.locator('[data-pref="agent-rainbow"] [role="switch"]') + ok((await rbSwitch.getAttribute('aria-checked')) === 'true', 'Rainbow finished mark is on by default') + await rbSwitch.click() + const plain = await until(async () => { + const f = await fin() + return f && !f.rainbow && f.image === 'none' ? f : null + }, 4000, 50) + ok(!!plain && plain.motion === 'none', `switched off, the finished line is the finished colour, still (${JSON.stringify(plain)})`) + await rbSwitch.click() // Switched off, the finished mark goes, and the badge has nothing to count. await page.evaluate(() => localStorage.setItem('prism.term.agentDoneOn', '0')) await page.locator('[data-title-settings]').click() @@ -1327,6 +1471,216 @@ const scenarios = { await closeApp(app) }, + /** + * THE PROMPT TAB STYLE (#143; owner, 2026-10-10). Six tabs in the mockups' + * states: in front and working, idle, working, finished, question, failed. + * Measured: no line between tabs in either style; Prompt's segments are + * clipped and overlap by 9.5 px; a working background tab's edge band sits + * flush in the gap and grows; the tab in front wears its rule; Fixed and + * Dynamic both hold. Screenshots against the mockups. + */ + async tabStyle(ok) { + const w = world() + const { app, page } = await launch(w, { args: markedFolders(w) }) + try { + await app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].setSize(1100, 700)) + await markStrip(page, ok) + const borders = () => + page.evaluate(() => + [...document.querySelectorAll('[data-tab]')].reduce( + (n, t) => n + parseFloat(getComputedStyle(t).borderRightWidth) + parseFloat(getComputedStyle(t).borderLeftWidth), + 0 + ) + ) + ok((await borders()) === 0, 'Classic: no line between tabs') + await shotStrip(page, 'tabs-classic') + // The row: right after Tab width, Classic by default. + const row = await gotoPref(page, 'tab-style') + const order = await page.evaluate(() => [...document.querySelectorAll('[data-pref]')].map((e) => e.getAttribute('data-pref'))) + ok(order.indexOf('tab-style') === order.indexOf('tab-width') + 1, `Tab style is right after Tab width (${order.slice(0, 3).join(' > ')})`) + ok((await row.locator('[aria-pressed="true"]').getAttribute('data-seg')) === 'classic', 'Classic is the default') + ok((await row.locator('[data-seg]').allTextContents()).join('|') === 'Classic|Prompt', 'the choices are Classic and Prompt') + await row.locator('[data-seg="prompt"]').click() + ok((await page.evaluate(() => localStorage.getItem('prism.window.tabStyle'))) === 'prompt', 'Prompt is stored') + await backToFirst(page) + ok((await page.locator('[data-tab-strip]').getAttribute('data-tab-style')) === 'prompt', 'the strip draws Prompt') + ok((await borders()) === 0, 'Prompt: no line between tabs') + const geo = await page.evaluate(() => { + const segs = [...document.querySelectorAll('[data-tab]')] + const boxes = segs.map((s) => s.getBoundingClientRect()) + const shapes = segs.map((s) => getComputedStyle(s.querySelector('[data-prompt-shape]')).clipPath) + const working = segs[2] + const band = working.querySelector('[data-prompt-edge]') + const b = band?.getBoundingClientRect() + const rule = segs[0].querySelector('[data-prompt-rule]') + return { + overlaps: boxes.slice(0, -1).map((r, i) => Math.round((r.right - boxes[i + 1].left) * 10) / 10), + clipped: shapes.every((c) => c.startsWith('polygon')), + band: b ? { left: b.left - boxes[2].right, right: b.right - boxes[2].right, top: b.top - boxes[2].top, height: b.height - boxes[2].height } : null, + bandMotion: band ? getComputedStyle(band.firstElementChild).animationName : null, + rule: rule ? { w: rule.getBoundingClientRect().width - boxes[0].width, clip: getComputedStyle(rule).clipPath } : null, + edges: segs.map((s) => s.querySelector('[data-prompt-edge]')?.getAttribute('data-prompt-edge') ?? null) + } + }) + ok(geo.clipped, 'every segment is clipped to its chevron') + ok(geo.overlaps.every((o) => Math.abs(o - 9.5) < 0.6), `neighbours overlap by 9.5 px (${geo.overlaps.join(', ')})`) + ok( + !!geo.band && Math.abs(geo.band.left + 13) < 0.6 && Math.abs(geo.band.right - 3.5) < 0.6 && Math.abs(geo.band.top) < 0.6 && Math.abs(geo.band.height) < 0.6, + `a working tab's edge band runs from 1 px inside its arrow to 1 px inside the next notch, the full height (${JSON.stringify(geo.band)})` + ) + ok(geo.bandMotion === 'p-mark-grow', `and grows (${geo.bandMotion})`) + ok(JSON.stringify(geo.edges) === JSON.stringify(['working', null, 'working', 'done', 'question', 'failed']), `each marked tab's edge says its state (${geo.edges.join(', ')})`) + ok(!!geo.rule && Math.abs(geo.rule.w) < 0.6 && geo.rule.clip.startsWith('polygon'), `the tab in front wears its rule, cut along the slant (${JSON.stringify(geo.rule)})`) + // The grow moves: two samples of the band's scale differ. + const scale = () => page.evaluate(() => getComputedStyle(document.querySelectorAll('[data-tab]')[2].querySelector('[data-prompt-edge] i')).transform) + const s1 = await scale() + await sleep(330) + ok(s1 !== (await scale()), 'the working edge is moving') + await shotStrip(page, 'tabs-prompt') + // Fixed and Dynamic both hold. + const widths = () => page.evaluate(() => [...document.querySelectorAll('[data-tab]')].map((t) => Math.round(t.getBoundingClientRect().width))) + const dyn = await widths() + await (await gotoPref(page, 'tab-width')).locator('[data-seg="fixed"]').click() + await backToFirst(page) + const fixed = await widths() + ok(new Set(fixed).size === 1 && fixed[0] >= 112 && fixed[0] <= 124, `Prompt, Fixed: every segment one width (${fixed.join(' / ')})`) + ok(new Set(dyn).size > 1, `Prompt, Dynamic: sized to the name (${dyn.join(' / ')})`) + await shotStrip(page, 'tabs-prompt-fixed') + // A light theme. + await pickTheme(page, 'paper') + await backToFirst(page) + await shotStrip(page, 'tabs-prompt-light') + await quietStrip(page) + } finally { + await closeApp(app) + } + }, + + /** + * THE INDICATOR STYLES (#143): Minimal, Ring and Full, on Volt and Paper, in + * both tab styles. Measured: Ring spins by a working tab's name and leaves + * the other states to Minimal; Full fills only tabs not in front, its names + * by the owner's 2:1 rule; the tab in front is told apart in every state; + * the rainbow flows, and under reduced motion nothing moves at all. + */ + async indicatorStyles(ok) { + const w = world() + const { app, page } = await launch(w, { args: markedFolders(w) }) + try { + await app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].setSize(1100, 700)) + await markStrip(page, ok) + const rgb = (c) => (c.match(/[\d.]+/g) ?? []).slice(0, 3).map(Number) + const lin = (v) => ((v /= 255) <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4) + const lum = ([r, g, b]) => 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b) + const ratio = (x, y) => (Math.max(lum(x), lum(y)) + 0.05) / (Math.min(lum(x), lum(y)) + 0.05) + const look = () => + page.evaluate(() => { + const probe = document.createElement('span') + probe.style.color = getComputedStyle(document.documentElement).getPropertyValue('--p-text') + document.body.appendChild(probe) + const text = getComputedStyle(probe).color + probe.remove() + return { + text, + tabs: [...document.querySelectorAll('[data-tab]')].map((t) => { + const fill = t.querySelector('[data-mark="fill"]') + return { + active: t.hasAttribute('data-tab-active'), + state: t.getAttribute('data-agent-state'), + marks: [...t.querySelectorAll('[data-mark]')].map((m) => m.getAttribute('data-mark')), + rule: !!t.querySelector('[data-prompt-rule]') || [...t.children].some((c) => c.tagName === 'SPAN' && c.getBoundingClientRect().height === 2 && c.getBoundingClientRect().top === t.getBoundingClientRect().top), + fill: fill ? getComputedStyle(fill).backgroundColor : null, + name: getComputedStyle(t.querySelector('[role="tab"]')).color + } + }) + } + }) + for (const theme of ['volt', 'paper']) { + await pickTheme(page, theme) + for (const style of ['classic', 'prompt']) { + await setPref(page, 'tab-style', style) + for (const ind of ['minimal', 'ring', 'full']) { + await setPref(page, 'agent-indicator', ind) + await backToFirst(page) + await sleep(250) + const l = await look() + const t = l.tabs + const tag = `${theme}, ${style}, ${ind}` + ok(t[0].active && t[0].rule, `${tag}: the tab in front wears its rule`) + ok(t.every((x) => !x.active || !x.fill), `${tag}: the tab in front is never filled`) + if (ind === 'ring') { + ok(t[0].marks.includes('ring') && t[2].marks.includes('ring'), `${tag}: a spinner by each working tab's name (${t[2].marks})`) + const minimal = style === 'prompt' ? 'edge' : 'line' + ok([3, 4, 5].every((i) => t[i].marks.includes(minimal)), `${tag}: finished, question and failed keep Minimal's ${minimal}`) + } + if (ind === 'full') { + ok([2, 3, 4, 5].every((i) => !!t[i].fill && rgb(t[i].fill).length === 3), `${tag}: every marked tab not in front is filled`) + ok(!t[1].fill, `${tag}: an idle tab is not`) + ok(t[1].name === l.text, `${tag}: an idle name is the theme's text (${t[1].name} vs ${l.text})`) + for (const i of [2, 3, 4, 5]) { + const r = ratio(rgb(l.text), rgb(t[i].fill)) + const kept = t[i].name === l.text + ok(kept === r >= 2, `${tag}: ${t[i].state}'s name is the text unless it reads under 2:1 (${r.toFixed(2)}:1, ${kept ? 'kept' : 'flipped to ' + t[i].name})`) + } + if (theme === 'volt') ok(t[2].name !== l.text, `${tag}: on Volt the working fill flips its name`) + } + await shotStrip(page, `marks-${style}-${ind}-${theme}`) + } + } + } + // THE TAB IN FRONT, IN EVERY STATE (#143: "the active tab must stay + // obvious in every state"). The parked window is never focused, so + // opening a marked tab does not clear it: each state can be in front. + await setPref(page, 'agent-indicator', 'full') + for (const style of ['classic', 'prompt']) { + await setPref(page, 'tab-style', style) + for (const i of [3, 4, 5]) { + await backToFirst(page) + await page.locator('[data-tab]').nth(i).click() + await sleep(200) + const t = (await look()).tabs[i] + ok(t.active && t.rule && !t.fill && t.marks.length > 0, `${style}, Full: ${t.state} in front has its rule and its line, not a fill (${t.marks})`) + } + } + await setPref(page, 'agent-indicator', 'minimal') + await setPref(page, 'tab-style', 'classic') + await backToFirst(page) + // THE RAINBOW FLOWS: two samples of the finished line's position differ. + const pos = () => page.evaluate(() => getComputedStyle(document.querySelectorAll('[data-tab]')[3].querySelector('[data-attention="done"]')).backgroundPositionX) + const p1 = await pos() + await sleep(400) + const p2 = await pos() + ok(p1 !== p2, `the rainbow flows (${p1} then ${p2})`) + // REDUCED MOTION: everything still. + await page.emulateMedia({ reducedMotion: 'reduce' }) + await sleep(200) + const running = await page.evaluate(() => + document.getAnimations().filter((a) => /^p-(mark|agent)/.test(a.animationName ?? '')).map((a) => a.animationName) + ) + ok(running.length === 0, `with reduced motion no mark moves (${running.join(', ')})`) + const q1 = await pos() + await sleep(400) + ok(q1 === (await pos()), 'the rainbow stands still') + const still = await page.evaluate(() => { + const run = document.querySelector('[data-tab] .p-agent-run') + const strip = run?.parentElement + const q = document.querySelectorAll('[data-tab]')[4].querySelector('[data-attention="question"]') + return { run: run && strip ? Math.round(run.getBoundingClientRect().width) === Math.round(strip.getBoundingClientRect().width) : false, question: q ? getComputedStyle(q).opacity : null } + }) + ok(still.run && still.question === '1', `the run is a full line and the question fully on (${JSON.stringify(still)})`) + await setPref(page, 'tab-style', 'prompt') + await backToFirst(page) + const edge = await page.evaluate(() => getComputedStyle(document.querySelectorAll('[data-tab]')[2].querySelector('[data-prompt-edge] i')).transform) + ok(edge === 'none', `and a working Prompt edge rests full (${edge})`) + await shotStrip(page, 'marks-reduced-motion') + await page.emulateMedia({ reducedMotion: 'no-preference' }) + await setPref(page, 'tab-style', 'classic') + await quietStrip(page) + } finally { + await closeApp(app) + } + }, + // NO TITLE BAR (#91; owner, 2026-09-28, "tabs in the top row"): Hidden // puts the tabs in the title bar's row with its buttons at the end, one row // where there were two. Shown, the default, is the window as it was. @@ -2477,8 +2831,11 @@ const scenarios = { [...readFileSync(resolve(process.cwd(), file), 'utf8').matchAll(/\{\s*id: '([a-z-]+)'[^}]*\}/g)] .filter((m) => keep(m[0])) .map((m) => ({ id: m[1], section: (m[0].match(/section: '([a-z]+)'/) ?? [])[1] ?? null })) + // The tab marks' own rows (#143) keep a list of their own, drawn here (this + // host declares tabMarks) right after the Finished switch. + const marks = entries('core/renderer/settings/markOptions.ts') const core = [ - ...entries('core/renderer/settings/options.ts'), + ...entries('core/renderer/settings/options.ts').flatMap((e) => (e.id === 'agent-done-on' ? [e, ...marks] : [e])), ...entries('core/renderer/settings/dictationOptions.ts', (row) => !row.includes('onlyWhere')), // Command help (#12) keeps a list of its own, as dictation does. ...entries('core/renderer/settings/helpOptions.ts'), @@ -2888,7 +3245,7 @@ const scenarios = { await until(async () => (await tabLabels(page)).length === 1) await app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].setSize(1600, 1000)) const labelOf = {} - for (const file of ['core/renderer/settings/options.ts', 'core/renderer/settings/dictationOptions.ts', 'core/renderer/settings/helpOptions.ts', 'src/renderer/src/components/settings/appOptions.ts']) + for (const file of ['core/renderer/settings/options.ts', 'core/renderer/settings/markOptions.ts', 'core/renderer/settings/dictationOptions.ts', 'core/renderer/settings/helpOptions.ts', 'src/renderer/src/components/settings/appOptions.ts']) for (const m of readFileSync(resolve(process.cwd(), file), 'utf8').matchAll(/\{\s*id: '([a-z-]+)'[^}]*\}/g)) if (!m[0].includes('onlyWhere')) labelOf[m[1]] = (m[0].match(/label: '([^']+)'/) ?? [])[1] const order = [...readFileSync(resolve(process.cwd(), 'src/renderer/src/components/settings/settingsIndex.ts'), 'utf8').matchAll(/'([a-z]+(?:-[a-z]+)+|[a-z]+-[a-z]+)'/g)].map((m) => m[1]) @@ -3070,7 +3427,7 @@ const scenarios = { // and the agent rows have pages of their own; Appearance keeps the // window's rows above the theme and what a theme sets under it. const rows = await page.evaluate(() => [...document.querySelectorAll('[data-pref]')].map((e) => e.getAttribute('data-pref'))) - const want = ['tab-width', 'title-bar', 'window-edges', 'term-theme', 'window-background', 'window-accent', 'term-acrylic'] + const want = ['tab-width', 'tab-style', 'title-bar', 'window-edges', 'term-theme', 'window-background', 'window-accent', 'term-acrylic'] ok(JSON.stringify(rows) === JSON.stringify(want), `Appearance runs ${want.join(' > ')} (${rows.join(' > ')})`) // Font size is 50% to 200% in tens. await gotoPref(page, 'term-font') @@ -3358,23 +3715,34 @@ const scenarios = { }, 3000, 50) ok(!!stored && stored.endsWith('80'), `the working colour is stored as hex8 (${stored})`) ok(/^#[0-9a-f]{8}$/.test(await field.inputValue()), `and the code field shows the eight digits (${await field.inputValue()})`) + // FULL FILLS SOLID, AND ONLY A TAB NOT IN FRONT (#143): Settings is the + // tab in front, so the working shell's tab is filled, OPAQUE, with the + // half-alpha colour laid on the ground; its name is the theme's text + // unless that reads under 2:1 on the fill (the owner's rule). const tab = await until(async () => { const l = await page.evaluate(() => { - const el = document.querySelector('[data-agent-state="working"]') + const el = document.querySelector('[data-agent-state="working"] [data-mark="fill"]') if (!el) return null - const cs = getComputedStyle(el) - return { bg: cs.backgroundColor, ink: cs.color, ground: getComputedStyle(document.documentElement).getPropertyValue('--p-bg-solid') } + const name = el.closest('[data-tab]').querySelector('[role="tab"]') + const root = getComputedStyle(document.documentElement) + const probe = document.createElement('span') + probe.style.color = root.getPropertyValue('--p-text') + document.body.appendChild(probe) + const text = getComputedStyle(probe).color + probe.remove() + return { bg: getComputedStyle(el).backgroundColor, ink: getComputedStyle(name).color, text, ground: root.getPropertyValue('--p-bg-solid') } }) - // Settled, not mid-transition: the fill eases between colours. - return l && Math.abs(rgba(l.bg).a - 128 / 255) < 0.01 ? l : null + return l && rgba(l.bg).a === 1 ? l : null }, 4000, 50) - ok(!!tab, `the Full tab's fill carries the alpha (${tab?.bg})`) - if (tab) { - const f = rgba(tab.bg) + ok(!!tab, `the Full tab's fill is opaque (${tab?.bg})`) + if (tab && stored) { const g = hexRgb(tab.ground) - const seen = f.rgb.map((v, i) => g[i] + (v - g[i]) * f.a) - const r = ratio(rgba(tab.ink).rgb, seen) - ok(r >= 4.5, `the Full tab's text reads on the composite (${r.toFixed(1)}:1, ${tab.ink} on ${tab.bg} over ${tab.ground.trim()})`) + const want = hexRgb(stored).map((v, i) => Math.round(g[i] + (v - g[i]) * (0x80 / 255))) + const got = rgba(tab.bg).rgb + ok(got.every((v, i) => Math.abs(v - want[i]) <= 1), `the half-alpha colour laid on the ground (${got} vs ${want})`) + const textRatio = ratio(rgba(tab.text).rgb, got) + const keeps = rgba(tab.ink).rgb.join() === rgba(tab.text).rgb.join() + ok(keeps === textRatio >= 2, `the name is the theme's text unless it reads under 2:1 (${textRatio.toFixed(2)}:1, ${keeps ? 'kept' : 'flipped'})`) } await page.locator('[data-tab-strip]').screenshot({ path: resolve(process.cwd(), '.e2e-shots/colour-picker-full-tab.png') }).catch(() => {}) await shot('alpha') @@ -3835,7 +4203,8 @@ const scenarios = { const w = world() // Two tabs, so there IS a line between tabs to measure. let { app, page } = await launch(w, { args: [w.alpha, w.beta] }) - ok(await until(async () => (await tabLabels(page)).length === 2), 'two tabs open, so a tab separator exists') + // NO LINE BETWEEN TABS (#143): two tabs, so a separator WOULD show if one came back. + ok(await until(async () => (await tabLabels(page)).length === 2), 'two tabs open') const probe = (pg) => pg.evaluate(() => { const parts = (c) => (c.match(/[\d.]+/g) ?? []).map(Number) @@ -3860,7 +4229,6 @@ const scenarios = { const second = document.querySelector('[data-pref="title-bar"]') const css = (el, prop) => (el ? getComputedStyle(el)[prop] : null) const edges = { - tab: css(tab, 'borderRightColor'), title: css(title, 'borderBottomColor'), rail: css(rail, 'borderRightColor'), row: css(panel, 'borderTopColor'), @@ -3874,16 +4242,19 @@ const scenarios = { divider: getComputedStyle(document.documentElement).getPropertyValue('--p-divider').trim(), line: getComputedStyle(document.documentElement).getPropertyValue('--p-line').trim() }, - tab: edges.tab === null ? null : alpha(edges.tab), - tabInk: edges.tab === null ? null : rgb(edges.tab), title: edges.title === null ? null : alpha(edges.title), + titleInk: edges.title === null ? null : rgb(edges.title), rail: edges.rail === null ? null : alpha(edges.rail), row: edges.row === null ? null : alpha(edges.row), rule: edges.rule === null ? null : alpha(edges.rule), // A border keeps its pixel whatever its colour: nothing may move. tabWidth: tab ? tab.getBoundingClientRect().width : 0, titleHeight: title ? title.getBoundingClientRect().height : 0, - tabBorder: css(tab, 'borderRightWidth'), + // Every tab's own side borders, summed: none since #143. + tabBorder: [...document.querySelectorAll('[data-tab]')].reduce( + (n, t) => n + parseFloat(getComputedStyle(t).borderRightWidth) + parseFloat(getComputedStyle(t).borderLeftWidth), + 0 + ), pressed: document.querySelector('[data-pref="window-edges"] [aria-pressed="true"]')?.getAttribute('data-seg') ?? null, stored: localStorage.getItem('prism.window.edges') } @@ -3894,7 +4265,7 @@ const scenarios = { const settled = (pg, also = () => true, ms = 15000) => until(async () => { const p = await probe(pg) - const chrome = [p.tab, p.title, ...(p.rail === null ? [] : [p.rail])] + const chrome = [p.title, ...(p.rail === null ? [] : [p.rail])] const arrived = chrome.every((a) => near(a, p.divider)) && (p.row === null || near(p.row, p.line)) && (p.rule === null || near(p.rule, p.line)) return arrived && also(p) ? p : null }, ms) @@ -3908,7 +4279,8 @@ const scenarios = { first?.raw.divider === '#ffffff12' && first?.raw.line === '#ffffff17', `the default is exactly the look before the setting existed (${first?.raw.divider}, ${first?.raw.line})` ) - ok(near(first?.tab ?? null, 0.07), `the line between tabs is the 7% hairline it always was (${first?.tab})`) + ok(near(first?.title ?? null, 0.07), `the title bar's rule is the 7% hairline it always was (${first?.title})`) + ok(first?.tabBorder === 0, `and there is no line between tabs (#143; ${first?.tabBorder}px of tab border)`) ok((await page.evaluate(() => window.prism.e2eWindowEdges())) === 'hairline', 'main holds a hairline for the window border') await page.locator('[data-title-settings]').click() @@ -3943,29 +4315,29 @@ const scenarios = { ok(all, 'all four options were measured') if (all) { const { none, faint, hairline, solid } = seen - for (const edge of ['tab', 'title', 'rail', 'row', 'rule']) { + for (const edge of ['title', 'rail', 'row', 'rule']) { ok(none[edge] === 0, `none: the ${edge} edge is transparent (alpha ${none[edge]})`) ok( faint[edge] > 0 && faint[edge] < hairline[edge] && hairline[edge] < solid[edge], `${edge}: faint < hairline < solid (${faint[edge]} < ${hairline[edge]} < ${solid[edge]})` ) } - ok(near(hairline.tab, 0.07) && near(hairline.row, 0.09), 'going back to Hairline is going back to the old look') - ok(solid.tabInk === '255,255,255', `on a dark ground the line is white ink (${solid.tabInk})`) + ok(near(hairline.title, 0.07) && near(hairline.row, 0.09), 'going back to Hairline is going back to the old look') + ok(solid.titleInk === '255,255,255', `on a dark ground the line is white ink (${solid.titleInk})`) // Transparent, not absent: the border keeps its width, so nothing shifts. // The width is whatever one CSS pixel snaps to on this display (MEASURED // 0.888889px at 225% scaling), so it is compared, never assumed to be 1px. const widths = new Set(Object.values(seen).map((p) => `${p.tabWidth}|${p.titleHeight}|${p.tabBorder}`)) ok( - widths.size === 1 && parseFloat(none.tabBorder) > 0, - `no option moves the layout, and "none" keeps its border's width (${[...widths].join(' ; ')})` + widths.size === 1 && none.tabBorder === 0, + `no option moves the layout, and none brings a line between tabs back (${[...widths].join(' ; ')})` ) } // A light theme: the same choice, in black ink at the light ground's alpha. await page.locator('[data-term-card="paper"]').first().click() const lightSolid = await settled(page, (q) => q.ink === '0,0,0' && q.pressed === 'solid') - ok(!!lightSolid && near(lightSolid.tab, 0.18), `on a light theme Solid is black ink at 18% (${lightSolid?.tabInk} @ ${lightSolid?.tab})`) + ok(!!lightSolid && near(lightSolid.title, 0.18), `on a light theme Solid is black ink at 18% (${lightSolid?.titleInk} @ ${lightSolid?.title})`) // AND IT SURVIVES A RELAUNCH: quit properly (localStorage is flushed on the // way out), come back, and measure before Settings is even opened. @@ -3984,8 +4356,8 @@ const scenarios = { ok(exited, 'the app quits when it is asked to, before anything relaunches it') ;({ app, page } = await launch(w)) ok(await until(async () => (await tabLabels(page)).length === 2, 20000), 'a relaunch brings the tabs back') - const back = await settled(page, (q) => q.stored === 'solid' && near(q.tab, 0.18), 30000) - ok(!!back, `the relaunched window draws Solid edges before Settings is opened (tab ${back?.tab}, stored ${back?.stored})`) + const back = await settled(page, (q) => q.stored === 'solid' && near(q.title, 0.18), 30000) + ok(!!back, `the relaunched window draws Solid edges before Settings is opened (title ${back?.title}, stored ${back?.stored})`) ok( (await until(async () => (await page.evaluate(() => window.prism.e2eWindowEdges())) === 'solid', 5000)) === true, 'and main was told again at launch' @@ -5265,7 +5637,7 @@ function collectStalls(scenario) { } } /** Scenarios that honestly take longer than the default limit. */ -const SLOW = { dictation: 360000, dictationParakeet: 360000, helpPanel: 300000, updateWindow: 300000 } +const SLOW = { dictation: 360000, dictationParakeet: 360000, helpPanel: 300000, updateWindow: 300000, indicatorStyles: 360000 } reapStrays() for (const [name, run] of Object.entries(scenarios)) { if (only.length && !only.some((o) => name.toLowerCase().includes(o.toLowerCase()))) continue From ecc673b9492284a421ffad47fb0f928c31039d95 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Sat, 10 Oct 2026 02:27:21 +0200 Subject: [PATCH 4/5] docs: the tab marks, the rainbow finish and Prompt tabs (#143) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FHHaWKR4M5QtW7Wecyuk4t --- README.md | 3 ++- core/README.md | 29 ++++++++++++++++++++++++++--- docs/regression-rules.md | 39 +++++++++++++++++++++++++++++++++++---- docs/two-apps.md | 11 +++++++++++ 4 files changed, 74 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index fc8ce7b..2b7cf1f 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,8 @@ session comes back when you reopen the app. | | | |---|---| -| **Agent indicator on every tab** | A tab lights the moment Claude Code or Codex starts working and shows when the answer has landed. | +| **Agent indicator on every tab** | A tab lights the moment Claude Code or Codex starts working and shows when the answer has landed, a question is waiting, or a turn failed. Pick Minimal, Ring or Full; a finished tab wears the app icon's rainbow, flowing. | +| **Two tab styles** | Classic flat tabs, or Prompt: chevron segments whose arrow edge is the agent's mark. | | **Sessions that come back** | Close the app and reopen it: every tab returns in its folder, and agent conversations resume on their own. | | **Local dictation** | Hold `Right Alt` and speak. [whisper.cpp](https://github.com/ggml-org/whisper.cpp) runs on your PC, offline, and never presses Enter. | | **Command help** | Press `F1`, describe a task in plain words and copy the command. Works offline. | diff --git a/core/README.md b/core/README.md index 54d2e6f..a4a7387 100644 --- a/core/README.md +++ b/core/README.md @@ -105,7 +105,8 @@ ask me."* And: *"why can't this repo be the core?"* It can, and this is it. [`renderer/host.ts`](renderer/host.ts): the default each untouched setting reads as, whether there is a host style to follow, who paints the ground, what an unpicked indicator colour resolves to, what "acrylic" means as a - terminal setting, which chords the app owns. If a difference is not on that page, it is a fork, + terminal setting, which chords the app owns, which tab marks its strip draws + (`tabMarks`, #143). If a difference is not on that page, it is a fork, and a fork is what this exists to end. Adding a field is an owner decision. 4. **Defaults are per host on purpose.** An update must never silently change what an existing user sees. Where the owner picks one value for both apps, @@ -201,6 +202,26 @@ is `settings/sections/DiagnosticsPage.tsx` (props only), its rows `diagnosticsOptions.ts`. Schema and how to read it: Prism Terminal's `docs/diagnostics.md`. +**THE TAB MARKS (#143, 2026-10-10)** are the sixth: how a tab says its agent +is working, finished, asking or failed. `renderer/lib/tabMark.ts` +(`resolveTabMark`, the one rule: which mark, in which colour role, with which +motion, for Off, Minimal, Ring and Full on a flat or a Prompt tab), +`markColours.ts` (the icon's rainbow, `floorMark` to 3:1 on every ground), +`nameInk.ts` (Full's name: the theme's text unless under 2:1 on the fill), +`markPalette.ts` (every colour a strip's marks wear, pure), +`components/TabMark.tsx` (a flat tab's run, line, ring and fill, props only) +and `styles/marks.css`, the motion, which a host IMPORTS +(`prism-term-core/renderer/styles/marks.css`; without it the marks stand still). +**`tabMarks`** on `TermHostConfig` declares what the host's strip draws: the +indicator choices (`hostIndicators()`; a stored value outside them reads as the +host's default, `readIndicator`) and whether it draws the rainbow +(`hostRainbow()`, which also decides whether `AgentMarksSection` draws the +"Rainbow finished mark" row). Absent: Off, Minimal and Full, Full being the +host's own filled tab, and no rainbow row, so a host that has not adopted the +marks sees no change. That row's list is `settings/markOptions.ts`, NOT +`TERMINAL_OPTIONS`; `coreSettingsIndex({ marks: true })` indexes it. Its +switch is `agentRainbow()` (`prism.term.agentRainbow`, on; no theme resets it). + ## Rules for code in here (lint-enforced in Prism Terminal's `eslint.config.js`) - **Relative imports only.** No `@shared` / `@renderer` / `@core` aliases: a @@ -218,8 +239,10 @@ is `settings/sections/DiagnosticsPage.tsx` (props only), its rows the three preload members it needs (`UpdateBridge`), so the same components drop into a host whose terminal is not even mounted. - CSS: the core uses Tailwind utilities and the `--p-*` tokens both apps define, - plus the classes `.p-agent-run` and `.p-scroll` and the `.xterm` rules in each - app's `index.css`. A new token or class needed here must be added to BOTH. + plus the class `.p-scroll` and the `.xterm` rules in each app's `index.css`. A + new token or class needed here must be added to BOTH. The marks' classes + (`.p-agent-run`, `.p-mark-*`) live in the core's own `styles/marks.css` since + #143, which each host imports. ## How each app consumes it diff --git a/docs/regression-rules.md b/docs/regression-rules.md index 58afd2f..530b669 100644 --- a/docs/regression-rules.md +++ b/docs/regression-rules.md @@ -101,7 +101,8 @@ one-liner in `CLAUDE.md` only if it is among the most important. A tab whose agent finished, or waits on you, while you were NOT LOOKING (another tab in front, or the window unfocused) gets a static 3 px line along its bottom: Finished colour, or Question colour (default blue) which outranks it; each behind its own switch (`agent-done-on`, - `agent-question-on`, both on). Opening the tab clears it. Full's fill is for working alone now. + `agent-question-on`, both on). Opening the tab clears it. Since #143 (below) a question BREATHES + (fades to the ground and back, 2 s) and a finish is the icon's flowing rainbow, in every style. CLAUDE GIVES NO SIGNAL FOR A QUESTION (MEASURED in a pty: the title is `✳` exactly as when done, no bell, no OSC 9), so `agentQuestion.looksLikeQuestion` reads the last text rows of the screen (through termBus, never by importing the panel) for its footer when the title goes idle and as @@ -127,8 +128,37 @@ one-liner in `CLAUDE.md` only if it is among the most important. chrome's own `--p-accent`, finished = the theme's green moved to the contrast floor (the fallback green when a palette's green IS its accent, since two states in one colour is no indicator). Picking a theme gives both back to the theme; a pick of your own shows a "Follow theme" button. - Settings calls them "Agent working indicator" and "Agent finished indicator". The finished mark - is Full's alone, so the e2e turns the volume up before it looks for one. + Settings calls them "Agent working indicator" and "Agent finished indicator". +- **FOUR INDICATOR STYLES, ONE RULE, THE RAINBOW FINISH** (#143; owner, 2026-10-10: "Let's just keep + the like that border that moves slowly. The pulsation effect is also now exactly what I want it. And I + like the gentle pulse too for questions. Make sure that's also applied to the minimal style for normal + tabs"; spec `docs/superpowers/specs/2026-10-10-indicator-styles-design.md`). Off, Minimal, Ring, Full. + WHICH mark a tab wears is ONE pure rule, the core's `lib/tabMark.ts` `resolveTabMark`; the strip only + draws it. Minimal: working is the running bar, finished/question/failed a straight 3 px line. Ring: the + spinner by the name while working, Minimal's marks after. Full: every marked tab NOT in front is filled + SOLID (a see-through working colour laid on the ground first); the tab in front is NEVER filled and + shows Minimal's mark, so it is obvious in every state; finished is the icon's dark badge `#383c44` + with the rainbow along its foot. FULL'S NAME (owner, 2026-10-10): every name is the theme's text, idle + ones too; a fill flips it to the opposite only when the text reads under 2:1 on that state's full + colour (`lib/nameInk.ts`), chosen once per colour, never per frame. The brain icon is gone, and so is + the line between tabs. The RAINBOW (`lib/markColours.ts`) is `build/icon-source.png`'s seven, each + moved to 3:1 on every ground it sits on, keeping its hue; "Rainbow finished mark" (`agent-rainbow`, on) + turns it off for the plain finished colour. All motion is `core/renderer/styles/marks.css` (run, grow, + breathe, flow, ride, spin), and under reduced motion NOTHING moves (a working edge rests full, the + question fully on): the window's own `*` rule would end a grow at empty, so the marks override it. + `indicatorStyles` measures it on Volt and Paper, `attention` the breathe and the rainbow. +- **PROMPT TABS: THE ARROW EDGE IS THE MARK** (#143; Settings > Appearance > Tab style, `tab-style`, + `prism.window.tabStyle`, Classic by default). Chevron segments (`lib/promptGeometry.ts`, MEASURED in + the approved mockup): a 12 px arrow, the next segment's notch parallel to it, overlapping by 9.5 px, so + the ground-coloured EDGE between them is 2.5 px across, the top rule's own 2 px square to the slant. + The edge band is drawn BEHIND the segments, from 1 px inside this segment's arrow to 1 px inside the + next notch (the last one stops where a next notch would be), so both shapes cut it and it fills the + gap flush, tip to both corners. Working GROWS it from the tip (empty 0.2 s, out 0.6 s, full 0.2 s, back + 0.6 s); on the tab in front in the rule's colour, so rule and edge read as one line bending down. The + rule runs 0.5 px past the slant (the mockup's measured seam). The wrapper lets the pointer through, so + a click lands on the segment's SHAPE and the notch belongs to the tab whose arrow fills it. + `--p-seg` / `--p-seg-on` are the text over the strip at 4% / 11% (at that ALPHA on glass). The + `tabStyle` e2e measures the overlap, the band and the rule. - **The process poll** (`agentPoll.ts`) asks only after a pty printed something and backs off 2.5s to 20s. It reports CHANGES, so its first verdict on a shell is said once; the e2e waits for it. A title that claims an agent the poll has not reported asks for a look NOW (`termAgentLook`, @@ -181,7 +211,8 @@ one-liner in `CLAUDE.md` only if it is among the most important. validates it; `edgeFor` scales its step, and none is DWMWA_COLOR_NONE, what a maximized window already gets). That half is unit-tested only: the DWM helper is off under `--e2e`, so the e2e asserts what main HEARD, and how the border looks is on the hands-on list. The `edges` e2e - measures real edges (a tab separator, the title bar's rule, the settings rail, a settings row), + measures real edges (the title bar's rule, the settings rail, a settings row; and that no tab has a + side border, since #143 drew no line between tabs), WAITING for each to arrive, since the strip's border colour transitions over 550ms. - **THE ACCENT AND THE BACKGROUND ARE SETTINGS, AND UNSET IS THE THEME'S** (owner, 2026-09-22: "an accent colour option which would pick the accents you see, like the blue highlight effect diff --git a/docs/two-apps.md b/docs/two-apps.md index 13d38fc..bc09c5b 100644 --- a/docs/two-apps.md +++ b/docs/two-apps.md @@ -42,6 +42,17 @@ so an update never silently changes what an existing user sees; the bridge to ma (`settingsIndex.ts`), and the `settingsSearch` e2e opens each one by its label. The old components (`TerminalAppearanceSettings` and friends) stay exported, unchanged, until Prism has moved (spec 3.0); do not build on them. +- **THE TAB MARKS ARE THE CORE'S; WHICH ONES A HOST DRAWS IS DECLARED** (#143, 2026-10-10). The + rules (`lib/tabMark.ts`, `markColours.ts`, `nameInk.ts`, `markPalette.ts`), the flat tab's marks + (`components/TabMark.tsx`) and all their motion (`styles/marks.css`, which a host imports) are + written once. `TermHostConfig.tabMarks` (optional; an owner decision named in #143's PR) says what + the host's STRIP draws: this app passes Off, Minimal, Ring, Full and the rainbow; Prism passes + nothing, so it keeps Off, Minimal and its own Full, a stored Ring reads as its default + (`readIndicator`), and no rainbow row is drawn there. The "Rainbow finished mark" row is in its own + list, `markOptions.ts`, NOT in `TERMINAL_OPTIONS`, for `helpOptions.ts`'s reason: Prism's gate reads + that file as text and its unit test orders every id in it. Verified before the PR against Prism + `952e309` (typecheck, unit suite, `terminal`, `termOptions`, `termColourPicker`, `settingsLook`, + `tabs`, `agentTitle`, `promptLayout`). Prism adopting the marks is a later owner decision. - **THIS IS A PRODUCT FOR OTHER PEOPLE** (owner, 2026-09-19: "this isn't an app for just me. keep that in mind with all things you implement"). A feature bundles or fetches what it needs and works on a fresh Windows install: never lean on the owner's GPU, tools, caches or installed runtimes. From 530d7e5398cc3a0b439a92f85916812650b9d805 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Sat, 10 Oct 2026 02:46:59 +0200 Subject: [PATCH 5/5] perf(tabs): work out the mark palette only when its inputs change (#143) The strip renders on every pointer move of a tab drag; flooring the mark colours on every ground each time was work the frame did not need. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FHHaWKR4M5QtW7Wecyuk4t --- src/renderer/src/components/TabStrip.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/components/TabStrip.tsx b/src/renderer/src/components/TabStrip.tsx index 3fd7dc1..7c67c7f 100644 --- a/src/renderer/src/components/TabStrip.tsx +++ b/src/renderer/src/components/TabStrip.tsx @@ -1,6 +1,7 @@ import { useEffect, useLayoutEffect, + useMemo, useRef, useState, type JSX, @@ -147,7 +148,13 @@ export function TabStrip({ const grounds = prompt ? [solidGround, composite(read('--p-seg', solidGround), solidGround), composite(read('--p-seg-on', solidGround), solidGround)] : [solidGround] - const palette = markPalette({ colours, grounds, solidGround, text: read('--p-text', '#e7e7ee'), rainbow }) + const text = read('--p-text', '#e7e7ee') + // Worked out again only when what it is made of changes: the strip renders on + // every pointer move of a tab drag, and flooring eighteen colours on three + // grounds each time is work the frame does not need. + const paletteKey = `${colours.working}|${colours.finished}|${colours.question}|${colours.failed}|${grounds.join(',')}|${solidGround}|${text}|${rainbow}` + // eslint-disable-next-line react-hooks/exhaustive-deps -- the key IS every input, as text + const palette = useMemo(() => markPalette({ colours, grounds, solidGround, text, rainbow }), [paletteKey]) /** A mark's colour role as a CSS background: the rainbow runs along a line * (`x`) or down a Prompt edge (`y`); the rule is the active rule's token. */ const paint = (role: MarkColour | null, axis: 'x' | 'y'): string => {