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())); + }); +});