From 0fc7c6fcd4d5b8d9ee76d5adf3636cba49d08d63 Mon Sep 17 00:00:00 2001 From: Nicolas Gonzalez Date: Tue, 15 Sep 2026 00:09:41 +0200 Subject: [PATCH 01/20] fix(design): give filled mint surfaces a fill colour text can sit on The Export button painted --accent (#10b981) under --accent-on (#04221a): near-black on saturated mint. It clears AA on paper (6.6:1) but reads muddy at 13px, and the label was effectively unreadable in light theme. Mint is a border, glow and wash colour -- nothing readable sits on it in light theme, in either direction (white is 2.5:1 against it). So the fix is not a new text colour but a new fill: --accent-fill is the mint darkened until white clears AA on it (#047857, 5.5:1), with --accent-fill-lo as its hover, darker again so hovering never *loses* contrast the way hovering to --brand-lo did. In dark theme both collapse back onto mint, where the existing dark-on-mint pairing already reads at 7.7:1 -- that theme was never the complaint and is untouched. Switched the fourteen surfaces that carry text or an icon on mint: the export and record buttons, .btnPrimary, the chat CTA, the titlebar brand mark, the preview empty-state button, the export dialog's active segment, the template and project cells, the rewind confirm, and the shortcuts save. The bars, dots, waveform bins, scrub fills and slider thumbs keep --accent: nothing sits on them, so they keep the brand's brightness. The toggle knob moves off --accent-on to a literal white. It is a puck that rides both the off track (--border-hi) and the on track (--brand), so it must not follow a token that now flips per theme with the fill beneath it. Co-Authored-By: Claude Opus 5 (1M context) --- src/components/ai-edition/ExportDialog.tsx | 7 ++++--- src/components/ai-edition/LeftPanel.tsx | 4 ++-- src/components/ai-edition/Modals.tsx | 5 +++-- .../ai-edition/NewEditorShell.module.css | 16 ++++++++-------- .../ai-edition/VirtualPreview.module.css | 2 +- .../ai-edition/v4/EditorShellV4.module.css | 12 ++++++------ src/components/ai-edition/v4/MediaStage.tsx | 4 ++-- .../video-editor/ShortcutsConfigDialog.tsx | 2 +- src/styles/design-tokens.css | 13 ++++++++++++- 9 files changed, 39 insertions(+), 26 deletions(-) diff --git a/src/components/ai-edition/ExportDialog.tsx b/src/components/ai-edition/ExportDialog.tsx index 5dff50acf..8989dc40e 100644 --- a/src/components/ai-edition/ExportDialog.tsx +++ b/src/components/ai-edition/ExportDialog.tsx @@ -717,8 +717,9 @@ function FormatToggle({ background: active ? "var(--accent-wash)" : "var(--surface)", // Selection is conveyed by border + wash background (like the quality // cards below), not by swapping text color -- `--accent-on` is meant - // for text on a SOLID accent fill, and paired with the near-transparent - // `--accent-wash` it read as near-invisible dark-on-dark text. + // for text on a SOLID `--accent-fill`, and paired with the + // near-transparent `--accent-wash` it disappears in one theme or the + // other (it was dark-on-dark before, it is white-on-white now). color: "var(--fg)", cursor: "pointer", font: "600 14px/1 var(--font-body)", @@ -884,7 +885,7 @@ function segStyle(active: boolean): React.CSSProperties { padding: "8px 10px", border: `1px solid ${active ? "var(--accent)" : "var(--border)"}`, borderRadius: 8, - background: active ? "var(--brand)" : "var(--bg)", + background: active ? "var(--accent-fill)" : "var(--bg)", color: active ? "var(--accent-on)" : "var(--fg-2)", cursor: "pointer", font: "500 12px/1 var(--font-body)", diff --git a/src/components/ai-edition/LeftPanel.tsx b/src/components/ai-edition/LeftPanel.tsx index 8f88aa080..678b7160c 100644 --- a/src/components/ai-edition/LeftPanel.tsx +++ b/src/components/ai-edition/LeftPanel.tsx @@ -1665,8 +1665,8 @@ export function ChatStripPanel() { onClick={() => void confirmRewind(rewindFor.messageId)} style={{ padding: "4px 10px", - background: "var(--accent)", - border: "1px solid var(--accent)", + background: "var(--accent-fill)", + border: "1px solid var(--accent-fill)", borderRadius: "var(--r-sm)", color: "var(--accent-on)", font: "500 12px var(--font-body)", diff --git a/src/components/ai-edition/Modals.tsx b/src/components/ai-edition/Modals.tsx index f24500224..371c4528b 100644 --- a/src/components/ai-edition/Modals.tsx +++ b/src/components/ai-edition/Modals.tsx @@ -276,7 +276,8 @@ export function OpenProjectModal({ width: 36, height: 36, borderRadius: "var(--r-sm)", - background: "linear-gradient(135deg, var(--brand-lo), var(--brand))", + background: + "linear-gradient(135deg, var(--accent-fill-lo), var(--accent-fill))", display: "grid", placeItems: "center", color: "var(--accent-on)", @@ -537,7 +538,7 @@ function TemplateCell({ width: 36, height: 36, borderRadius: "var(--r-sm)", - background: active ? "var(--accent)" : "var(--surface-2)", + background: active ? "var(--accent-fill)" : "var(--surface-2)", color: active ? "var(--accent-on)" : "var(--muted)", display: "grid", placeItems: "center", diff --git a/src/components/ai-edition/NewEditorShell.module.css b/src/components/ai-edition/NewEditorShell.module.css index d2458328c..f8e8034bc 100644 --- a/src/components/ai-edition/NewEditorShell.module.css +++ b/src/components/ai-edition/NewEditorShell.module.css @@ -68,7 +68,7 @@ width: 18px; height: 18px; border-radius: 5px; - background: var(--accent); + background: var(--accent-fill); display: grid; place-items: center; color: var(--accent-on); @@ -472,7 +472,7 @@ gap: var(--sp-2); padding: var(--sp-3) var(--sp-4); border-radius: var(--r-sm); - background: var(--primary, #34B27B); + background: var(--accent-fill); color: var(--accent-on); border: none; font-weight: 500; @@ -1102,7 +1102,7 @@ left: 2px; width: 16px; height: 16px; - background: var(--accent-on); + background: #ffffff; border-radius: 50%; box-shadow: 0 1px 3px rgba(22, 23, 29, 0.2); transition: transform 150ms ease; @@ -1893,11 +1893,11 @@ } .btnSecondary:hover { background: var(--surface-warm); border-color: var(--border-hi); } .btnPrimary { - background: var(--accent); + background: var(--accent-fill); color: var(--accent-on); - border: 1px solid var(--accent); + border: 1px solid var(--accent-fill); } -.btnPrimary:hover { background: var(--brand-lo); } +.btnPrimary:hover { background: var(--accent-fill-lo); } .btnPrimary:disabled { background: var(--surface-2); border-color: var(--border); @@ -2311,9 +2311,9 @@ width: 100%; height: 34px; border-radius: var(--r-md); - background: var(--accent); + background: var(--accent-fill); color: var(--accent-on); - border: 1px solid var(--accent); + border: 1px solid var(--accent-fill); font: 600 12.5px var(--font-body); cursor: pointer; transition: filter 120ms ease; diff --git a/src/components/ai-edition/VirtualPreview.module.css b/src/components/ai-edition/VirtualPreview.module.css index 6177a9f52..63d2fb102 100644 --- a/src/components/ai-edition/VirtualPreview.module.css +++ b/src/components/ai-edition/VirtualPreview.module.css @@ -59,7 +59,7 @@ } .iconButton:hover:not(:disabled) { - background: var(--accent); + background: var(--accent-fill); color: var(--accent-on); } diff --git a/src/components/ai-edition/v4/EditorShellV4.module.css b/src/components/ai-edition/v4/EditorShellV4.module.css index 4b456274d..b3c89aada 100644 --- a/src/components/ai-edition/v4/EditorShellV4.module.css +++ b/src/components/ai-edition/v4/EditorShellV4.module.css @@ -441,9 +441,9 @@ height: var(--topbar-icon); padding: 0 14px; border-radius: 9px; - background: var(--accent); + background: var(--accent-fill); color: var(--accent-on); - border: 1px solid var(--accent); + border: 1px solid var(--accent-fill); font-size: 13px; font-weight: 600; cursor: pointer; @@ -452,7 +452,7 @@ transition: background var(--motion-fast) var(--ease); } .exportBtn:hover { - background: var(--brand-lo); + background: var(--accent-fill-lo); } .exportBtn:disabled { opacity: 0.5; @@ -1329,9 +1329,9 @@ height: 52px; padding: 0 26px; border-radius: 16px; - border: 1px solid var(--accent); + border: 1px solid var(--accent-fill); color: var(--accent-on); - background: var(--accent); + background: var(--accent-fill); font-size: 14px; font-weight: 600; cursor: pointer; @@ -1339,7 +1339,7 @@ transition: background var(--motion-fast) var(--ease); } .bigRecBtn:hover { - background: var(--brand-lo); + background: var(--accent-fill-lo); } .bigRecBtn.recording { border-color: var(--danger); diff --git a/src/components/ai-edition/v4/MediaStage.tsx b/src/components/ai-edition/v4/MediaStage.tsx index 94dce2859..0b845a77c 100644 --- a/src/components/ai-edition/v4/MediaStage.tsx +++ b/src/components/ai-edition/v4/MediaStage.tsx @@ -287,8 +287,8 @@ export function MediaStage({ gap: 7, marginBottom: 16, borderRadius: 9, - border: "1px solid var(--accent)", - background: "var(--accent)", + border: "1px solid var(--accent-fill)", + background: "var(--accent-fill)", color: "var(--accent-on)", fontSize: 12.5, fontWeight: 650, diff --git a/src/components/video-editor/ShortcutsConfigDialog.tsx b/src/components/video-editor/ShortcutsConfigDialog.tsx index 7118a2915..aa5fa1a0c 100644 --- a/src/components/video-editor/ShortcutsConfigDialog.tsx +++ b/src/components/video-editor/ShortcutsConfigDialog.tsx @@ -246,7 +246,7 @@ export function ShortcutsConfigDialog() { - {/* Both states are always rendered, stacked in one grid cell, so the slot keeps the width of the longer label and the bar doesn't twitch every time the document goes dirty. The inactive one is visibility:hidden, @@ -173,6 +172,11 @@ export function EditorTopBar({ ))} + {/* Language and theme are the two app-wide preferences in this bar, so they + sit together at its right end rather than one of them being stranded + among the per-project file actions. .langMenu is anchored right:0, so + it opens leftwards from here and stays on screen. */} + ))} @@ -203,9 +198,11 @@ export function EditorTopBar({ function ProjectNameField({ title, + dirty, onRename, }: { title: string | null; + dirty: boolean; onRename: (title: string) => void; }) { const t = useScopedT("editor"); @@ -244,19 +241,30 @@ function ProjectNameField({ ); } + // No project, nothing to be unsaved — and the button reads "No project" there, + // which a marker beside it would contradict. + const modified = dirty && title !== null; + return ( - + <> + + {/* The marker above is decoration to a screen reader, and the button's + aria-label swallows anything nested in it, so the state is spelled out + here instead — the one piece of the old status badge worth keeping. */} + {modified ? {t("topbar.unsaved")} : null} + ); } From 68bf55fafffc741591634f031609b6757762e5ec Mon Sep 17 00:00:00 2001 From: Nicolas Gonzalez Date: Tue, 15 Sep 2026 00:37:46 +0200 Subject: [PATCH 04/20] fix(timeline): draw the scroll hints as keys, not as two dead buttons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pair read as generated for three reasons, and the monospace was only the loudest of them. The mono face went first. A monospace family on a two-character label is a typographic costume -- it signals "data" on something that is neither tabular nor aligned to anything. The app already had a keycap that does not use it, .recMenuKey in the add-audio menu, so .tlKbd now wears that same box rather than this toolbar inventing a second kind of key. It diverges in one place: .recMenuKey is --muted against --fg rows, while here the label beside it is already --muted, so the key takes --fg-2 to stay the emphasised half. A floor on the width keeps "⇧" and "⌃" from coming out two different sizes side by side. The outer pills went second. Each hint sat in its own bordered, filled, rounded plate -- and nothing in either one is clickable, so the border was advertising an affordance that does not exist; two of them side by side read as a pair of dead buttons. The plates are gone. The keycap is now the only bordered thing in the hint, which is the one place the border is the content rather than decoration. "Scroll" went third. It was a hardcoded English string, untranslated in all 13 locales, and it is now the Mouse glyph -- which says the same thing in every one of them. The modifiers are now engraved the way the local keyboard engraves them: ⇧ and ⌃ on macOS, Shift and Ctrl elsewhere. Deliberately NOT through formatBinding, which maps "ctrl" to ⌘ because there it means the primary modifier: the wheel handler tests e.ctrlKey literally on every platform (a Mac trackpad pinch arrives as ctrl+wheel), so ⌘ would be a lie. Only the engraving changes, never the key. Scoping the add-audio menu's key assertion is fallout worth keeping: it swept `kbd` off the whole document, so it failed the moment the toolbar taught a key of its own. It now asks each row what key it teaches. Co-Authored-By: Claude Opus 5 (1M context) --- .../ai-edition/v4/EditorShellV4.module.css | 32 +++++++++++++------ .../v4/V4Timeline.geometry.test.tsx | 9 ++++-- src/components/ai-edition/v4/V4Timeline.tsx | 16 ++++++++-- 3 files changed, 43 insertions(+), 14 deletions(-) diff --git a/src/components/ai-edition/v4/EditorShellV4.module.css b/src/components/ai-edition/v4/EditorShellV4.module.css index 71db91b6f..6e11ac4af 100644 --- a/src/components/ai-edition/v4/EditorShellV4.module.css +++ b/src/components/ai-edition/v4/EditorShellV4.module.css @@ -1419,26 +1419,38 @@ margin-left: auto; display: flex; align-items: center; - gap: 10px; + gap: 18px; } +/* Passive text: nothing here is clickable, so it carries no plate of its own — + a bordered pill around a hint advertises an affordance that isn't there, and + two of them side by side read as a pair of dead buttons. The keycap below is + the one bordered thing, because there the border IS the content. */ .tlHint { display: inline-flex; align-items: center; - gap: 7px; - height: 26px; - padding: 0 10px; - background: var(--surface-1); - border: 1px solid var(--border); - border-radius: 7px; + gap: 6px; font-size: 11px; font-weight: 500; color: var(--muted); } +/* The same keycap as .recMenuKey's, so a key looks like a key everywhere in this + app rather than this toolbar inventing a second one — plus a floor on the + width, so "⇧" and "⌃" don't come out two different sizes sitting side by side. + Deliberately NOT --font-mono: a monospace face on a two-character label is a + typographic costume, and it is what made these read as generated. The one + divergence is the text colour: .recMenuKey is --muted against --fg rows, while + here the label beside it is already --muted, so the key takes --fg-2 to stay + the emphasised half of the pair. */ .tlKbd { - font-family: var(--font-mono); - font-size: 10.5px; - font-weight: 600; + display: inline-grid; + place-items: center; + min-width: 20px; + padding: 2px 6px; + border: 1px solid var(--border-soft); + border-radius: 5px; + background: var(--surface-2); color: var(--fg-2); + font: 600 10.5px/1.4 var(--font-body); } /* Ruler + tracks live in one relative wrapper (between the toolbar and the zoom bar) so a single playhead overlay can span both. */ diff --git a/src/components/ai-edition/v4/V4Timeline.geometry.test.tsx b/src/components/ai-edition/v4/V4Timeline.geometry.test.tsx index 01269a5c5..bc7fb7d00 100644 --- a/src/components/ai-edition/v4/V4Timeline.geometry.test.tsx +++ b/src/components/ai-edition/v4/V4Timeline.geometry.test.tsx @@ -644,8 +644,13 @@ describe("V4Timeline audio lane drag", () => { // shortcuts dialog moves the menu with it instead of teaching a stale key. renderAudio(); fireEvent.click(screen.getByLabelText("toolbar.addAudioTooltip")); - const keys = Array.from(document.querySelectorAll("kbd"), (k) => k.textContent); - expect(keys).toEqual([ + // Scoped to each row rather than swept off the whole document: the toolbar + // teaches keys of its own (the scroll-gesture hints), and a document-wide + // kbd sweep makes this assertion fail whenever an unrelated key is added + // anywhere in the timeline. + const keyTaughtBy = (label: string) => + screen.getByText(label).closest("button")?.querySelector("kbd")?.textContent; + expect([keyTaughtBy("audio.addVoiceover"), keyTaughtBy("audioTrack.add")]).toEqual([ formatBinding(DEFAULT_SHORTCUTS.addVoiceover, false), formatBinding(DEFAULT_SHORTCUTS.addAudio, false), ]); diff --git a/src/components/ai-edition/v4/V4Timeline.tsx b/src/components/ai-edition/v4/V4Timeline.tsx index 28aad552c..87d9e4c14 100644 --- a/src/components/ai-edition/v4/V4Timeline.tsx +++ b/src/components/ai-edition/v4/V4Timeline.tsx @@ -6,6 +6,7 @@ import { Maximize2, MessageSquare, Mic, + Mouse, Music, Pencil, Scissors, @@ -1999,12 +2000,23 @@ export function V4Timeline({ onNextClip={onNextClip} onSeek={setCurrentTime} /> + {/* The wheel handler tests e.shiftKey and e.ctrlKey literally, on every + platform (a Mac trackpad pinch arrives as ctrl+wheel), so these are + the real keys everywhere — this must NOT go through formatBinding, + which maps "ctrl" to ⌘ because there it means the primary modifier. + Only the engraving changes: a Mac keyboard says ⇧ and ⌃. + The scroll half of the gesture is a glyph rather than the word + "Scroll", which was hardcoded English in all 13 locales. */}
- Shift+Scroll {t("labels.pan")} + {isMac ? "⇧" : "Shift"} + + {t("labels.pan")} - Ctrl+Scroll {t("labels.zoom")} + {isMac ? "⌃" : "Ctrl"} + + {t("labels.zoom")}
From 45628c00775a27961649a291be5ed33037342f27 Mon Sep 17 00:00:00 2001 From: Nicolas Gonzalez Date: Tue, 15 Sep 2026 00:44:18 +0200 Subject: [PATCH 05/20] fix(timecodes): set the time readouts in the UI face, not in monospace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every timecode in the editor was set in Geist Mono. Monospace was doing one real job there — keeping a readout from twitching as its digits change during playback — and `font-variant-numeric: tabular-nums` does that job in the UI face, which is how YouTube's player sets its own `1:23 / 4:56`. The monospace was buying fixed-width digits and paying for them with the look of source code on a number a person reads, not one a machine parses. Nothing here is invented: the app already had two readouts set this way, and they were the odd ones out — .tlDragTip and the HUD's recording timer. This makes them the rule. Converted: the transport's current/total, the ruler tick labels, the duration printed on a clip card, the media card's duration, the inspector's clip range, the edit-clip dialog's three headline figures and the scale under its trim track, and the lane pills (a trim pill's label is a duration, and it now holds still while the pill is dragged). The media card's file size travels with its duration even though it is not a time: they are one row of two figures, and leaving one monospace beside a proportional one is more conspicuous than either choice applied to both. The transport also loses its 0.02em tracking, which existed to loosen a mono face. A proportional one is already spaced, and YouTube tracks its timestamp at zero. Checked rather than assumed: Geist ships `tnum` (Geist Mono, for what it is worth, does not), so tabular-nums resolves to real tabular figures here and the readouts do not jitter. The ~49 remaining monospace spans are left alone deliberately — file paths, API keys, provider ids, ratios, percentages. Those are machine strings, which is what the face is for. Co-Authored-By: Claude Opus 5 (1M context) --- src/components/ai-edition/Modals.tsx | 13 +++++++++++-- .../ai-edition/NewEditorShell.module.css | 10 ++++++++-- .../ai-edition/v4/EditorShellV4.module.css | 15 +++++++++++---- .../ai-edition/v4/FloatingInspector.tsx | 8 +++++++- 4 files changed, 37 insertions(+), 9 deletions(-) diff --git a/src/components/ai-edition/Modals.tsx b/src/components/ai-edition/Modals.tsx index 371c4528b..085238963 100644 --- a/src/components/ai-edition/Modals.tsx +++ b/src/components/ai-edition/Modals.tsx @@ -1132,7 +1132,8 @@ export function EditClipModal({ style={{ display: "flex", justifyContent: "space-between", - font: "500 10px/1.4 var(--font-mono)", + font: "500 10px/1.4 var(--font-body)", + fontVariantNumeric: "tabular-nums", color: "var(--muted)", marginBottom: 4, }} @@ -1362,7 +1363,15 @@ export function EditClipModal({ function RangeStat({ label, value, testId }: { label: string; value: string; testId?: string }) { return (
- {value} + + {value} + {label} diff --git a/src/components/ai-edition/NewEditorShell.module.css b/src/components/ai-edition/NewEditorShell.module.css index f8e8034bc..b9c5016af 100644 --- a/src/components/ai-edition/NewEditorShell.module.css +++ b/src/components/ai-edition/NewEditorShell.module.css @@ -759,11 +759,17 @@ .transport .tbtn.play:hover { background: var(--fg-2); } +/* The UI face with tabular figures, not a monospace one — the same treatment + .tlDragTip and the HUD's recording timer already use. Monospace was doing one + job here, keeping the readout from twitching as digits change during playback, + and tabular-nums does that job without dressing a timecode up as source code. + No letter-spacing: that was loosening a mono face; a proportional one is + already spaced. min-width still carries the rest, since the string itself + grows a character at 10 minutes. */ .transport .time { - font-family: var(--font-mono); font-size: var(--fs-app-sm); + font-variant-numeric: tabular-nums; color: var(--fg); - letter-spacing: 0.02em; min-width: 96px; text-align: center; flex-shrink: 0; diff --git a/src/components/ai-edition/v4/EditorShellV4.module.css b/src/components/ai-edition/v4/EditorShellV4.module.css index 6e11ac4af..5eba1d552 100644 --- a/src/components/ai-edition/v4/EditorShellV4.module.css +++ b/src/components/ai-edition/v4/EditorShellV4.module.css @@ -806,13 +806,16 @@ justify-content: space-between; gap: 8px; } +/* The size travels with the duration here — they are one row of figures, and + leaving one monospace beside a proportional one is more conspicuous than + either choice on its own. */ .mediaCardStats .dur { - font-family: var(--font-mono); + font-variant-numeric: tabular-nums; font-size: 10.5px; color: var(--muted); } .mediaCardStats .size { - font-family: var(--font-mono); + font-variant-numeric: tabular-nums; font-size: 10.5px; color: var(--muted); } @@ -1604,7 +1607,7 @@ top: 12px; left: 50%; transform: translateX(-50%); - font-family: var(--font-mono); + font-variant-numeric: tabular-nums; font-size: 10px; color: var(--meta); white-space: nowrap; @@ -1653,6 +1656,9 @@ color: var(--fg); font-size: 11px; font-weight: 600; + /* A trim pill's label IS a duration, so its digits hold still while the pill + is dragged — and it keeps the "Zoom 1"/"Zoom 2" numerals aligned too. */ + font-variant-numeric: tabular-nums; white-space: nowrap; overflow: hidden; box-sizing: border-box; @@ -1982,7 +1988,8 @@ text-overflow: ellipsis; } .tlClipDuration { - font: 500 10px/1.2 var(--font-mono); + font: 500 10px/1.2 var(--font-body); + font-variant-numeric: tabular-nums; color: rgba(255, 255, 255, 0.7); white-space: nowrap; flex-shrink: 0; diff --git a/src/components/ai-edition/v4/FloatingInspector.tsx b/src/components/ai-edition/v4/FloatingInspector.tsx index 2a4a4904c..e19368389 100644 --- a/src/components/ai-edition/v4/FloatingInspector.tsx +++ b/src/components/ai-edition/v4/FloatingInspector.tsx @@ -231,7 +231,13 @@ export function FloatingInspector({ {te("editClipDialog.clipLabel", { index: index + 1 })} - + {formatSeconds(clip.timelineStartSec)}–{formatSeconds(clip.timelineEndSec)} From a86fea42664bc156879ae61730b27ea48ffa8847 Mon Sep 17 00:00:00 2001 From: Nicolas Gonzalez Date: Tue, 15 Sep 2026 00:54:58 +0200 Subject: [PATCH 06/20] fix(topbar): let the selected mode lift off its track instead of sinking into it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The selected segment took --surface-3, and that token does not mean the same thing on each side of the theme line. The light surface scale runs DOWNWARD in lightness (--surface-1 #f1f5f9 → --surface-3 #cbd5e1) and the dark one runs upward (#14181f → #252a35). One declaration therefore produced a plate that rose by ΔL +0.014 in dark and sank by ΔL -0.251 in light: in the light theme the selected tab was the heaviest grey in the whole control, reading as pressed out rather than picked. Measured off a render, not inferred — track (242,245,249), plate (205,213,224). --surface-hi is the raised surface in both themes, so the plate now lifts on both: +0.092 in light, +0.021 in dark. The shadow was a hardcoded rgba(0,0,0,0.25) — roughly eight times the weight of the design system's own card shadow, and in flat black where the rest of the app casts slate. It takes --elev-card. Not --elev-card-soft: white on --surface-1 separates by only ΔL* ~3.6, where macOS gives its own segmented control ~9, so at 3% the plate would not have read at all and the fix would have traded "too grey" for "invisible". Not touched, and worth knowing: the unselected labels are --muted on --surface-1, which is 4.34:1 — a hair under AA. Darkening the track to --surface-2 would sharpen the plate to ΔL* ~8.4 but drop those labels to 3.86:1, so that trade is left to make deliberately rather than as a side effect of this. Co-Authored-By: Claude Opus 5 (1M context) --- .../ai-edition/v4/EditorShellV4.module.css | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/src/components/ai-edition/v4/EditorShellV4.module.css b/src/components/ai-edition/v4/EditorShellV4.module.css index 5eba1d552..873824650 100644 --- a/src/components/ai-edition/v4/EditorShellV4.module.css +++ b/src/components/ai-edition/v4/EditorShellV4.module.css @@ -423,11 +423,27 @@ text-overflow: ellipsis; white-space: nowrap; } +/* The selected segment is a plate that LIFTS off the track, the way a segmented + control has worked since the first one — so it takes --surface-hi, which is + the raised surface in both themes. + + It used to take --surface-3, and that token does not mean the same thing on + each side: the light scale runs downward in lightness (#f1f5f9 → #cbd5e1) and + the dark scale runs upward (#14181f → #252a35). So one declaration produced a + plate that rose by ΔL +0.014 in dark and sank by ΔL -0.251 in light — the + selected tab was the heaviest grey in the control, reading as pressed-out + rather than picked. + + The shadow goes to the system's own token too, in flat black no longer but in + the slate the rest of the app casts. --elev-card rather than --elev-card-soft: + white on --surface-1 separates by only ΔL* ~3.6 (macOS gives its own segmented + control ~9), so the plate needs the wider, softer half of the card shadow to + read at all — a 1px hairline alone would leave it floating. */ .modeSwitch button[aria-selected="true"] { - background: var(--surface-3); + background: var(--surface-hi); color: var(--fg-emphasis); font-weight: 600; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.25); + box-shadow: var(--elev-card); } .exportBtn { display: inline-flex; From 2c67bcdfab4a2b49b19b99fc23b2f4edbd6c5719 Mon Sep 17 00:00:00 2001 From: Nicolas Gonzalez Date: Tue, 15 Sep 2026 00:55:25 +0200 Subject: [PATCH 07/20] feat(topbar): make the language menu operable, and readable at the row that matters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The chosen language was the least readable line in the menu. It was --accent on --accent-wash: mint on a near-white tint, 2.4:1 measured off the render — so the one row a person opens this menu to find was the one row they could not read. It takes --accent-fill now, 5.2:1 in light and 6.4:1 in dark. Colour was also the ONLY thing marking it, which reaches neither a screen reader nor anyone who cannot separate mint from slate. There is a tick now, in a gutter that is always reserved so the thirteen names stay on one left edge, and the rows are menuitemradio with aria-checked rather than plain buttons with a data attribute. The rest is manners the app menu twenty lines up already had and this one did not. It opened on click and closed on an outside click, and that was all: no Escape, no arrow keys, no focus to return to, and aria-pressed on a control that opens a menu rather than toggling a state. Now: menu semantics on the trigger, ArrowDown to open it, arrows that wrap, Home/End, Escape and a pick that both hand focus back, and Tab that leaves without snatching focus back from wherever it was going. Two decisions come from the list being thirteen entries in eleven scripts. The keyboard opens onto the language you are in rather than the top of the list, so the menu shows you where you are and escaping a mis-click is free. And typeahead matches the locale code as well as the native name: "Français" can be typed, 日本語 cannot, and "ja" is what a Latin keyboard can actually produce. Keystrokes inside 600ms accumulate, so "po" reaches Português rather than stopping at the first p. The menu also has a ceiling now. Thirteen rows is taller than a short window and it simply ran off the bottom, with no way to reach what fell past the edge. The test's locale is settable rather than pinned to "en" — "en" sorts first among the thirteen, so with it fixed there is no telling "opens on the language you are in" apart from "opens on the first row". Both new expectations were checked against the old behaviour and fail on it. Co-Authored-By: Claude Opus 5 (1M context) --- .../ai-edition/v4/EditorShellV4.module.css | 33 +++- .../ai-edition/v4/EditorTopBar.test.tsx | 115 ++++++++++++- src/components/ai-edition/v4/EditorTopBar.tsx | 158 +++++++++++++++--- 3 files changed, 281 insertions(+), 25 deletions(-) diff --git a/src/components/ai-edition/v4/EditorShellV4.module.css b/src/components/ai-edition/v4/EditorShellV4.module.css index 873824650..d67551709 100644 --- a/src/components/ai-edition/v4/EditorShellV4.module.css +++ b/src/components/ai-edition/v4/EditorShellV4.module.css @@ -312,7 +312,12 @@ position: absolute; top: calc(100% + 4px); right: 0; - min-width: 160px; + min-width: 184px; + /* Thirteen rows is taller than a short window, and the menu used to simply run + off the bottom with no way to reach what fell past the edge. */ + max-height: min(360px, calc(100vh - 96px)); + overflow-y: auto; + overscroll-behavior: contain; background: var(--surface); border: 1px solid var(--border); border-radius: var(--r-md); @@ -320,10 +325,16 @@ padding: 4px; z-index: 60; } +/* A fixed gutter for the tick, so every name sits on one left edge whether or not + it is the chosen one. */ .langMenuItem { - display: block; + display: grid; + grid-template-columns: 14px 1fr; + align-items: center; + gap: 8px; width: 100%; text-align: left; + white-space: nowrap; padding: 6px 10px; border: 0; background: transparent; @@ -333,13 +344,29 @@ font: 500 12px var(--font-body); transition: background var(--motion-fast) var(--ease), color var(--motion-fast) var(--ease); } +.langMenuCheck { + display: grid; + place-items: center; + color: var(--accent-fill); +} .langMenuItem:hover { background: var(--surface-2); color: var(--fg); } +/* Arrow keys move focus through this list, so the ring has to be visible — it is + the only thing saying where you are before you commit. */ +.langMenuItem:focus-visible { + outline: none; + background: var(--surface-2); + color: var(--fg); + box-shadow: var(--focus-ring); +} +/* --accent-fill, not --accent: mint on the near-white wash was 2.5:1, which made + the language you are actually in the least readable line in the menu. The tick + above carries the state anyway; this only has to stay legible. */ .langMenuItem[data-active="true"] { background: var(--accent-wash); - color: var(--accent); + color: var(--accent-fill); } /* One box for every short locale label — see --topbar-lang-label-w. Without it diff --git a/src/components/ai-edition/v4/EditorTopBar.test.tsx b/src/components/ai-edition/v4/EditorTopBar.test.tsx index 2ce8ca1c6..00598c214 100644 --- a/src/components/ai-edition/v4/EditorTopBar.test.tsx +++ b/src/components/ai-edition/v4/EditorTopBar.test.tsx @@ -1,13 +1,18 @@ // @vitest-environment jsdom import "@testing-library/jest-dom"; -import { fireEvent, render, screen } from "@testing-library/react"; -import { describe, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; // ProjectNameField is a private helper inside EditorTopBar, so reach it through // the public topbar instead. The translator echoes keys; assertions read better // against keys than against prose that drifts with copy edits. +// +// The locale is settable rather than pinned to "en": "en" sorts first among the +// thirteen, so with it fixed there is no way to tell "opens on the language you +// are in" apart from "opens on the first row". +const i18n = vi.hoisted(() => ({ locale: "en", setLocale: vi.fn() })); vi.mock("@/contexts/I18nContext", () => ({ - useI18n: () => ({ locale: "en", setLocale: () => {} }), + useI18n: () => ({ locale: i18n.locale, setLocale: i18n.setLocale }), useScopedT: () => (key: string) => key, })); @@ -15,8 +20,18 @@ vi.mock("@/hooks/useTheme", () => ({ useTheme: () => ({ theme: "dark", toggle: () => {} }), })); +import { getAvailableLocales } from "@/i18n/loader"; import { EditorTopBar } from "./EditorTopBar"; +/** Row order is the loader's, not this file's guess at it. */ +const getLocaleIndex = (code: string) => getAvailableLocales().indexOf(code); + +beforeEach(() => { + i18n.locale = "en"; + i18n.setLocale.mockClear(); +}); +afterEach(cleanup); + const noop = () => {}; function renderTopBar(projectTitle: string | null, dirty = false) { @@ -310,6 +325,100 @@ describe("EditorTopBar responsive affordances and tooltips", () => { fireEvent.click(langBtn); expect(screen.getByText("English")).toBeInTheDocument(); }); +}); + +describe("EditorTopBar language menu", () => { + const openMenu = () => { + renderTopBar("Demo Project"); + const trigger = screen.getByRole("button", { name: "topbar.changeLanguage" }); + fireEvent.click(trigger); + return { trigger, items: () => screen.getAllByRole("menuitemradio") }; + }; + + // It announced itself with aria-pressed, which says "this control is a toggle + // that is currently on" — it opens a menu. + it("announces itself as a menu trigger, not as a pressed toggle", () => { + renderTopBar("Demo Project"); + const trigger = screen.getByRole("button", { name: "topbar.changeLanguage" }); + expect(trigger).toHaveAttribute("aria-haspopup", "menu"); + expect(trigger).toHaveAttribute("aria-expanded", "false"); + expect(trigger).not.toHaveAttribute("aria-pressed"); + fireEvent.click(trigger); + expect(trigger).toHaveAttribute("aria-expanded", "true"); + }); + + // The chosen language used to be marked by colour alone, which does not reach + // a screen reader and did not survive the contrast fix either. + it("marks the current language to something other than the eye", () => { + i18n.locale = "fr"; + const { items } = openMenu(); + const checked = items().filter((i) => i.getAttribute("aria-checked") === "true"); + expect(checked).toHaveLength(1); + expect(checked[0]).toHaveTextContent("Français"); + }); + + it("opens onto the language in use rather than the top of the list", () => { + i18n.locale = "fr"; + const { items } = openMenu(); + expect(document.activeElement).toBe(items()[getLocaleIndex("fr")]); + expect(document.activeElement).toHaveTextContent("Français"); + }); + + it("closes on Escape and hands focus back to the trigger", () => { + const { trigger } = openMenu(); + fireEvent.keyDown(screen.getByRole("menu"), { key: "Escape" }); + expect(screen.queryByRole("menu")).not.toBeInTheDocument(); + expect(document.activeElement).toBe(trigger); + }); + + it("walks the list with the arrow keys, wrapping at both ends", () => { + const { items } = openMenu(); + const menu = screen.getByRole("menu"); + const all = items(); + expect(document.activeElement).toBe(all[0]); + fireEvent.keyDown(menu, { key: "ArrowDown" }); + expect(document.activeElement).toBe(all[1]); + fireEvent.keyDown(menu, { key: "ArrowUp" }); + fireEvent.keyDown(menu, { key: "ArrowUp" }); + expect(document.activeElement).toBe(all[all.length - 1]); + fireEvent.keyDown(menu, { key: "Home" }); + expect(document.activeElement).toBe(all[0]); + fireEvent.keyDown(menu, { key: "End" }); + expect(document.activeElement).toBe(all[all.length - 1]); + }); + + it("jumps to a language by its typed name", () => { + openMenu(); + fireEvent.keyDown(screen.getByRole("menu"), { key: "f" }); + expect(document.activeElement).toHaveTextContent("Français"); + }); + + // Typing the native name only reaches the ones a Latin keyboard can produce, + // so the locale code has to match too or 日本語 is unreachable by keyboard. + it("jumps by locale code for the names a keyboard cannot type", () => { + const { items } = openMenu(); + fireEvent.keyDown(screen.getByRole("menu"), { key: "j" }); + expect(document.activeElement).toBe(items()[getLocaleIndex("ja-JP")]); + }); + + // Consecutive keys inside the window accumulate, which is what makes "po" + // reach Português instead of stopping at the first p. + it("accumulates consecutive keystrokes into one search", () => { + const { items } = openMenu(); + const menu = screen.getByRole("menu"); + fireEvent.keyDown(menu, { key: "p" }); + expect(document.activeElement).toBe(items()[getLocaleIndex("pt-BR")]); + fireEvent.keyDown(menu, { key: "o" }); + expect(document.activeElement).toBe(items()[getLocaleIndex("pt-BR")]); + }); + + it("picks a language and closes, returning focus to the trigger", () => { + const { trigger, items } = openMenu(); + fireEvent.click(items()[getLocaleIndex("fr")]); + expect(i18n.setLocale).toHaveBeenCalledWith("fr"); + expect(screen.queryByRole("menu")).not.toBeInTheDocument(); + expect(document.activeElement).toBe(trigger); + }); // Language and theme are the bar's two app-wide preferences, and they are meant // to read as one pair at its right end. Nothing about either button says where it diff --git a/src/components/ai-edition/v4/EditorTopBar.tsx b/src/components/ai-edition/v4/EditorTopBar.tsx index 9c68acbbc..381c4c5f6 100644 --- a/src/components/ai-edition/v4/EditorTopBar.tsx +++ b/src/components/ai-edition/v4/EditorTopBar.tsx @@ -1,4 +1,5 @@ import { + Check, ChevronDown, Download, Film, @@ -17,10 +18,17 @@ import { Sparkles, Sun, } from "lucide-react"; -import { type KeyboardEvent as ReactKeyboardEvent, useEffect, useRef, useState } from "react"; +import { + type KeyboardEvent as ReactKeyboardEvent, + useEffect, + useMemo, + useRef, + useState, +} from "react"; import logoMark from "@/assets/openscreen-mark.png"; import { useI18n, useScopedT } from "@/contexts/I18nContext"; import { useTheme } from "@/hooks/useTheme"; +import type { Locale } from "@/i18n/config"; import { getAvailableLocales, getLocaleName, getLocaleShort } from "@/i18n/loader"; import styles from "./EditorShellV4.module.css"; @@ -463,11 +471,28 @@ function AppMenu({ actions }: { actions: TopBarActions }) { ); } +/** The bar's one settings menu that is not the app menu. + * + * It was a click-only popover: no Escape, no arrow keys, no focus to return to, and + * `aria-pressed` on a control that opens a menu rather than toggling a state. The + * app menu twenty lines up already does all of this properly, so this follows it + * rather than inventing a second set of manners for the same gesture. + * + * The list is thirteen entries in eleven scripts, which shapes two decisions below: + * the keyboard opens onto the language you are already in rather than the top of + * the list, and typeahead matches the locale code as well as the native name — + * nobody reaches 日本語 by typing its own name on a Latin keyboard. */ function LangButton() { const { locale, setLocale } = useI18n(); const t = useScopedT("editor"); const [open, setOpen] = useState(false); const ref = useRef(null); + const menuRef = useRef(null); + const triggerRef = useRef(null); + // Stable across renders so it can be a dependency below without re-firing. + const locales = useMemo(() => getAvailableLocales(), []); + const typeahead = useRef({ buffer: "", at: 0 }); + useEffect(() => { if (!open) return; const onDocClick = (e: MouseEvent) => { @@ -476,38 +501,133 @@ function LangButton() { document.addEventListener("mousedown", onDocClick); return () => document.removeEventListener("mousedown", onDocClick); }, [open]); + + // Land on the current language, not on the top of the list: opening the menu + // should show you where you are, and it makes escaping a mis-click free. + useEffect(() => { + if (!open) return; + const items = menuRef.current?.querySelectorAll('[role="menuitemradio"]'); + const at = locales.indexOf(locale); + items?.[at >= 0 ? at : 0]?.focus(); + }, [open, locale, locales]); + + const close = (restoreFocus: boolean) => { + setOpen(false); + // Escape and a pick hand focus back to the trigger; a click does not, because + // the pointer user did not come from there and a ring appearing under the + // cursor reads as a bug. + if (restoreFocus) triggerRef.current?.focus(); + }; + + const itemsInMenu = () => + Array.from( + menuRef.current?.querySelectorAll('[role="menuitemradio"]') ?? [], + ); + + const onMenuKeyDown = (e: ReactKeyboardEvent) => { + if (e.key === "Escape") { + e.preventDefault(); + close(true); + return; + } + // Tabbing out is a legitimate way to leave; closing without stealing focus + // back lets it land wherever Tab was going. + if (e.key === "Tab") { + setOpen(false); + return; + } + const list = itemsInMenu(); + if (list.length === 0) return; + const at = list.indexOf(document.activeElement as HTMLButtonElement); + if (e.key === "ArrowDown" || e.key === "ArrowUp") { + e.preventDefault(); + const next = e.key === "ArrowDown" ? at + 1 : at - 1; + // Wraps both ways; `at` is -1 when focus escaped the list, and ArrowDown + // then lands on 0. + list[(next + list.length) % list.length]?.focus(); + return; + } + if (e.key === "Home" || e.key === "End") { + e.preventDefault(); + (e.key === "Home" ? list[0] : list[list.length - 1])?.focus(); + return; + } + if (e.key.length !== 1 || e.metaKey || e.ctrlKey || e.altKey) return; + const now = Date.now(); + const buffer = now - typeahead.current.at < 600 ? typeahead.current.buffer + e.key : e.key; + typeahead.current = { buffer, at: now }; + const needle = buffer.toLowerCase(); + // The code as well as the name: "Français" is reachable by typing it, 日本語 + // is not, and "ja" is what a Latin keyboard can actually produce. + const hit = locales.findIndex( + (code) => + getLocaleName(code).toLowerCase().startsWith(needle) || + code.toLowerCase().startsWith(needle), + ); + if (hit >= 0) { + e.preventDefault(); + list[hit]?.focus(); + } + }; + + const choose = (code: Locale) => { + setLocale(code); + close(true); + }; + return (
{open ? ( -
- {getAvailableLocales().map((code) => ( - - ))} +
+ {locales.map((code) => { + const active = code === locale; + return ( + + ); + })}
) : null}
From fa36b79cd0244f9eea65690c54d8a840c7abace1 Mon Sep 17 00:00:00 2001 From: Nicolas Gonzalez Date: Tue, 15 Sep 2026 01:04:17 +0200 Subject: [PATCH 08/20] fix(chat): rebuild the strip's two readouts around what they actually say MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The model button and the context badge sat side by side and were wrong in the same way: both wore a monospace face over prose, and both let one decoration stand in for the information. THE MODEL BUTTON. Its label is two different kinds of string. With a provider set it is a bare model id — "claude-opus-5", "gpt-4o-mini" — which is a machine string and keeps monospace. With none set it is a sentence asking you to pick one, and the whole button was monospace either way, so "Configurer le modèle d'IA" rendered as a terminal transcript. The face follows the content now, not the slot. Its glyph was three stacked lines hand-rolled as inline SVG in a codebase that imports lucide everywhere else — a navigation hamburger, which names the mechanism (a menu opens) rather than the subject. It takes Sparkles, which is what the app menu and the timeline already put on AI, and AI is what this button configures. Its aria-label was "Model", which overrides everything inside a button — so a screen reader was told the word "Model" and never which one, nor that none was set. Configured, it now reads the existing `currentModel` sentence with the id after it; unconfigured, the label is dropped entirely, because the visible text IS the instruction and naming the button again talks over it. And 4px of right padding, cut for a trailing element this button no longer has, left the label running into the edge of its own pill. Measured off the render: 10.5px left, 4.5px right. The right side now gets slightly MORE than the left — the left edge is followed by a glyph, which carries its own whitespace, and a text edge needs more room than a mark to sit the same distance away. THE CONTEXT BADGE. A dot, a percentage and a --success wash, and three things wrong with it. The colour never moved: the class was static, so it was the same green at 0% as at 99% — a status signal that reports no status. Its text sat at 2.4:1 on its own background, measured off the render. And a percentage is a ratio against a ceiling, which a bar shows at a glance and a number only shows to someone who stops to read it. It is a meter now, with the number kept beside it for the precision the bar cannot give. Fill and track are two steps of one ramp, and the colour finally means something — mint while there is room, --warn past 80%, where the next few turns start evicting history. Never colour alone: crossing the threshold also puts a glyph in the row, because --warn is below 3:1 on this surface by design and the pairing is what makes it legible. The label stays --muted rather than turning amber, which would have rebuilt the exact trap the mint pill fell into. It stops at --warn rather than escalating to red: this design system reserves red for REC, cut, skip and trim, and a fourth meaning would blunt it. Co-Authored-By: Claude Opus 5 (1M context) --- src/components/ai-edition/LeftPanel.tsx | 84 +++++++++++++------ .../ai-edition/NewEditorShell.module.css | 70 ++++++++++++---- 2 files changed, 112 insertions(+), 42 deletions(-) diff --git a/src/components/ai-edition/LeftPanel.tsx b/src/components/ai-edition/LeftPanel.tsx index 678b7160c..66ddc4420 100644 --- a/src/components/ai-edition/LeftPanel.tsx +++ b/src/components/ai-edition/LeftPanel.tsx @@ -1,4 +1,4 @@ -import { ArrowLeft, Check, Loader2, X } from "lucide-react"; +import { ArrowLeft, Check, Loader2, Sparkles, TriangleAlert, X } from "lucide-react"; import { useCallback, useEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { toast } from "sonner"; @@ -24,6 +24,7 @@ import { } from "../../../electron/ai-edition/provider-registry"; import { ChatWelcome } from "./ChatWelcome"; import { canSendChat } from "./chatAvailability"; +import type { ChatBudget } from "./chatBudget"; import { ChatHistoryModal } from "./Modals"; import styles from "./NewEditorShell.module.css"; import { useChatBudget } from "./useChatBudget"; @@ -1019,16 +1020,7 @@ export function ChatStripPanel() {
- - - {t("chat.contextPercent", { percent: Math.min(100, Math.round(budget.ratio * 100)) })} - + {reasoningLabel ? (