From 5f2a6152a6b15f50db78f85a42263a306a266ff7 Mon Sep 17 00:00:00 2001 From: Darrell van Swinderen Date: Tue, 22 Sep 2026 23:54:27 +0200 Subject: [PATCH 1/2] test(e2e): exports from one game land in that game's folder Two assertions still expected every render in the flat top level `Exports/`. Since #17 a timeline cut from one game lands in `/Exports/` and belongs to that game, which is what both tests build, so both failed whenever the suite ran. The full suite had not run since #17 merged, which is how they went unnoticed. `features.spec.ts` and `measure.spec.ts`: 13 passed. Co-Authored-By: Claude Opus 5.5 (1M context) --- tests/e2e/features.spec.ts | 7 ++++--- tests/e2e/measure.spec.ts | 6 ++++-- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/tests/e2e/features.spec.ts b/tests/e2e/features.spec.ts index 135b4aae..83dcde93 100644 --- a/tests/e2e/features.spec.ts +++ b/tests/e2e/features.spec.ts @@ -150,7 +150,7 @@ test.describe('features survive the port', () => { test('an export renders and registers the result', async () => { const list = await call('GET', '/clips', undefined, { pageSize: 50 }); - const clip = (list.body as { items: Array<{ id: number }> }).items[0]; + const clip = (list.body as { items: Array<{ id: number; game: string }> }).items[0]; const started = await call('POST', '/clips/export', { outputName: 'e2e_export', @@ -176,8 +176,9 @@ test.describe('features survive the port', () => { expect(final?.clip).toBeTruthy(); // Renders land in "Exports", not "Editor": a top level folder is a game name // here, so every export used to grow a game beside Battlefield 6 that shared - // its name with a screen. - expect(statSync(join(ctx.videosRoot, 'Exports', 'e2e_export.mp4')).size).toBeGreaterThan(0); + // its name with a screen. A timeline cut from one game lands in that game's + // own `Exports/` folder. + expect(statSync(join(ctx.videosRoot, clip.game, 'Exports', 'e2e_export.mp4')).size).toBeGreaterThan(0); }); test('publishing is absent rather than broken with no publisher', async () => { diff --git a/tests/e2e/measure.spec.ts b/tests/e2e/measure.spec.ts index cb674f86..2647326a 100644 --- a/tests/e2e/measure.spec.ts +++ b/tests/e2e/measure.spec.ts @@ -263,8 +263,10 @@ test.describe('measurements for 2.0', () => { ); console.log(`MEASURED export finished as: ${finished.message || 'no message'}`); - // The render lands in the library as a clip of its own, under Exports. - const exports = await clipsIn('Exports'); + // The render lands in the library as a clip of its own. Every clip on this + // timeline is from one game, so it belongs to that game, in its own + // `Exports/` folder, rather than to the flat top level one. + const exports = await clipsIn(game); expect(exports.items.some((clip) => clip.filename.startsWith('measure-bench'))).toBe(true); }); }); From 85d844c161b0267a4fb7c649ff876814ee47927a Mon Sep 17 00:00:00 2001 From: Darrell van Swinderen Date: Tue, 22 Sep 2026 23:54:28 +0200 Subject: [PATCH 2/2] refactor(renderer): one variant system, on cva Closes #37. `class-variance-authority`, and `cn()` over `clsx` plus the `tailwind-merge` that was already a dependency and used by nothing. The rule, decided once and written into CLAUDE.md: anything that comes in kinds is a recipe in `Base/variants.ts`, anything with one look is a constant in `Base/geometry.ts`. **One button.** `BaseButton` on `buttonVariants`: `tone` (default, strong, quiet, danger, success), `size` (md, sm, dense), `icon-only`, `reserve-icon`. All 34 `BUTTON*` string call sites across 13 files moved to it and the strings are deleted. The hand-written `switch` it used to carry is gone, and with it the disagreement: `geometry.ts` refused a danger tone while `BaseButton` had a filled one. **Danger is now a tone, outlined and never filled**, which is what every destructive button already hand-wrote (`border-danger text-danger-ink hover:bg-danger/10`); a filled red button beside the filled accent is two primaries. `strong` stays the only filled accent. A caller's `class` goes through `cn`, so `class="px-2"` means `px-2`. **Icon buttons are `BaseButton icon-only`**, not a second component: 14 hand-rolled ones in 7 files, in four greys and three disabled opacities, now share one quiet glyph recipe. The clip menu's Reka trigger calls the recipe directly. **Menu rows are `menuItemVariants`** on Reka's own items (16 rows). The destructive rows had drifted to `gap-2 px-3 py-2` against everybody else's `gap-2.5 h-[34px] px-2.5`, so Delete sat off the label column; they share one geometry now and a tone is only a colour. Rows gained a `data-[highlighted]` style, so arrowing through a menu finally shows where you are, and `:disabled` replaces the `opacity-50 pointer-events-none` each busy row carried, which also takes it out of the keyboard order. **`BasePanel`** for six bordered panels (one had drifted to `p-4`), **`BaseChip`** for six labels over video, and **`BaseEmptyState`** on cva with `size="page" | "panel"`, a tone for the icon and an `actions` slot, now used by the six empty states Storage Saver and Publisher had hand-rolled. **Ternaries that are really variants became variants**: the status dot (4 sites), the wizard step bar (2), the switch's track and thumb, the editor's two pressed toolbar toggles, and the Connections button that turns green once registered (`tone="success"`). The rest stay, because they are layout switches (`compact`, `flush`, `block`), animation states (`open ? 'rotate-180'`, collapsing `grid-rows`, fades) or one-off emphasis, and a variant nobody else asks for is a second place to look. `useClipActions.ts` loses a `variant` field nothing read, and the `ButtonVariant` and `PopoverAction` types go with it. **A guard, because this broke once while it was being done.** A `Base*` component used without an import renders as `` and nothing fails: `vue-tsc` accepts unknown tags. The card's drag grip did exactly that until a pixel diff of the card title (a few pixels wider) caught it. `tests/unit/renderer/baseImports.spec.ts` now fails on any template using a Base component it does not import (proved by removing the import), and holds the recipe rules: one filled accent, danger never filled, icon-only square at every size, a caller's padding wins, and menu rows share one geometry. Gates: - `npm run check`: green; `npm run build`: green. - `screens.spec.ts`: 8 passed, both palettes, 581 text runs measured, nothing changes size on hover or focus. - 11 UI specs (features, primitives, pager, modals, focus, measure, libraryHeader, overflow, compress, editor, safety): 47 passed; the two failures were stale export paths from #17, fixed in the commit before this one. - Before and after screenshots of 26 steps across every route: settings, stats, tag patterns, publisher and storage differ by 10 pixels, a sidebar game icon loading at a different moment. The rest is the intended quiet glyph grey on icon buttons (editor controls, theme toggle). Nothing moved. Co-Authored-By: Claude Opus 5.5 (1M context) --- CLAUDE.md | 32 +- package-lock.json | 23 ++ package.json | 2 + .../src/components/Base/BaseButton.vue | 65 ++-- src/renderer/src/components/Base/BaseChip.vue | 13 + .../src/components/Base/BaseEmptyState.vue | 49 ++- .../src/components/Base/BasePager.vue | 19 +- .../src/components/Base/BasePanel.vue | 26 ++ .../src/components/Base/BaseToggle.vue | 12 +- src/renderer/src/components/Base/cn.ts | 14 + src/renderer/src/components/Base/geometry.ts | 48 +-- src/renderer/src/components/Base/types.ts | 10 - src/renderer/src/components/Base/variants.ts | 314 ++++++++++++++++++ .../src/components/ClipDetail/ClipFacts.vue | 5 +- .../src/components/Editor/DraftsDialog.vue | 10 +- .../src/components/Editor/EditorControls.vue | 55 +-- .../src/components/Editor/MusicLibrary.vue | 19 +- .../Library/BatchOperationsToolbar.vue | 42 +-- .../components/Library/ClipActionsMenu.vue | 80 +++-- .../src/components/Library/ClipCard.vue | 11 +- .../components/Library/ClipExportBadge.vue | 6 +- .../src/components/Library/ClipFilters.vue | 10 +- .../components/Library/ClipPublishedBadge.vue | 5 +- .../src/components/Library/ClipStarButton.vue | 11 +- .../components/Library/ClipSuggestedBadge.vue | 6 +- .../components/Library/ClipsListFooter.vue | 9 +- .../components/Publish/PublisherStatCards.vue | 13 +- .../src/components/Settings/BackupsCard.vue | 24 +- .../Settings/ConnectionsSettings.vue | 31 +- .../KeyboardShortcutCustomization.vue | 22 +- .../components/Settings/MusicFolderCard.vue | 4 +- .../components/Settings/ObsSetupDialog.vue | 4 +- .../src/components/Settings/PublisherCard.vue | 22 +- .../components/Settings/RecordingSettings.vue | 49 ++- .../src/components/Settings/RescanCard.vue | 7 +- .../components/Settings/StreamDeckCard.vue | 28 +- .../components/Settings/SuggestionsCard.vue | 29 +- .../src/components/Storage/BurstCluster.vue | 16 +- .../src/components/Storage/BurstSection.vue | 22 +- .../components/Storage/GraveyardSection.vue | 47 +-- .../src/components/Storage/ReclaimSummary.vue | 5 +- .../components/Storage/StorageClipTile.vue | 20 +- .../src/composables/clips/useClipActions.ts | 24 +- src/renderer/src/views/EditorPage.vue | 7 +- src/renderer/src/views/PublisherPage.vue | 115 +++---- src/renderer/src/views/WelcomePage.vue | 4 +- tests/unit/renderer/baseImports.spec.ts | 70 ++++ 47 files changed, 960 insertions(+), 499 deletions(-) create mode 100644 src/renderer/src/components/Base/BaseChip.vue create mode 100644 src/renderer/src/components/Base/BasePanel.vue create mode 100644 src/renderer/src/components/Base/cn.ts create mode 100644 src/renderer/src/components/Base/variants.ts create mode 100644 tests/unit/renderer/baseImports.spec.ts diff --git a/CLAUDE.md b/CLAUDE.md index 4b32271f..1cbba113 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1074,6 +1074,36 @@ lined up were each given their own padding by hand, and drifted. The rules that - **Motion is 120 to 180ms**, opacity and transform, nothing bouncing. `prefers-reduced-motion` is honoured once in `styles.css`, not per component. +### Variants, and where a look is written down + +**Anything that comes in kinds is a `cva` recipe in `Base/variants.ts`; anything with one look is a +constant in `Base/geometry.ts`.** That is the whole rule, and it is decided once so it is not +decided per file. Buttons, menu rows, panels, chips, empty states, status dots, wizard steps, the +switch and the editor's toolbar toggles are recipes; heights, the icon box, the focus ring, the menu +frame and the section header are constants. + +- **One button.** `BaseButton` on `buttonVariants`: `tone` (`default | strong | quiet | danger | + success`), `size` (`md | sm | dense`), `icon-only`, `reserve-icon`. The `BUTTON*` strings that + used to sit in `geometry.ts` beside a `BaseButton` with a hand-written `switch` are gone; the two + disagreed about danger. An icon-only button is `BaseButton icon-only`, not a second component. +- **`strong` is the only filled accent**, one per region. **`danger` is outlined, never filled**: + a filled red button beside the filled accent is two primaries, and what keeps a destructive press + honest is `useConfirm` and the sentence beside it, not the colour. +- **A caller's `class` goes through `cn`** (`clsx` plus `tailwind-merge`), because `BaseButton` + turns `inheritAttrs` off for exactly that: `class="px-2"` on a recipe that says `px-3.5` means + `px-2`, not both with the stylesheet deciding. +- **A recipe is exported as well as its component**, so something that must look like a button + and is not a ` +

{{ loaded }} of {{ total }} diff --git a/src/renderer/src/components/Publish/PublisherStatCards.vue b/src/renderer/src/components/Publish/PublisherStatCards.vue index ea1c0b53..137da9d4 100644 --- a/src/renderer/src/components/Publish/PublisherStatCards.vue +++ b/src/renderer/src/components/Publish/PublisherStatCards.vue @@ -1,4 +1,5 @@ diff --git a/src/renderer/src/components/Storage/BurstSection.vue b/src/renderer/src/components/Storage/BurstSection.vue index 3478fbaa..3d48c703 100644 --- a/src/renderer/src/components/Storage/BurstSection.vue +++ b/src/renderer/src/components/Storage/BurstSection.vue @@ -1,6 +1,6 @@ diff --git a/src/renderer/src/components/Storage/StorageClipTile.vue b/src/renderer/src/components/Storage/StorageClipTile.vue index aae6043e..f09a6a50 100644 --- a/src/renderer/src/components/Storage/StorageClipTile.vue +++ b/src/renderer/src/components/Storage/StorageClipTile.vue @@ -1,4 +1,5 @@ ').pop() ?? ''; + const used = new Set([...template.matchAll(/<(Base[A-Z]\w*)\b/g)].map((m) => m[1])); + const missing = [...used].filter((name) => !new RegExp(`import ${name} from`).test(source)); + expect(missing).toEqual([]); + }); +}); + +/* + * The rules the recipes carry, held where a reordering of `variants.ts` + * would break them silently. + */ +describe('the button recipe', () => { + it('has exactly one filled accent, and it is `strong`', () => { + const tones = ['default', 'strong', 'quiet', 'danger', 'success'] as const; + const filled = tones.filter((tone) => buttonVariants({ tone }).split(' ').includes('bg-accent')); + expect(filled).toEqual(['strong']); + }); + + it('never fills danger', () => { + expect(buttonVariants({ tone: 'danger' })).not.toMatch(/(^|\s)bg-danger(\s|$)/); + }); + + it('is square when it is only a glyph, at every size', () => { + expect(cn(buttonVariants({ size: 'md', iconOnly: true }))).toMatch(/\bh-9\b.*\bw-9\b|\bw-9\b.*\bh-9\b/); + expect(cn(buttonVariants({ size: 'sm', iconOnly: true }))).toMatch(/\bw-8\b/); + expect(cn(buttonVariants({ size: 'dense', iconOnly: true }))).toMatch(/\bw-7\b/); + expect(cn(buttonVariants({ size: 'sm', iconOnly: true }))).not.toMatch(/\bpx-3\b/); + }); + + it('lets a caller override padding rather than adding a second one', () => { + const merged = cn(buttonVariants({ size: 'sm' }), 'px-2').split(' '); + expect(merged).toContain('px-2'); + expect(merged).not.toContain('px-3'); + }); +}); + +describe('the menu row recipe', () => { + it('keeps one geometry across tones, so labels form a column', () => { + const geometry = (classes: string) => + classes.split(' ').filter((c) => /^(h-|px-|py-|gap-)/.test(c)).sort().join(' '); + expect(geometry(menuItemVariants({ tone: 'danger' }))).toBe(geometry(menuItemVariants())); + expect(geometry(menuItemVariants({ tone: 'accent' }))).toBe(geometry(menuItemVariants())); + }); +});