Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
0fc7c6f
fix(design): give filled mint surfaces a fill colour text can sit on
NICOLASGON Sep 14, 2026
fff3ae5
refactor(topbar): seat the language selector beside the theme toggle
NICOLASGON Sep 14, 2026
b7011c8
feat(topbar): mark unsaved work on the document, and give each mode a…
NICOLASGON Sep 14, 2026
68bf55f
fix(timeline): draw the scroll hints as keys, not as two dead buttons
NICOLASGON Sep 14, 2026
45628c0
fix(timecodes): set the time readouts in the UI face, not in monospace
NICOLASGON Sep 14, 2026
a86fea4
fix(topbar): let the selected mode lift off its track instead of sink…
NICOLASGON Sep 14, 2026
2c67bcd
feat(topbar): make the language menu operable, and readable at the ro…
NICOLASGON Sep 14, 2026
fa36b79
fix(chat): rebuild the strip's two readouts around what they actually…
NICOLASGON Sep 14, 2026
1f8cf6d
fix(export): narrow the export dialog, and stop calling the narrow mo…
NICOLASGON Sep 14, 2026
1c6d378
fix(timeline): stand the toolbar's two menus on the same ground as th…
NICOLASGON Sep 14, 2026
94e160a
fix(surfaces): stand every floating layer on the ground the dock stan…
NICOLASGON Sep 14, 2026
458c1db
fix(empty-state): let the primary button name the action, not list th…
NICOLASGON Sep 14, 2026
4e66d60
fix(topbar): tie the unsaved announcement to the button it describes
NICOLASGON Sep 15, 2026
5cfe1a8
Merge branch 'main' into ux/ui-polish
NICOLASGON Sep 15, 2026
09180b9
fix(design): let the switch knob stand out from its track in both states
NICOLASGON Sep 16, 2026
99c0ae3
fix(i18n): give the Czech empty-state button the same short label
NICOLASGON Sep 16, 2026
c3ae4f1
fix(topbar): keep the language menu's focus ring on the row it opens on
NICOLASGON Sep 16, 2026
a593104
fix(chat): make the context warning legible without its colour or its…
NICOLASGON Sep 16, 2026
ce54fff
test(chat): pin what the context meter prints, draws and warns
NICOLASGON Sep 16, 2026
fb7a2ac
fix(topbar): let the language typeahead step through rows sharing a l…
NICOLASGON Sep 16, 2026
912de12
fix(timeline): say "scroll" to screen readers in the gesture hints
NICOLASGON Sep 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 2 additions & 4 deletions src/components/ai-edition/EditorEmptyState.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -128,9 +128,7 @@ describe("EditorEmptyState (new editor)", () => {
renderWithI18n(<EditorEmptyState hasProject={false} />);

expect(screen.getByText(/no project open/i)).toBeInTheDocument();
expect(
screen.getByRole("button", { name: /new project \+ import video/i }),
).toBeInTheDocument();
expect(screen.getByRole("button", { name: /^new project$/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /open project/i })).toBeInTheDocument();
});

Expand Down Expand Up @@ -197,7 +195,7 @@ describe("EditorEmptyState (new editor)", () => {
renderWithI18n(<EditorEmptyState hasProject={false} />);

await act(async () => {
fireEvent.click(screen.getByRole("button", { name: /new project \+ import video/i }));
fireEvent.click(screen.getByRole("button", { name: /^new project$/i }));
});

await waitFor(() => {
Expand Down
11 changes: 8 additions & 3 deletions src/components/ai-edition/ExportDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -369,6 +369,10 @@ export function ExportDialog({ open, onClose, document }: ExportDialogProps) {
<ModalShell
open={open}
onClose={handleClose}
// Nothing here wants 960px: the widest row is three quality cards, and at the
// default each one got ~290px to hold "720p" over "1280 × 720". The choices
// were swimming in their own plates.
compact
title={t("exportDialog.title")}
subtitle={t("exportDialog.subtitle")}
>
Expand Down Expand Up @@ -717,8 +721,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)",
Expand Down Expand Up @@ -884,7 +889,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)",
Expand Down
60 changes: 60 additions & 0 deletions src/components/ai-edition/LeftPanel.contextMeter.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
// @vitest-environment jsdom
// The chat strip's context meter: the number it prints, the bar it draws, and the
// warning it raises once the conversation nears its (estimated) ceiling. The warning is
// a colour change plus a glyph for sighted users, and the glyph is aria-hidden, so the
// words a screen reader gets in its place are pinned here alongside the rest.

import "@testing-library/jest-dom";
import { cleanup, render } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import type { ChatBudget } from "./chatBudget";
import { ContextMeter } from "./LeftPanel";

// Echoes the key, plus the percentage when one is passed, so assertions read against
// keys rather than against copy that moves with every revision.
function t(key: string, vars?: Record<string, string | number>): string {
return vars && "percent" in vars ? `${key}:${vars.percent}` : key;
}

function budget(ratio: number): ChatBudget {
return { usedTokens: Math.round(ratio * 80_000), budgetTokens: 80_000, ratio };
}

function renderMeter(ratio: number) {
const { container } = render(<ContextMeter budget={budget(ratio)} t={t} />);
const meter = container.firstElementChild as HTMLElement;
const fill = meter.querySelector<HTMLElement>("[aria-hidden] > span");
return { meter, fill };
}

afterEach(cleanup);

describe("ContextMeter", () => {
it("rounds the ratio to a whole percentage, in the label and the bar alike", () => {
const { meter, fill } = renderMeter(0.426);
expect(meter).toHaveTextContent("chat.contextPercent:43");
expect(fill).toHaveStyle({ width: "43%" });
});

it("caps at 100% when the estimate runs past the ceiling", () => {
const { meter, fill } = renderMeter(1.37);
expect(meter).toHaveTextContent("chat.contextPercent:100");
expect(fill).toHaveStyle({ width: "100%" });
});

it("stays out of the warning state just below the threshold", () => {
const { meter } = renderMeter(0.799);
expect(meter).toHaveAttribute("data-tight", "false");
expect(meter.querySelector("svg")).toBeNull();
expect(meter).not.toHaveTextContent("chat.contextTight");
});

it("warns from the threshold on, with a glyph and with words a screen reader gets", () => {
const { meter } = renderMeter(0.8);
expect(meter).toHaveAttribute("data-tight", "true");
expect(meter.querySelector("svg")).toHaveAttribute("aria-hidden", "true");
// The glyph is hidden from assistive tech, so the state has to arrive as text,
// and before the number it qualifies.
expect(meter).toHaveTextContent(/^chat\.contextTight\s*chat\.contextPercent:80$/);
});
});
100 changes: 71 additions & 29 deletions src/components/ai-edition/LeftPanel.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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";
Expand Down Expand Up @@ -1019,16 +1020,7 @@ export function ChatStripPanel() {
<div className={styles.panelHeader}>
<div className={styles.chatStrip}>
<div className={styles.chatStripRow}>
<span
className={styles.ctxPill}
title={t("chat.contextTooltip", {
usedTokens: budget.usedTokens,
budgetTokens: budget.budgetTokens,
})}
>
<span className={styles.d} aria-hidden />
{t("chat.contextPercent", { percent: Math.min(100, Math.round(budget.ratio * 100)) })}
</span>
<ContextMeter budget={budget} t={t} />
<span className={styles.stripActions}>
<button
type="button"
Expand Down Expand Up @@ -1486,26 +1478,21 @@ export function ChatStripPanel() {
ref={modelButtonRef}
type="button"
className={styles.modelPicker}
aria-label={t("chat.modelLabel")}
// Unconfigured, the visible text IS the instruction, so naming the
// button again would talk over it. Configured, the text is a bare
// model id, which needs saying what it is — `currentModel` already
// carries that sentence, and its own punctuation with it.
aria-label={llmConfig ? `${t("chat.currentModel")} ${modelLabel}` : undefined}
aria-haspopup="menu"
aria-expanded={modelPopoverOpen}
onClick={toggleModelPopoverOpen}
>
<svg
width={12}
height={12}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="3" y1="6" x2="21" y2="6" />
<line x1="3" y1="12" x2="21" y2="12" />
<line x1="3" y1="18" x2="21" y2="18" />
</svg>
<span>{modelLabel}</span>
{/* Sparkles is what the app menu and the timeline already put on AI,
and it is what this button configures. The three stacked lines it
replaces are a navigation hamburger — they said "menu", which is
the mechanism, not the subject. */}
<Sparkles size={12} aria-hidden />
<span className={llmConfig ? styles.modelId : undefined}>{modelLabel}</span>
</button>
{reasoningLabel ? (
<button
Expand Down Expand Up @@ -1665,8 +1652,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)",
Expand All @@ -1683,3 +1670,58 @@ export function ChatStripPanel() {
</aside>
);
}

/** How full the chat's context window is.
*
* This was a mint pill: a dot, a percentage, and a --success wash. Three things were
* wrong with it. The colour never moved — the class was static, so it was the same
* green at 0% as it would be at 99%, which is a status signal that reports no status.
* Its text sat at 2.4:1 on its own background. And a percentage is a ratio against a
* ceiling, which is a quantity a bar shows at a glance and a number only shows to
* someone who stops to read it.
*
* So: a meter, with the number kept beside it for the precision the bar cannot give.
* The fill and its track come from one ramp, and the colour now means something —
* mint while there is room, --warn past the point where the next few turns will start
* evicting history. Never colour alone: crossing the threshold also puts a glyph in
* the row, because the amber fill is below 3:1 on this surface by design and the
* glyph, in the darker --warn-fg, is what makes it legible. The glyph is decorative to
* a screen reader, so the same moment also adds the state in words ahead of the number.
*
* 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. */
const CONTEXT_TIGHT_RATIO = 0.8;

export function ContextMeter({
budget,
t,
}: {
budget: ChatBudget;
t: ReturnType<typeof useScopedT>;
}) {
const percent = Math.min(100, Math.round(budget.ratio * 100));
const tight = budget.ratio >= CONTEXT_TIGHT_RATIO;
return (
<span
className={styles.ctxMeter}
data-tight={tight}
title={t("chat.contextTooltip", {
usedTokens: budget.usedTokens,
budgetTokens: budget.budgetTokens,
})}
>
{/* Decorative: the label beside it already states the number, and announcing
the same ratio twice is noise rather than access. */}
<span className={styles.ctxTrack} aria-hidden>
<span className={styles.ctxFill} style={{ width: `${percent}%` }} />
</span>
{tight ? (
<>
<TriangleAlert size={11} className={styles.ctxWarnIcon} aria-hidden />
<span className="sr-only">{t("chat.contextTight")}</span>
</>
) : null}
<span className={styles.ctxLabel}>{t("chat.contextPercent", { percent })}</span>
</span>
);
}
29 changes: 20 additions & 9 deletions src/components/ai-edition/Modals.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,12 +42,13 @@ export function ModalShell({
closeOnEscape = true,
title,
subtitle,
wide,
compact,
children,
}: BaseModalProps & {
title: string;
subtitle?: string;
wide?: boolean;
/** 720px instead of the 960px default — the ordinary dialog width. */
compact?: boolean;
/** Off for a dialog that handles Escape itself — two listeners both fire for one
* keypress, and this one's `onClose` wins whatever order they registered in. */
closeOnEscape?: boolean;
Expand Down Expand Up @@ -83,7 +84,7 @@ export function ModalShell({
aria-labelledby="modal-title"
>
<div className={styles.modalBackdrop} aria-hidden onClick={onClose} />
<div className={`${styles.modalCard} ${wide ? styles.wide : ""}`}>
<div className={`${styles.modalCard} ${compact ? styles.compact : ""}`}>
<header className={styles.modalHead}>
<div>
<h2 id="modal-title">{title}</h2>
Expand Down Expand Up @@ -145,7 +146,7 @@ export function OpenProjectModal({
onClose={onClose}
title={t("openProjectDialog.title")}
subtitle={t("openProjectDialog.subtitle")}
wide
compact
>
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 12 }}>
<FolderOpen size={14} style={{ color: "var(--muted)" }} />
Expand Down Expand Up @@ -276,7 +277,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)",
Expand Down Expand Up @@ -537,7 +539,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",
Expand Down Expand Up @@ -1026,7 +1028,7 @@ export function EditClipModal({
onClose={onClose}
title={t("editClipDialog.title")}
subtitle={assetMeta?.label ?? undefined}
wide
compact
>
<div ref={cropFrameRef} style={previewBoxStyle(videoAspectRatio)}>
{cropPreviewSource ? (
Expand Down Expand Up @@ -1131,7 +1133,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,
}}
Expand Down Expand Up @@ -1361,7 +1364,15 @@ export function EditClipModal({
function RangeStat({ label, value, testId }: { label: string; value: string; testId?: string }) {
return (
<div data-testid={testId} style={{ display: "flex", flexDirection: "column", gap: 2 }}>
<strong style={{ font: "600 15px/1.2 var(--font-mono)", color: "var(--fg)" }}>{value}</strong>
<strong
style={{
font: "600 15px/1.2 var(--font-body)",
fontVariantNumeric: "tabular-nums",
color: "var(--fg)",
}}
>
{value}
</strong>
<small style={{ font: "500 10px/1.4 var(--font-body)", color: "var(--muted)" }}>
{label}
</small>
Expand Down
Loading