diff --git a/src/components/ai-edition/EditorEmptyState.test.tsx b/src/components/ai-edition/EditorEmptyState.test.tsx index be7f130b7..c33b5e8b9 100644 --- a/src/components/ai-edition/EditorEmptyState.test.tsx +++ b/src/components/ai-edition/EditorEmptyState.test.tsx @@ -128,9 +128,7 @@ describe("EditorEmptyState (new editor)", () => { renderWithI18n(); 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(); }); @@ -197,7 +195,7 @@ describe("EditorEmptyState (new editor)", () => { renderWithI18n(); 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(() => { diff --git a/src/components/ai-edition/ExportDialog.tsx b/src/components/ai-edition/ExportDialog.tsx index 5dff50acf..4daae5290 100644 --- a/src/components/ai-edition/ExportDialog.tsx +++ b/src/components/ai-edition/ExportDialog.tsx @@ -369,6 +369,10 @@ export function ExportDialog({ open, onClose, document }: ExportDialogProps) { @@ -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)", @@ -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)", diff --git a/src/components/ai-edition/LeftPanel.contextMeter.test.tsx b/src/components/ai-edition/LeftPanel.contextMeter.test.tsx new file mode 100644 index 000000000..744783c07 --- /dev/null +++ b/src/components/ai-edition/LeftPanel.contextMeter.test.tsx @@ -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 { + 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(); + const meter = container.firstElementChild as HTMLElement; + const fill = meter.querySelector("[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$/); + }); +}); diff --git a/src/components/ai-edition/LeftPanel.tsx b/src/components/ai-edition/LeftPanel.tsx index 8f88aa080..cd5bbfc6d 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 ? ( ))}
{/* 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. */} + + <> + + {/* 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 (
{open ? ( -
- {getAvailableLocales().map((code) => ( - - ))} +
+ {locales.map((code) => { + const active = code === locale; + return ( + + ); + })}
) : 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() {