diff --git a/scripts/check-docs.mjs b/scripts/check-docs.mjs index fc798edb..65119765 100644 --- a/scripts/check-docs.mjs +++ b/scripts/check-docs.mjs @@ -26,6 +26,7 @@ const LEGACY = [ "Bottombar", "Titlebar", "TranscriptEditor", + "BackgroundPane", "ai-edition-roadmap", "ai-edition-collision-analysis", "openscreen-inventory", diff --git a/src/components/ai-edition/NewEditorShell.module.css b/src/components/ai-edition/NewEditorShell.module.css index 833dda36..155b665e 100644 --- a/src/components/ai-edition/NewEditorShell.module.css +++ b/src/components/ai-edition/NewEditorShell.module.css @@ -987,6 +987,99 @@ color: var(--fg-2); margin: 4px var(--sp-4) 8px; } +/* ─── action menu (a button's list of things it could do) ─────── */ +/* Sits where a Toggle would; reads as the current value, not as on/off. */ +.rowAction { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 5px 8px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--bg); + color: var(--fg-2); + font: 500 var(--fs-app-sm)/1 var(--font-body); + font-variant-numeric: tabular-nums; + cursor: pointer; + transition: border-color 150ms ease, color 150ms ease; +} +.rowAction:hover:not(:disabled) { border-color: var(--brand); color: var(--fg); } +.rowAction:disabled { cursor: default; opacity: 0.5; } +.actionMenu { + min-width: 216px; + padding: 6px; + border: 1px solid var(--border); + border-radius: 12px; + background: var(--surface-1); + 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); + overflow-y: auto; + scrollbar-width: thin; + scrollbar-color: var(--border) transparent; +} +.actionMenuRow { + display: flex; + align-items: baseline; + gap: 8px; + width: 100%; + padding: 7px 8px; + border: 0; + border-radius: 8px; + background: transparent; + color: var(--fg); + cursor: pointer; + text-align: left; +} +.actionMenuRow:hover { background: var(--surface-2); } +.actionMenuRow.isActive { background: color-mix(in oklab, var(--brand), transparent 82%); } +/* Separates the fixed presets from the shapes this timeline actually holds. */ +.actionMenuGroup { + padding: 8px 8px 4px; + font: 600 10px/1 var(--font-body); + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--muted); +} +/* Resolution leads: it is the thing a user recognises about their own footage. */ +.actionMenuMain { font: 600 12.5px var(--font-display); font-variant-numeric: tabular-nums; } +/* The ratio token second, for whoever does read them — `64:27` means nothing alone. */ +.actionMenuMeta { font: 400 11px var(--font-body); color: var(--muted); } +.actionMenuCount { + margin-left: auto; + font: 500 11px var(--font-body); + font-variant-numeric: tabular-nums; + color: var(--meta); +} + +/* A section title that carries an action on its right. */ +.sectionHead { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin: 4px var(--sp-4) 8px; +} +.sectionHead .sectionLabel { margin: 0; } +/* Deliberately quieter than the settings it overwrites: it is reached on purpose, not + stumbled into, and it must not read as a fifth control in the group. */ +.sectionAction { + padding: 3px 8px; + border: 1px solid transparent; + border-radius: 999px; + background: var(--surface-2); + color: var(--muted); + font: 500 var(--fs-app-sm)/1.4 var(--font-body); + white-space: nowrap; + cursor: pointer; + transition: color 150ms ease, border-color 150ms ease, background 150ms ease; +} +.sectionAction:hover:not(:disabled) { + border-color: var(--brand); + background: var(--bg); + color: var(--fg); +} +.sectionAction:disabled { cursor: default; opacity: 0.45; } .uploadBtn { display: flex; align-items: center; @@ -1050,6 +1143,54 @@ box-shadow: 0 0 0 2px color-mix(in oklab, var(--brand), transparent 70%); } +/* ─── background picker (collapsed trigger + floating panel) ───── */ +/* The trigger paints the CURRENT background as its own surface, so the pane shows what is + selected without spending the ~300px the inline swatch grid used to cost. */ +.bgTrigger { + display: flex; + align-items: flex-end; + justify-content: flex-end; + height: 52px; + margin: 0 var(--sp-4) 12px; + padding: 6px; + border: 1px solid var(--border); + border-radius: 10px; + background: var(--surface-2); + cursor: pointer; + overflow: hidden; + transition: border-color 150ms ease; +} +.bgTrigger:hover { border-color: var(--brand); } +/* Sits ON an arbitrary photo, so it carries its own ground rather than trusting contrast. */ +.bgTriggerChip { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 3px 7px; + border-radius: 999px; + background: color-mix(in oklab, var(--bg), transparent 10%); + color: var(--fg); + font: 500 var(--fs-app-sm)/1 var(--font-body); +} +.bgPopover { + width: 300px; + padding-top: 4px; + border: 1px solid var(--border); + border-radius: 12px; + background: var(--surface-1); + 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 + term is the tallest it ever needs: the gradient tab is ~640px (tabs + preset grid + the + 240px-wide editor, whose wheel is square). A fixed 460px cap cut that editor in half and + read as "the gradient panel is gone" — the content was there, below the fold, behind a + scroll nobody could see. Beyond 680px there is nothing left to reveal. */ + max-height: min(var(--radix-popover-content-available-height, 80vh), 680px); + overflow-y: auto; + scrollbar-width: thin; + scrollbar-color: var(--border) transparent; +} + /* ─── toggle (switch) ──────────────────────────────────────────── */ .toggle { width: 36px; diff --git a/src/components/ai-edition/RightPanes.i18n.test.tsx b/src/components/ai-edition/RightPanes.i18n.test.tsx index 36cfd8c8..64e40e7d 100644 --- a/src/components/ai-edition/RightPanes.i18n.test.tsx +++ b/src/components/ai-edition/RightPanes.i18n.test.tsx @@ -6,12 +6,12 @@ // localized text is what actually reaches the DOM. import "@testing-library/jest-dom"; -import { cleanup, render, screen } from "@testing-library/react"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; import type { ReactElement } from "react"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { I18nProvider } from "@/contexts/I18nContext"; import { LOCALE_STORAGE_KEY } from "@/i18n/config"; -import { BackgroundPane, CursorPane, LayoutPane, VideoEffectsPane } from "./RightPanes"; +import { CursorPane, LayoutPane, VideoEffectsPane } from "./RightPanes"; function renderIn(locale: string, ui: ReactElement) { localStorage.setItem(LOCALE_STORAGE_KEY, locale); @@ -28,10 +28,21 @@ afterEach(() => { }); describe("right-rail panes are localized", () => { - it("renders the background pane in French", () => { - renderIn("fr", ); - expect(screen.getByRole("heading", { name: "Arrière-plan" })).toBeInTheDocument(); + it("renders the background section of the effects pane in French", () => { + // Background stopped being its own pane when the two facets merged; it is a section + // of the effects pane now, so the same strings must survive under the new heading. + renderIn("fr", ); + expect(screen.getByRole("heading", { name: "Composition" })).toBeInTheDocument(); + expect(screen.getByText("Arrière-plan")).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Aide" })).toBeInTheDocument(); + }); + + it("renders the floating background picker in French once it is opened", () => { + // The picker collapsed into a popover so the frame sliders stay above the fold, so + // its strings are only in the DOM after the trigger is clicked — the assertion has + // to open it, or it would pass on an empty document forever. + renderIn("fr", ); + fireEvent.click(screen.getByRole("button", { name: "Arrière-plan" })); expect(screen.getByRole("button", { name: "Téléverser une image" })).toBeInTheDocument(); // wallpaper swatches interpolate their index through the catalog expect(screen.getByRole("button", { name: "Fond 1" })).toBeInTheDocument(); @@ -39,14 +50,17 @@ describe("right-rail panes are localized", () => { it("renders the video-effects pane in Spanish", () => { renderIn("es", ); - expect(screen.getByRole("heading", { name: "Efectos de video" })).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: "Composición" })).toBeInTheDocument(); expect(screen.getByText("Desenfoque de movimiento")).toBeInTheDocument(); expect(screen.getByText("Sombra")).toBeInTheDocument(); + // the merged pane's own section headers + expect(screen.getByText("Marco")).toBeInTheDocument(); + expect(screen.getByText("Movimiento")).toBeInTheDocument(); }); it("renders the layout pane in Japanese", () => { renderIn("ja-JP", ); - expect(screen.getByRole("heading", { name: "レイアウト" })).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: "カメラレイアウト" })).toBeInTheDocument(); // the preset