{cropPreviewSource ? (
@@ -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,
}}
@@ -1361,7 +1364,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 af43610a9..d0577ec78 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;
@@ -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;
@@ -887,7 +893,7 @@
padding: 6px;
border: 1px solid var(--border);
border-radius: 12px;
- background: var(--surface-1);
+ background: var(--bg);
box-shadow: var(--elev-pop);
/* Ten shapes must cost no more room than two. */
max-height: min(var(--radix-popover-content-available-height, 60vh), 320px);
@@ -1099,7 +1105,7 @@
padding-top: 4px;
border: 1px solid var(--border);
border-radius: 12px;
- background: var(--surface-1);
+ background: var(--bg);
box-shadow: var(--elev-pop);
/* Radix measures the room actually left below the trigger and publishes it here, so the
picker uses the screen it has instead of a number guessed at authoring time. The second
@@ -1119,7 +1125,7 @@
flex-direction: column;
border: 1px solid var(--border);
border-radius: 14px;
- background: var(--surface-1);
+ background: var(--bg);
box-shadow: var(--elev-pop);
backdrop-filter: blur(18px);
-webkit-backdrop-filter: blur(18px);
@@ -1128,12 +1134,21 @@
}
/* ─── toggle (switch) ──────────────────────────────────────────── */
+/* The knob must stand out from the track in both states and both themes
+ (WCAG 1.4.11, 3:1). No single knob colour clears a pale off track and a
+ mint on track at once, so the tracks do the work instead: the off track
+ is --muted, dark enough for a light knob in light theme and light enough
+ for a dark one in dark theme, and the on track is --accent-fill. The knob
+ is --accent-on, the colour made to sit on that fill, so it flips with the
+ theme exactly as the fill beneath it does. Light: 4.8:1 off, 5.5:1 on.
+ Dark: 6.4:1 off, 7.7:1 on. The off track also reads against the panel
+ now (4.6:1 light), where --border-hi all but vanished into it. */
.toggle {
width: 36px;
height: 20px;
border: 0;
padding: 0;
- background: var(--border-hi);
+ background: var(--muted);
border-radius: 9999px;
position: relative;
cursor: pointer;
@@ -1152,7 +1167,7 @@
box-shadow: 0 1px 3px rgba(22, 23, 29, 0.2);
transition: transform 150ms ease;
}
-.toggle.isOn { background: var(--brand); }
+.toggle.isOn { background: var(--accent-fill); }
.toggle.isOn::after { transform: translateX(16px); }
.toggle:disabled { opacity: 0.5; cursor: not-allowed; }
@@ -1271,24 +1286,53 @@
gap: var(--sp-2);
margin-bottom: 6px;
}
-.ctxPill {
+/* No pill, no wash, no dot. The badge chrome made a passive readout look like a
+ status chip, and the dot was the only thing carrying the state — in a colour
+ that never changed. The row is now the meter plus its number. */
+.ctxMeter {
display: inline-flex;
align-items: center;
- gap: 5px;
- padding: 3px 9px 3px 7px;
+ gap: 7px;
+}
+/* Same-ramp track: the groove and the fill are two steps of one hue, so the bar
+ reads as "this much of that" rather than as two competing colours. */
+.ctxTrack {
+ width: 38px;
+ height: 4px;
border-radius: var(--r-pill);
- background: var(--success-soft);
- color: var(--success);
- font-family: var(--font-mono);
+ background: var(--accent-soft);
+ overflow: hidden;
+ flex-shrink: 0;
+}
+.ctxFill {
+ display: block;
+ height: 100%;
+ border-radius: inherit;
+ background: var(--accent);
+ transition: width var(--motion-base) var(--ease), background var(--motion-base) var(--ease);
+}
+.ctxLabel {
font-size: 10.5px;
font-weight: 500;
- letter-spacing: 0.02em;
+ font-variant-numeric: tabular-nums;
+ color: var(--muted);
}
-.ctxPill .d {
- width: 5px;
- height: 5px;
- border-radius: 50%;
- background: var(--success);
+/* --warn-fg, not --warn: the glyph is what makes the state legible, so it has to
+ clear 3:1 itself (4.8:1 on --bg in light theme, where --warn was 2.1:1). */
+.ctxWarnIcon {
+ color: var(--warn-fg);
+ flex-shrink: 0;
+}
+/* Past the threshold the ramp moves to the warning hue — and the glyph appears in
+ the same breath. The fill's --warn sits below 3:1 on this surface by design; the
+ icon is what makes the state legible without it, not a decoration beside it.
+ The label deliberately stays --muted: amber text would be the least readable
+ thing in the row, which is the trap the old mint pill fell into. */
+.ctxMeter[data-tight="true"] .ctxTrack {
+ background: var(--warn-soft);
+}
+.ctxMeter[data-tight="true"] .ctxFill {
+ background: var(--warn);
}
.stripActions {
margin-left: auto;
@@ -1407,17 +1451,30 @@
display: inline-flex;
align-items: center;
gap: 6px;
- padding: 5px 4px 5px 10px;
+ /* 4px on the right was cut for a trailing element this button no longer has, so
+ the label ran into the edge of its own pill. The right side gets a little MORE
+ than the left rather than matching it: 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. */
+ padding: 5px 12px 5px 10px;
border-radius: var(--r-pill);
background: var(--surface-2);
- font-family: var(--font-mono);
font-size: 11px;
color: var(--fg-2);
- letter-spacing: 0.01em;
cursor: pointer;
transition: background var(--motion-fast) var(--ease);
border: 0;
}
+/* This button's 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 the monospace the whole button used to wear. With none set
+ it is a sentence asking you to pick one, and prose in a monospace face reads
+ as a terminal transcript rather than as a button. The face follows the
+ content, not the slot. */
+.modelId {
+ font-family: var(--font-mono);
+ letter-spacing: 0.01em;
+}
.modelPicker:hover { background: var(--surface-3); }
.reasoningBtn {
display: inline-flex;
@@ -1598,7 +1655,7 @@
top: -20px;
height: 18px;
padding: 0 8px;
- background: var(--surface);
+ background: var(--bg);
border: 1px solid var(--border-soft);
border-radius: 9px;
color: var(--fg-2);
@@ -1882,7 +1939,11 @@
overflow: hidden;
color: var(--fg);
}
-.modalCard.wide { width: min(720px, 92vw); }
+/* Named for what it does. It was `.wide`, which narrowed: the unmodified card is
+ 960px and this modifier takes it DOWN to 720, so every dialog that wanted the
+ ordinary size had to ask for "wide" and the genuinely wide ones asked for
+ nothing. 720 is the house dialog width — three of them already sat here. */
+.modalCard.compact { width: min(720px, 92vw); }
.modalHead {
display: flex;
align-items: flex-start;
@@ -1938,11 +1999,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);
@@ -2356,9 +2417,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/ProviderSettings.tsx b/src/components/ai-edition/ProviderSettings.tsx
index 8d66681c7..4d0027cc3 100644
--- a/src/components/ai-edition/ProviderSettings.tsx
+++ b/src/components/ai-edition/ProviderSettings.tsx
@@ -174,7 +174,7 @@ function ProviderSettings({ open, onClose }: ProviderSettingsProps) {
closeOnEscape={false}
title={te("providerSettings.title")}
subtitle={te("providerSettings.subtitle")}
- wide
+ compact
>
{mode === "list" ? (
({ 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,11 +20,25 @@ 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) {
+/** The project-name / rename button, named by its aria-label rather than by the
+ * title it paints — the title is what changes between these cases. */
+const nameButton = () => screen.getByRole("button", { name: "topbar.renameProject" });
+
+function renderTopBar(projectTitle: string | null, dirty = false) {
const onRename = vi.fn();
const onShowAbout = vi.fn();
const onCheckForUpdates = vi.fn();
@@ -30,7 +49,7 @@ function renderTopBar(projectTitle: string | null) {
mode="edit"
onModeChange={noop}
projectTitle={projectTitle}
- dirty={false}
+ dirty={dirty}
canExport={false}
chatOpen={false}
actions={{
@@ -255,11 +274,51 @@ describe("EditorTopBar responsive affordances and tooltips", () => {
expect(tabs[2]).toHaveAttribute("title", "topbar.modes.rec");
});
- it("provides title tooltips on the saved status indicator", () => {
+ // The "Saved"/"Unsaved" badge is gone: unsaved work is marked on the document
+ // name instead. What has to survive that is the announcement -- the marker
+ // itself is decoration, and the rename button's aria-label swallows anything
+ // nested inside it, so a silent dot would drop the state out of the a11y tree
+ // altogether.
+ it("says nothing about saving while the document is clean", () => {
renderTopBar("Demo Project");
- const savedIndicator = screen.getByTitle("topbar.saved");
- expect(savedIndicator).toBeInTheDocument();
- expect(savedIndicator).toHaveTextContent("topbar.saved");
+ expect(screen.queryByText("topbar.unsaved")).not.toBeInTheDocument();
+ expect(screen.queryByText("topbar.saved")).not.toBeInTheDocument();
+ // Not "no description at all": the hover title describes the button on every
+ // bar. What has to be absent is the unsaved state.
+ expect(nameButton()).not.toHaveAccessibleDescription("topbar.unsaved");
+ });
+
+ // The accessible DESCRIPTION, not the mere presence of the text: a span sitting
+ // loose beside the button is announced when a reader walks past it and stays
+ // silent on the focus that matters, which is indistinguishable from the bug.
+ it("announces the unsaved state once the document is modified", () => {
+ renderTopBar("Demo Project", true);
+ expect(nameButton()).toHaveAccessibleDescription("topbar.unsaved");
+ });
+
+ // A project-less bar reads "No project", which an unsaved marker beside it
+ // would contradict.
+ it("keeps the unsaved marker off a bar with no project", () => {
+ renderTopBar(null, true);
+ expect(screen.queryByText("topbar.unsaved")).not.toBeInTheDocument();
+ expect(nameButton()).not.toHaveAccessibleDescription();
+ });
+
+ it("names every mode tab independently of the width its label is painted at", () => {
+ renderTopBar("Demo Project");
+ const tabs = screen.getAllByRole("tab");
+ expect(tabs.map((tab) => tab.getAttribute("aria-label"))).toEqual([
+ "topbar.modes.media",
+ "topbar.modes.edit",
+ "topbar.modes.rec",
+ ]);
+ // Each tab carries exactly one glyph, which is decorative: the name above
+ // is what a screen reader reads.
+ for (const tab of tabs) {
+ const icons = tab.querySelectorAll("svg");
+ expect(icons).toHaveLength(1);
+ expect(icons[0]).toHaveAttribute("aria-hidden");
+ }
});
it("keeps the brand trigger accessible by label and title even when text collapses", () => {
@@ -278,3 +337,137 @@ describe("EditorTopBar responsive affordances and tooltips", () => {
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")]);
+ });
+
+ // Two locales share the "z" code prefix. Accumulating "zz" matched nothing, so the
+ // second one could not be reached by typing at all; the repeated key has to step to
+ // the next match instead, and wrap back to the first.
+ it("cycles through the matches when the same key is pressed again", () => {
+ const { items } = openMenu();
+ const menu = screen.getByRole("menu");
+ fireEvent.keyDown(menu, { key: "z" });
+ expect(document.activeElement).toBe(items()[getLocaleIndex("zh-CN")]);
+ fireEvent.keyDown(menu, { key: "z" });
+ expect(document.activeElement).toBe(items()[getLocaleIndex("zh-TW")]);
+ fireEvent.keyDown(menu, { key: "z" });
+ expect(document.activeElement).toBe(items()[getLocaleIndex("zh-CN")]);
+ });
+
+ // A fresh letter searches past the row you are on, or typing the initial of the
+ // language already focused would leave you where you are.
+ it("moves past the focused row when its own initial is typed", () => {
+ const { items } = openMenu();
+ const menu = screen.getByRole("menu");
+ expect(document.activeElement).toBe(items()[getLocaleIndex("en")]);
+ fireEvent.keyDown(menu, { key: "e" });
+ expect(document.activeElement).toBe(items()[getLocaleIndex("es")]);
+ });
+
+ 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
+ // belongs, so without this the language selector drifts back among the file
+ // actions the first time someone reorders the header.
+ it("seats the language selector immediately before the theme toggle", () => {
+ renderTopBar("Demo Project");
+ const langBtn = screen.getByRole("button", { name: "topbar.changeLanguage" });
+ const themeBtn = screen.getByRole("button", { name: "topbar.toggleTheme" });
+ // The selector is wrapped in its own popover anchor, so the sibling that
+ // precedes the theme button is that anchor, not the button itself.
+ expect(themeBtn.previousElementSibling).toBe(langBtn.closest("div"));
+ expect(
+ langBtn.compareDocumentPosition(themeBtn) & Node.DOCUMENT_POSITION_FOLLOWING,
+ ).toBeTruthy();
+ });
+});
diff --git a/src/components/ai-edition/v4/EditorTopBar.tsx b/src/components/ai-edition/v4/EditorTopBar.tsx
index 92232b3b7..0eba5b2b0 100644
--- a/src/components/ai-edition/v4/EditorTopBar.tsx
+++ b/src/components/ai-edition/v4/EditorTopBar.tsx
@@ -1,22 +1,35 @@
import {
+ Check,
ChevronDown,
Download,
+ Film,
FolderOpen,
FolderPlus,
Info,
Keyboard,
Languages,
+ type LucideIcon,
+ MonitorSmartphone,
Moon,
PanelLeft,
RefreshCw,
Save,
+ Scissors,
Sparkles,
Sun,
} from "lucide-react";
-import { type KeyboardEvent as ReactKeyboardEvent, useEffect, useRef, useState } from "react";
+import {
+ type KeyboardEvent as ReactKeyboardEvent,
+ useEffect,
+ useId,
+ 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 { StylePresetsMenu } from "../StylePresetsMenu";
import styles from "./EditorShellV4.module.css";
@@ -46,10 +59,14 @@ interface EditorTopBarProps {
actions: TopBarActions;
}
-const MODES: Array<{ id: EditorMode; labelKey: string }> = [
- { id: "media", labelKey: "topbar.modes.media" },
- { id: "edit", labelKey: "topbar.modes.edit" },
- { id: "rec", labelKey: "topbar.modes.rec" },
+/* No tab invents a glyph: each wears the one its own stage already uses, so the
+ tab and the screen it opens name the same thing. Film is what MediaStage
+ stamps on every asset card, Scissors is V4Timeline's, and MonitorSmartphone is
+ the source picker RecStage opens with. */
+const MODES: Array<{ id: EditorMode; labelKey: string; Icon: LucideIcon }> = [
+ { id: "media", labelKey: "topbar.modes.media", Icon: Film },
+ { id: "edit", labelKey: "topbar.modes.edit", Icon: Scissors },
+ { id: "rec", labelKey: "topbar.modes.rec", Icon: MonitorSmartphone },
];
export function EditorTopBar({
@@ -91,7 +108,7 @@ export function EditorTopBar({
-
+
-
- {/* 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,
- which also takes it out of the accessibility tree. */}
-
-
-
- {t("topbar.saved")}
-
-
-
- {t("topbar.unsaved")}
-
-
- {MODES.map((m) => (
+ {MODES.map(({ id, labelKey, Icon }) => (
onModeChange(m.id)}
+ data-label={t(labelKey)}
+ onClick={() => onModeChange(id)}
>
- {t(m.labelKey)}
+
+ {t(labelKey)}
))}
{/* A preset is the whole look the right panel's panes edit — Composition, camera,
cursor — so its entry sits in the bar, reachable from every pane and mode,
- rather than in any one pane's header. */}
+ rather than in any one pane's header. It stays on the project side of the
+ bar, ahead of the two app-wide preferences. */}
+ {/* 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. */}
+
void;
}) {
const t = useScopedT("editor");
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(title ?? "");
+ const unsavedId = useId();
const startEditing = () => {
setDraft(title ?? "");
@@ -245,19 +257,38 @@ 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 (
-
- {title ?? t("topbar.noProject")}
-
+ <>
+
+ {title ?? t("topbar.noProject")}
+
+
+ {/* 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}
+ >
);
}
@@ -456,11 +487,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) => {
@@ -469,38 +517,141 @@ 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 };
+ // The same key pressed again ("zz") is not a two-letter search, which would
+ // match nothing: it asks for the next row starting with that letter, so zh-CN
+ // steps on to zh-TW and wraps back. A new single letter also starts past the
+ // focused row, or pressing it on a match would go nowhere. A longer search
+ // still includes the focused row, which is what keeps "po" on Português.
+ const repeated = [...buffer].every((ch) => ch === buffer[0]);
+ const needle = (repeated ? buffer[0] : buffer).toLowerCase();
+ const from = at < 0 ? 0 : needle.length === 1 ? at + 1 : at;
+ // 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 matches = (code: Locale) =>
+ getLocaleName(code).toLowerCase().startsWith(needle) || code.toLowerCase().startsWith(needle);
+ for (let step = 0; step < locales.length; step++) {
+ const index = (from + step) % locales.length;
+ if (matches(locales[index])) {
+ e.preventDefault();
+ list[index]?.focus();
+ return;
+ }
+ }
+ };
+
+ const choose = (code: Locale) => {
+ setLocale(code);
+ close(true);
+ };
+
return (
setOpen((v) => !v)}
+ onKeyDown={(e) => {
+ if (e.key === "ArrowDown" && !open) {
+ e.preventDefault();
+ setOpen(true);
+ }
+ }}
+ aria-haspopup="menu"
+ aria-expanded={open}
aria-label={t("topbar.changeLanguage")}
- aria-pressed={open}
>
-
+
{/* Fixed-width, centred: the short labels run from "EN" to "PT-BR" to
the CJK "简中", and letting the button size to them moved everything
to its right on each language change. */}
{getLocaleShort(locale)}
-
+
{open ? (
-
- {getAvailableLocales().map((code) => (
-
{
- setLocale(code);
- setOpen(false);
- }}
- >
- {getLocaleName(code)}
-
- ))}
+
+ {locales.map((code) => {
+ const active = code === locale;
+ return (
+ choose(code)}
+ >
+ {/* The tick, not the colour, is what says "this one". The gutter is
+ always there so the names stay on one left edge. */}
+
+ {active ? : null}
+
+ {getLocaleName(code)}
+
+ );
+ })}
) : null}
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)}
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/ai-edition/v4/V4Timeline.geometry.test.tsx b/src/components/ai-edition/v4/V4Timeline.geometry.test.tsx
index 01269a5c5..6b848f00a 100644
--- a/src/components/ai-edition/v4/V4Timeline.geometry.test.tsx
+++ b/src/components/ai-edition/v4/V4Timeline.geometry.test.tsx
@@ -446,6 +446,25 @@ describe("V4Timeline create-from-toolbar", () => {
});
});
+describe("V4Timeline scroll hints", () => {
+ // The wheel half of each gesture is painted as a Mouse glyph, which a screen reader
+ // skips, so the hint used to read "Shift Pan". Each one now carries a sentence of its
+ // own naming the wheel, and everything painted beside it is hidden so the gesture is
+ // not announced twice.
+ it.each([
+ ["labels.panHint", "labels.pan"],
+ ["labels.zoomHint", "labels.zoom"],
+ ])("speaks %s in place of the painted keycap, glyph and label", (spoken, painted) => {
+ renderTimeline();
+ const hint = screen.getByText(spoken).parentElement as HTMLElement;
+ expect(screen.getByText(spoken)).toHaveClass("sr-only");
+ expect(hint.querySelector("kbd")).toHaveAttribute("aria-hidden", "true");
+ expect(hint.querySelector("svg")).toHaveAttribute("aria-hidden", "true");
+ expect(screen.getByText(painted)).toHaveAttribute("aria-hidden", "true");
+ expect(screen.getByText(painted).parentElement).toBe(hint);
+ });
+});
+
describe("V4Timeline clip row", () => {
// Three clips = two junctions. As a flex row with `gap: 6px`, each junction
// added 6px while every clip shrank proportionally to pay for it, so a clip's
@@ -644,8 +663,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..dd230f6ab 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,35 @@ 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.
+ A glyph says nothing to a screen reader, though, which heard
+ "Shift Pan". So the painted hint is hidden from it and a
+ visually hidden sentence names the key, the wheel and the
+ action instead, with the key spelled out rather than engraved. */}
- Shift+Scroll {t("labels.pan")}
+
+ {isMac ? "⇧" : "Shift"}
+
+
+ {t("labels.pan")}
+ {t("labels.panHint", { modifier: "Shift" })}
- Ctrl+Scroll {t("labels.zoom")}
+
+ {isMac ? "⌃" : "Ctrl"}
+
+
+ {t("labels.zoom")}
+
+ {t("labels.zoomHint", { modifier: isMac ? "Control" : "Ctrl" })}
+
>
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() {
{tc("actions.save")}
diff --git a/src/i18n/locales/ar/editor.json b/src/i18n/locales/ar/editor.json
index dcef44624..d5c832342 100644
--- a/src/i18n/locales/ar/editor.json
+++ b/src/i18n/locales/ar/editor.json
@@ -57,7 +57,7 @@
"description": "أنشئ مشروعًا جديدًا أو افتح مشروعًا موجودًا.",
"titleHasAsset": "أضف فيديو للبدء",
"descriptionHasAsset": "استورد تسجيلاً لبدء التحرير.",
- "newProjectButton": "مشروع جديد + استيراد فيديو",
+ "newProjectButton": "مشروع جديد",
"importVideoButton": "استيراد فيديو",
"loadProjectButton": "فتح مشروع",
"supportedFormats": "الصيغ المدعومة: MP4، MOV، WebM، MKV، AVI، M4V، WMV",
@@ -240,6 +240,7 @@
"reasoningEffortUpdateFailed": "تعذّر تحديث جهد الاستدلال",
"contextTooltip": "{{usedTokens}} / {{budgetTokens}} رمز مقدّر",
"contextPercent": "{{percent}}% من السياق",
+ "contextTight": "السياق ممتلئ تقريبًا",
"compactContext": "ضغط السياق",
"aiSettings": "إعدادات الذكاء الاصطناعي",
"history": "السجل",
diff --git a/src/i18n/locales/ar/timeline.json b/src/i18n/locales/ar/timeline.json
index d4d77c4af..a7e463cac 100644
--- a/src/i18n/locales/ar/timeline.json
+++ b/src/i18n/locales/ar/timeline.json
@@ -22,6 +22,8 @@
"labels": {
"pan": "تحريك",
"zoom": "تكبير",
+ "panHint": "{{modifier}} + عجلة الماوس للتحريك",
+ "zoomHint": "{{modifier}} + عجلة الماوس للتكبير",
"trim": "قص",
"speed": "سرعة",
"zoomItem": "تكبير {{index}}",
diff --git a/src/i18n/locales/cs/editor.json b/src/i18n/locales/cs/editor.json
index 81afc8965..f99bd5b75 100644
--- a/src/i18n/locales/cs/editor.json
+++ b/src/i18n/locales/cs/editor.json
@@ -57,7 +57,7 @@
"description": "Vytvořte nový projekt nebo otevřete existující.",
"titleHasAsset": "Přidejte video pro začátek",
"descriptionHasAsset": "Importujte nahrávku pro zahájení úprav.",
- "newProjectButton": "Nový projekt + importovat video",
+ "newProjectButton": "Nový projekt",
"importVideoButton": "Importovat video",
"loadProjectButton": "Otevřít projekt",
"supportedFormats": "Podporované formáty: MP4, MOV, WebM, MKV, AVI, M4V, WMV",
@@ -240,6 +240,7 @@
"reasoningEffortUpdateFailed": "Nepodařilo se aktualizovat úsilí uvažování",
"contextTooltip": "{{usedTokens}} / {{budgetTokens}} odhadovaných tokenů",
"contextPercent": "{{percent}}% kontextu",
+ "contextTight": "Kontext je téměř plný",
"compactContext": "Komprimovat kontext",
"aiSettings": "Nastavení AI",
"history": "Historie",
diff --git a/src/i18n/locales/cs/timeline.json b/src/i18n/locales/cs/timeline.json
index 0c3457467..eaa5b8136 100644
--- a/src/i18n/locales/cs/timeline.json
+++ b/src/i18n/locales/cs/timeline.json
@@ -22,6 +22,8 @@
"labels": {
"pan": "Posun",
"zoom": "Přiblížení",
+ "panHint": "{{modifier}} + kolečko myši pro posun",
+ "zoomHint": "{{modifier}} + kolečko myši pro přiblížení",
"trim": "Oříznutí",
"speed": "Rychlost",
"zoomItem": "Přiblížení {{index}}",
diff --git a/src/i18n/locales/en/editor.json b/src/i18n/locales/en/editor.json
index a75b70055..f9c0349c1 100644
--- a/src/i18n/locales/en/editor.json
+++ b/src/i18n/locales/en/editor.json
@@ -57,7 +57,7 @@
"description": "Create a new project or open an existing one.",
"titleHasAsset": "Add a video to get started",
"descriptionHasAsset": "Import a recording to begin editing.",
- "newProjectButton": "New project + import video",
+ "newProjectButton": "New project",
"importVideoButton": "Import video",
"loadProjectButton": "Open project",
"supportedFormats": "Supported formats: MP4, MOV, WebM, MKV, AVI, M4V, WMV",
@@ -240,6 +240,7 @@
"reasoningEffortUpdateFailed": "Could not update reasoning effort",
"contextTooltip": "{{usedTokens}} / {{budgetTokens}} estimated tokens",
"contextPercent": "{{percent}}% context",
+ "contextTight": "Context nearly full",
"compactContext": "Compact context",
"aiSettings": "AI settings",
"history": "History",
diff --git a/src/i18n/locales/en/timeline.json b/src/i18n/locales/en/timeline.json
index fa8a50b7e..4f194bdca 100644
--- a/src/i18n/locales/en/timeline.json
+++ b/src/i18n/locales/en/timeline.json
@@ -22,6 +22,8 @@
"labels": {
"pan": "Pan",
"zoom": "Zoom",
+ "panHint": "{{modifier}} + scroll to pan",
+ "zoomHint": "{{modifier}} + scroll to zoom",
"trim": "Trim",
"speed": "Speed",
"zoomItem": "Zoom {{index}}",
diff --git a/src/i18n/locales/es/editor.json b/src/i18n/locales/es/editor.json
index 9487e6b70..24aacda04 100644
--- a/src/i18n/locales/es/editor.json
+++ b/src/i18n/locales/es/editor.json
@@ -57,7 +57,7 @@
"description": "Crea un nuevo proyecto o abre uno existente.",
"titleHasAsset": "Añade un vídeo para empezar",
"descriptionHasAsset": "Importa una grabación para empezar a editar.",
- "newProjectButton": "Nuevo proyecto + importar vídeo",
+ "newProjectButton": "Nuevo proyecto",
"importVideoButton": "Importar vídeo",
"loadProjectButton": "Abrir proyecto",
"supportedFormats": "Formatos compatibles: MP4, MOV, WebM, MKV, AVI, M4V, WMV",
@@ -240,6 +240,7 @@
"reasoningEffortUpdateFailed": "No se pudo actualizar el esfuerzo de razonamiento",
"contextTooltip": "{{usedTokens}} / {{budgetTokens}} tokens estimados",
"contextPercent": "{{percent}}% de contexto",
+ "contextTight": "Contexto casi lleno",
"compactContext": "Compactar contexto",
"aiSettings": "Configuración de IA",
"history": "Historial",
diff --git a/src/i18n/locales/es/timeline.json b/src/i18n/locales/es/timeline.json
index 045f66809..d1031c4a7 100644
--- a/src/i18n/locales/es/timeline.json
+++ b/src/i18n/locales/es/timeline.json
@@ -22,6 +22,8 @@
"labels": {
"pan": "Desplazar",
"zoom": "Zoom",
+ "panHint": "{{modifier}} + rueda del ratón para desplazar",
+ "zoomHint": "{{modifier}} + rueda del ratón para hacer zoom",
"trim": "Recortar",
"speed": "Velocidad",
"zoomItem": "Zoom {{index}}",
diff --git a/src/i18n/locales/fr/editor.json b/src/i18n/locales/fr/editor.json
index cf0de68ee..5124dd813 100644
--- a/src/i18n/locales/fr/editor.json
+++ b/src/i18n/locales/fr/editor.json
@@ -57,7 +57,7 @@
"description": "Créez un nouveau projet ou ouvrez-en un existant.",
"titleHasAsset": "Ajoutez une vidéo pour commencer",
"descriptionHasAsset": "Importez un enregistrement pour commencer à éditer.",
- "newProjectButton": "Nouveau projet + importer une vidéo",
+ "newProjectButton": "Nouveau projet",
"importVideoButton": "Importer une vidéo",
"loadProjectButton": "Ouvrir un projet",
"supportedFormats": "Formats pris en charge : MP4, MOV, WebM, MKV, AVI, M4V, WMV",
@@ -240,6 +240,7 @@
"reasoningEffortUpdateFailed": "Impossible de mettre à jour l'effort de raisonnement",
"contextTooltip": "{{usedTokens}} / {{budgetTokens}} jetons estimés",
"contextPercent": "{{percent}}% du contexte",
+ "contextTight": "Contexte presque plein",
"compactContext": "Compacter le contexte",
"aiSettings": "Réglages IA",
"history": "Historique",
diff --git a/src/i18n/locales/fr/timeline.json b/src/i18n/locales/fr/timeline.json
index cfaf9662a..c61fc3dad 100644
--- a/src/i18n/locales/fr/timeline.json
+++ b/src/i18n/locales/fr/timeline.json
@@ -22,6 +22,8 @@
"labels": {
"pan": "Panoramique",
"zoom": "Zoom",
+ "panHint": "{{modifier}} + molette pour le panoramique",
+ "zoomHint": "{{modifier}} + molette pour zoomer",
"trim": "Couper",
"speed": "Vitesse",
"zoomItem": "Zoom {{index}}",
diff --git a/src/i18n/locales/it/editor.json b/src/i18n/locales/it/editor.json
index 9f3b35aa4..77e61d205 100644
--- a/src/i18n/locales/it/editor.json
+++ b/src/i18n/locales/it/editor.json
@@ -57,7 +57,7 @@
"description": "Crea un nuovo progetto o aprine uno esistente.",
"titleHasAsset": "Aggiungi un video per iniziare",
"descriptionHasAsset": "Importa una registrazione per iniziare a modificare.",
- "newProjectButton": "Nuovo progetto + importa video",
+ "newProjectButton": "Nuovo progetto",
"importVideoButton": "Importa video",
"loadProjectButton": "Apri progetto",
"supportedFormats": "Formati supportati: MP4, MOV, WebM, MKV, AVI, M4V, WMV",
@@ -240,6 +240,7 @@
"reasoningEffortUpdateFailed": "Impossibile aggiornare lo sforzo di ragionamento",
"contextTooltip": "{{usedTokens}} / {{budgetTokens}} token stimati",
"contextPercent": "{{percent}}% di contesto",
+ "contextTight": "Contesto quasi pieno",
"compactContext": "Comprimi contesto",
"aiSettings": "Impostazioni IA",
"history": "Cronologia",
diff --git a/src/i18n/locales/it/timeline.json b/src/i18n/locales/it/timeline.json
index f0431fceb..eca04b4dc 100644
--- a/src/i18n/locales/it/timeline.json
+++ b/src/i18n/locales/it/timeline.json
@@ -22,6 +22,8 @@
"labels": {
"pan": "Panoramica",
"zoom": "Zoom",
+ "panHint": "{{modifier}} + rotellina per la panoramica",
+ "zoomHint": "{{modifier}} + rotellina per lo zoom",
"trim": "Taglio",
"speed": "Velocità",
"zoomItem": "Zoom {{index}}",
diff --git a/src/i18n/locales/ja-JP/editor.json b/src/i18n/locales/ja-JP/editor.json
index ca82e702f..894900ec6 100644
--- a/src/i18n/locales/ja-JP/editor.json
+++ b/src/i18n/locales/ja-JP/editor.json
@@ -57,7 +57,7 @@
"description": "新しいプロジェクトを作成するか、既存のプロジェクトを開いてください。",
"titleHasAsset": "動画を追加して開始",
"descriptionHasAsset": "録画をインポートして編集を開始します。",
- "newProjectButton": "新規プロジェクト+動画をインポート",
+ "newProjectButton": "新規プロジェクト",
"importVideoButton": "動画をインポート",
"loadProjectButton": "プロジェクトを開く",
"supportedFormats": "対応フォーマット:MP4、MOV、WebM、MKV、AVI、M4V、WMV",
@@ -240,6 +240,7 @@
"reasoningEffortUpdateFailed": "推論の労力を更新できませんでした",
"contextTooltip": "推定トークン数 {{usedTokens}} / {{budgetTokens}}",
"contextPercent": "コンテキスト {{percent}}%",
+ "contextTight": "コンテキストがほぼいっぱいです",
"compactContext": "コンテキストを圧縮",
"aiSettings": "AI設定",
"history": "履歴",
diff --git a/src/i18n/locales/ja-JP/timeline.json b/src/i18n/locales/ja-JP/timeline.json
index 8c8b0c76d..59cbe27e4 100644
--- a/src/i18n/locales/ja-JP/timeline.json
+++ b/src/i18n/locales/ja-JP/timeline.json
@@ -22,6 +22,8 @@
"labels": {
"pan": "移動",
"zoom": "ズーム",
+ "panHint": "{{modifier}} + スクロールで移動",
+ "zoomHint": "{{modifier}} + スクロールでズーム",
"trim": "トリム",
"speed": "再生速度",
"zoomItem": "ズーム {{index}}",
diff --git a/src/i18n/locales/ko-KR/editor.json b/src/i18n/locales/ko-KR/editor.json
index d65edaa60..ad38a29c3 100644
--- a/src/i18n/locales/ko-KR/editor.json
+++ b/src/i18n/locales/ko-KR/editor.json
@@ -57,7 +57,7 @@
"description": "새 프로젝트를 만들거나 기존 프로젝트를 여세요.",
"titleHasAsset": "시작하려면 동영상을 추가하세요",
"descriptionHasAsset": "녹화 파일을 가져와서 편집을 시작하세요.",
- "newProjectButton": "새 프로젝트 + 동영상 가져오기",
+ "newProjectButton": "새 프로젝트",
"importVideoButton": "동영상 가져오기",
"loadProjectButton": "프로젝트 열기",
"supportedFormats": "지원 형식: MP4, MOV, WebM, MKV, AVI, M4V, WMV",
@@ -240,6 +240,7 @@
"reasoningEffortUpdateFailed": "추론 노력 수준을 업데이트할 수 없습니다",
"contextTooltip": "예상 토큰 {{usedTokens}} / {{budgetTokens}}",
"contextPercent": "컨텍스트 {{percent}}%",
+ "contextTight": "컨텍스트가 거의 가득 찼습니다",
"compactContext": "컨텍스트 압축",
"aiSettings": "AI 설정",
"history": "기록",
diff --git a/src/i18n/locales/ko-KR/timeline.json b/src/i18n/locales/ko-KR/timeline.json
index 8d1ec2e88..60255b5f0 100644
--- a/src/i18n/locales/ko-KR/timeline.json
+++ b/src/i18n/locales/ko-KR/timeline.json
@@ -22,6 +22,8 @@
"labels": {
"pan": "이동",
"zoom": "줌",
+ "panHint": "{{modifier}} + 스크롤로 이동",
+ "zoomHint": "{{modifier}} + 스크롤로 줌",
"trim": "트림",
"speed": "속도",
"zoomItem": "줌 {{index}}",
diff --git a/src/i18n/locales/pt-BR/editor.json b/src/i18n/locales/pt-BR/editor.json
index e913b61dc..72f713b3b 100644
--- a/src/i18n/locales/pt-BR/editor.json
+++ b/src/i18n/locales/pt-BR/editor.json
@@ -57,7 +57,7 @@
"description": "Crie um novo projeto ou abra um existente.",
"titleHasAsset": "Adicione um vídeo para começar",
"descriptionHasAsset": "Importe uma gravação para começar a editar.",
- "newProjectButton": "Novo projeto + importar vídeo",
+ "newProjectButton": "Novo projeto",
"importVideoButton": "Importar vídeo",
"loadProjectButton": "Abrir projeto",
"supportedFormats": "Formatos suportados: MP4, MOV, WebM, MKV, AVI, M4V, WMV",
@@ -240,6 +240,7 @@
"reasoningEffortUpdateFailed": "Não foi possível atualizar o esforço de raciocínio",
"contextTooltip": "{{usedTokens}} / {{budgetTokens}} tokens estimados",
"contextPercent": "{{percent}}% de contexto",
+ "contextTight": "Contexto quase cheio",
"compactContext": "Compactar contexto",
"aiSettings": "Configurações de IA",
"history": "Histórico",
diff --git a/src/i18n/locales/pt-BR/timeline.json b/src/i18n/locales/pt-BR/timeline.json
index 2c0669cbb..6af213b9c 100644
--- a/src/i18n/locales/pt-BR/timeline.json
+++ b/src/i18n/locales/pt-BR/timeline.json
@@ -22,6 +22,8 @@
"labels": {
"pan": "Mover",
"zoom": "Zoom",
+ "panHint": "{{modifier}} + roda do mouse para mover",
+ "zoomHint": "{{modifier}} + roda do mouse para dar zoom",
"trim": "Recorte",
"speed": "Velocidade",
"zoomItem": "Zoom {{index}}",
diff --git a/src/i18n/locales/ru/editor.json b/src/i18n/locales/ru/editor.json
index d0f663c41..dabb6370c 100644
--- a/src/i18n/locales/ru/editor.json
+++ b/src/i18n/locales/ru/editor.json
@@ -57,7 +57,7 @@
"description": "Создайте новый проект или откройте существующий.",
"titleHasAsset": "Добавьте видео, чтобы начать",
"descriptionHasAsset": "Импортируйте запись, чтобы начать редактирование.",
- "newProjectButton": "Новый проект + импорт видео",
+ "newProjectButton": "Новый проект",
"importVideoButton": "Импортировать видео",
"loadProjectButton": "Открыть проект",
"supportedFormats": "Поддерживаемые форматы: MP4, MOV, WebM, MKV, AVI, M4V, WMV",
@@ -240,6 +240,7 @@
"reasoningEffortUpdateFailed": "Не удалось обновить уровень рассуждений",
"contextTooltip": "{{usedTokens}} / {{budgetTokens}} расчётных токенов",
"contextPercent": "{{percent}}% контекста",
+ "contextTight": "Контекст почти заполнен",
"compactContext": "Сжать контекст",
"aiSettings": "Настройки ИИ",
"history": "История",
diff --git a/src/i18n/locales/ru/timeline.json b/src/i18n/locales/ru/timeline.json
index e6281aaa8..d82c34032 100644
--- a/src/i18n/locales/ru/timeline.json
+++ b/src/i18n/locales/ru/timeline.json
@@ -22,6 +22,8 @@
"labels": {
"pan": "Панорамирование",
"zoom": "Масштабирование",
+ "panHint": "{{modifier}} + колесо мыши для панорамирования",
+ "zoomHint": "{{modifier}} + колесо мыши для масштабирования",
"trim": "Обрезка",
"speed": "Скорость воспроизведения",
"zoomItem": "Масштабирование {{index}}",
diff --git a/src/i18n/locales/tr/editor.json b/src/i18n/locales/tr/editor.json
index b30bc78d6..8f7e7c627 100644
--- a/src/i18n/locales/tr/editor.json
+++ b/src/i18n/locales/tr/editor.json
@@ -57,7 +57,7 @@
"description": "Yeni bir proje oluşturun veya mevcut birini açın.",
"titleHasAsset": "Başlamak için bir video ekleyin",
"descriptionHasAsset": "Düzenlemeye başlamak için bir kayıt içe aktarın.",
- "newProjectButton": "Yeni proje + video içe aktar",
+ "newProjectButton": "Yeni proje",
"importVideoButton": "Video içe aktar",
"loadProjectButton": "Proje aç",
"supportedFormats": "Desteklenen formatlar: MP4, MOV, WebM, MKV, AVI, M4V, WMV",
@@ -240,6 +240,7 @@
"reasoningEffortUpdateFailed": "Akıl yürütme çabası güncellenemedi",
"contextTooltip": "{{usedTokens}} / {{budgetTokens}} tahmini belirteç",
"contextPercent": "%{{percent}} bağlam",
+ "contextTight": "Bağlam neredeyse dolu",
"compactContext": "Bağlamı sıkıştır",
"aiSettings": "Yapay zeka ayarları",
"history": "Geçmiş",
diff --git a/src/i18n/locales/tr/timeline.json b/src/i18n/locales/tr/timeline.json
index 2a40fd1f8..02c37264e 100644
--- a/src/i18n/locales/tr/timeline.json
+++ b/src/i18n/locales/tr/timeline.json
@@ -22,6 +22,8 @@
"labels": {
"pan": "Kaydır",
"zoom": "Yakınlaştır",
+ "panHint": "Kaydırmak için {{modifier}} + fare tekerleği",
+ "zoomHint": "Yakınlaştırmak için {{modifier}} + fare tekerleği",
"trim": "Kırp",
"speed": "Hız",
"zoomItem": "Yakınlaştırma {{index}}",
diff --git a/src/i18n/locales/vi/editor.json b/src/i18n/locales/vi/editor.json
index 60db3e563..d4741c582 100644
--- a/src/i18n/locales/vi/editor.json
+++ b/src/i18n/locales/vi/editor.json
@@ -57,7 +57,7 @@
"description": "Tạo dự án mới hoặc mở một dự án hiện có.",
"titleHasAsset": "Thêm video để bắt đầu",
"descriptionHasAsset": "Nhập một bản ghi để bắt đầu chỉnh sửa.",
- "newProjectButton": "Dự án mới + nhập video",
+ "newProjectButton": "Dự án mới",
"importVideoButton": "Nhập video",
"loadProjectButton": "Mở dự án",
"supportedFormats": "Định dạng được hỗ trợ: MP4, MOV, WebM, MKV, AVI, M4V, WMV",
@@ -240,6 +240,7 @@
"reasoningEffortUpdateFailed": "Không thể cập nhật mức độ suy luận",
"contextTooltip": "{{usedTokens}} / {{budgetTokens}} token ước tính",
"contextPercent": "{{percent}}% ngữ cảnh",
+ "contextTight": "Ngữ cảnh gần đầy",
"compactContext": "Nén ngữ cảnh",
"aiSettings": "Cài đặt AI",
"history": "Lịch sử",
diff --git a/src/i18n/locales/vi/timeline.json b/src/i18n/locales/vi/timeline.json
index 9b15c0e39..298ff18e5 100644
--- a/src/i18n/locales/vi/timeline.json
+++ b/src/i18n/locales/vi/timeline.json
@@ -22,6 +22,8 @@
"labels": {
"pan": "Xoay",
"zoom": "Thu phóng",
+ "panHint": "{{modifier}} + cuộn chuột để di chuyển",
+ "zoomHint": "{{modifier}} + cuộn chuột để thu phóng",
"trim": "Cắt",
"speed": "Tốc độ",
"zoomItem": "Thu phóng {{index}}",
diff --git a/src/i18n/locales/zh-CN/editor.json b/src/i18n/locales/zh-CN/editor.json
index 15fb5cf09..b2d38487c 100644
--- a/src/i18n/locales/zh-CN/editor.json
+++ b/src/i18n/locales/zh-CN/editor.json
@@ -57,7 +57,7 @@
"description": "创建新项目或打开现有项目。",
"titleHasAsset": "添加视频以开始",
"descriptionHasAsset": "导入录制内容以开始编辑。",
- "newProjectButton": "新建项目并导入视频",
+ "newProjectButton": "新建项目",
"importVideoButton": "导入视频",
"loadProjectButton": "打开项目",
"supportedFormats": "支持的格式:MP4、MOV、WebM、MKV、AVI、M4V、WMV",
@@ -240,6 +240,7 @@
"reasoningEffortUpdateFailed": "无法更新推理强度",
"contextTooltip": "预估 {{usedTokens}} / {{budgetTokens}} 个 token",
"contextPercent": "上下文 {{percent}}%",
+ "contextTight": "上下文即将用满",
"compactContext": "压缩上下文",
"aiSettings": "AI 设置",
"history": "历史记录",
diff --git a/src/i18n/locales/zh-CN/timeline.json b/src/i18n/locales/zh-CN/timeline.json
index a5d4f897a..f7e22928a 100644
--- a/src/i18n/locales/zh-CN/timeline.json
+++ b/src/i18n/locales/zh-CN/timeline.json
@@ -22,6 +22,8 @@
"labels": {
"pan": "平移",
"zoom": "缩放",
+ "panHint": "{{modifier}} + 滚轮平移",
+ "zoomHint": "{{modifier}} + 滚轮缩放",
"trim": "剪辑",
"speed": "速度",
"zoomItem": "缩放 {{index}}",
diff --git a/src/i18n/locales/zh-TW/editor.json b/src/i18n/locales/zh-TW/editor.json
index 62f162592..7179f17aa 100644
--- a/src/i18n/locales/zh-TW/editor.json
+++ b/src/i18n/locales/zh-TW/editor.json
@@ -57,7 +57,7 @@
"description": "建立新專案或開啟現有專案。",
"titleHasAsset": "新增影片以開始",
"descriptionHasAsset": "匯入錄製內容以開始編輯。",
- "newProjectButton": "新專案+匯入影片",
+ "newProjectButton": "新專案",
"importVideoButton": "匯入影片",
"loadProjectButton": "開啟專案",
"supportedFormats": "支援的格式:MP4、MOV、WebM、MKV、AVI、M4V、WMV",
@@ -240,6 +240,7 @@
"reasoningEffortUpdateFailed": "無法更新推理強度",
"contextTooltip": "預估 {{usedTokens}} / {{budgetTokens}} 個權杖",
"contextPercent": "上下文 {{percent}}%",
+ "contextTight": "上下文即將用滿",
"compactContext": "壓縮上下文",
"aiSettings": "AI 設定",
"history": "歷史紀錄",
diff --git a/src/i18n/locales/zh-TW/timeline.json b/src/i18n/locales/zh-TW/timeline.json
index 7f5d90beb..0df0cad1a 100644
--- a/src/i18n/locales/zh-TW/timeline.json
+++ b/src/i18n/locales/zh-TW/timeline.json
@@ -22,6 +22,8 @@
"labels": {
"pan": "平移",
"zoom": "縮放",
+ "panHint": "{{modifier}} + 滾輪平移",
+ "zoomHint": "{{modifier}} + 滾輪縮放",
"trim": "剪輯",
"speed": "速度",
"zoomItem": "縮放 {{index}}",
diff --git a/src/styles/design-tokens.css b/src/styles/design-tokens.css
index 1f6bf3a56..81c92fecb 100644
--- a/src/styles/design-tokens.css
+++ b/src/styles/design-tokens.css
@@ -12,6 +12,14 @@
* depending on import order. */
:root:root {
/* canvas */
+ /* --bg is also the ground every FLOATING layer stands on — menus, popovers,
+ tooltips, the modal card, the floating inspector. What makes those read as
+ a layer above the page is their border and --elev-pop, not a lighter fill,
+ so they take the same ground as the shell and the left dock rather than a
+ --surface step. The surface scale is for things IN the flow: cards, tracks,
+ inputs, hover fills. Reaching for --surface on a popover is the drift this
+ note exists to stop; it also inverts between themes, where --surface sits
+ above --bg in light and --surface-1 collapses onto --surface in dark. */
--bg: #f8fafc;
--bg-grad: radial-gradient(ellipse 1200px 800px at 50% -200px, #ffffff 0%, #f1f5f9 80%);
--surface: #ffffff;
@@ -42,7 +50,16 @@
--brand-glow: rgba(16, 185, 129, 0.35);
--brand-soft: rgba(16, 185, 129, 0.10);
--accent: #10b981;
- --accent-on: #04221a;
+ /* Filled accent surfaces (buttons, active segments, badges) do NOT use
+ --accent: mint is a border/glow/wash colour, and no readable text sits
+ on it in light theme -- white is 2.5:1 against it and the near-black it
+ used to carry was 6.6:1 but muddy at 13px. --accent-fill is the mint
+ darkened until white text clears AA on it (5.5:1); --accent-fill-lo is
+ its hover, darker again so hover never *loses* contrast. In dark theme
+ the pair collapses back onto mint, where dark-on-mint reads at 7.7:1. */
+ --accent-fill: #047857;
+ --accent-fill-lo: #065f46;
+ --accent-on: #ffffff;
--accent-soft: rgba(16, 185, 129, 0.10);
--accent-wash: rgba(16, 185, 129, 0.06);
--accent-border: rgba(16, 185, 129, 0.26);
@@ -76,6 +93,11 @@
--danger-wash: rgba(239, 68, 68, 0.06);
--danger-hatch: rgba(239, 68, 68, 0.32);
--warn: #f59e0b;
+ /* --warn is a fill and wash hue: amber is 2.1:1 on --bg in light theme, so a
+ glyph or a word that has to carry the warning on its own takes --warn-fg,
+ the same amber darkened until it clears 3:1 for graphics and AA for text
+ (4.8:1 on --bg). Dark theme needs no darker step and keeps --warn. */
+ --warn-fg: #b45309;
--warn-soft: rgba(245, 158, 11, 0.12);
/* timeline pills */
@@ -193,6 +215,8 @@
--brand-glow: rgba(16, 185, 129, 0.40);
--brand-soft: rgba(16, 185, 129, 0.14);
--accent: #10b981;
+ --accent-fill: #10b981;
+ --accent-fill-lo: #34d399;
--accent-on: #0a0d12;
--accent-soft: rgba(16, 185, 129, 0.14);
--accent-wash: rgba(16, 185, 129, 0.06);
@@ -207,6 +231,7 @@
--danger-hatch: rgba(248, 113, 113, 0.40);
--warn: #fbbf24;
+ --warn-fg: #fbbf24;
--warn-soft: rgba(251, 191, 36, 0.14);
--annotation: #fbbf24;