Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 31 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<button>` (a Reka trigger) calls the recipe. Menu rows are `menuItemVariants` on
Reka's own `MenubarItem` or `DropdownMenuItem`, since Reka owns their keyboard and focus, and
`:disabled` on the item replaces the `opacity-50 pointer-events-none` each row used to carry.
- **A ternary in `:class` is a variant only when it names a state of the thing it styles**
(selected, pressed, on, reached, a tone). Layout switches (`compact`, `flush`, `block`),
animation states (`open ? 'rotate-180'`, a collapsing `grid-rows`) and one-off emphasis stay
where they are, because a variant nobody else will ever ask for is a second place to look.
- **An empty state is `BaseEmptyState`**, `size="page"` for a whole screen and `size="panel"` for
one section of a busier one, with an `actions` slot. Six were hand-rolled in 3.5.0 and one first
shipped with no button at all.

## Testing

Two suites, three orders of magnitude apart, and the split is by what a test needs rather than by
Expand Down Expand Up @@ -1128,7 +1158,7 @@ These do not run in CI (they need a desktop session, a GPU and ffmpeg). `build:w
- TypeScript strict, ESM everywhere. Main imports need the `.js` extension on relative paths.
- Vue: `<script setup lang="ts">`, typed `defineProps`/`defineEmits`, `ref` over `reactive`.
- Tailwind utilities over custom CSS; tokens over literals.
- Feedback through the toast store; destructive actions use `toastStore.confirm`.
- Feedback through the toast store; destructive actions ask through `useConfirm`, never the toast.
- Long work runs as a job (`services/jobs.ts`) with progress, an ETA and an `AbortController`,
never awaited inside a handler.

Expand Down
23 changes: 23 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@
"dependencies": {
"@modelcontextprotocol/sdk": "^1.30.0",
"better-sqlite3": "^13.0.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"ffmpeg-static": "^5.2.0",
"ffprobe-static": "^3.1.0",
"reka-ui": "^2.10.4"
Expand Down
65 changes: 31 additions & 34 deletions src/renderer/src/components/Base/BaseButton.vue
Original file line number Diff line number Diff line change
@@ -1,24 +1,34 @@
<script lang="ts" setup>
import { computed } from 'vue';
import { computed, useAttrs } from 'vue';
import { Icon } from '@iconify/vue';
import type { ButtonVariant } from './types';
import { CONTROL_HEIGHT, CONTROL_HEIGHT_DENSE, FOCUS_RING, ICON_BOX, ICON_GAP, MOTION } from './geometry';
import type { ClassValue } from 'clsx';
import { cn } from './cn';
import { ICON_BOX } from './geometry';
import { buttonVariants, type ButtonSize, type ButtonTone } from './variants';

/**
* A button, at the one height buttons are.
*
* The classes are `buttonVariants` in `variants.ts`, which is where the rules
* about tones live: one `strong` per region, `danger` outlined and never
* filled, nothing changing size between states.
*
* What it used to be: a violet-to-cyan gradient for `primary`, a drop shadow
* that grew on hover, and `active:scale-[0.98]`. The scale is the one worth
* naming, because it changes the element's measured size on press, which is
* the same class of defect as a card growing on hover: the box moves when the
* state does. Colour moves; geometry does not.
*
* At most one `primary` per region. Everything else is `outline` or `ghost`,
* which is what makes the filled one mean something.
* `inheritAttrs` is off so a caller's `class` goes through `cn` rather than
* being appended: `class="px-2"` on a button whose recipe says `px-3.5` should
* mean `px-2`, not both with the stylesheet deciding.
*/
defineOptions({ inheritAttrs: false });

const props = withDefaults(
defineProps<{
variant?: ButtonVariant;
tone?: ButtonTone;
size?: ButtonSize;
/** An Iconify name, drawn before the label in a fixed 16px box. */
icon?: string;
/**
Expand All @@ -28,43 +38,30 @@ const props = withDefaults(
* labels start at different x, which is `07-editor-filter-dropdowns`.
*/
reserveIcon?: boolean;
/** Only inside a dense toolbar. See `CONTROL_HEIGHT_DENSE`. */
dense?: boolean;
/** No label, so the box is square. */
/** No label, so the box is square. Give it an `aria-label`. */
iconOnly?: boolean;
type?: 'button' | 'submit' | 'reset';
}>(),
{ variant: 'default' },
{ tone: 'default', size: 'md', type: 'button' },
);

const variantClass = computed(() => {
switch (props.variant) {
case 'primary':
return 'bg-accent text-accent-fg border-transparent hover:bg-accent-hover';
case 'danger':
return 'bg-danger text-danger-fg border-transparent hover:opacity-90';
case 'ghost':
return 'bg-transparent border-transparent text-muted-600 hover:bg-muted-50 hover:text-foreground';
case 'outline':
case 'muted':
return 'bg-transparent border-border text-foreground hover:bg-muted-50';
default:
return 'bg-muted-50 border-transparent text-foreground hover:bg-muted-100';
}
});
const attrs = useAttrs();

const classes = computed(() =>
cn(
buttonVariants({ tone: props.tone, size: props.size, iconOnly: props.iconOnly }),
attrs.class as ClassValue,
),
);

const sizeClass = computed(() => {
const height = props.dense ? CONTROL_HEIGHT_DENSE : CONTROL_HEIGHT;
if (props.iconOnly) return `${height} ${props.dense ? 'w-7' : 'w-9'} px-0`;
return `${height} ${props.dense ? 'px-2.5' : 'px-3.5'}`;
const rest = computed(() => {
const { class: _class, ...others } = attrs;
return others;
});
</script>

<template>
<button
type="button"
class="inline-flex items-center justify-center rounded-md border text-sm font-medium disabled:opacity-50 disabled:pointer-events-none"
:class="[variantClass, sizeClass, ICON_GAP, FOCUS_RING, MOTION]"
>
<button :type="type" v-bind="rest" :class="classes">
<Icon v-if="icon" :icon="icon" :class="ICON_BOX" />
<span v-else-if="reserveIcon" :class="ICON_BOX" aria-hidden="true" />
<slot />
Expand Down
13 changes: 13 additions & 0 deletions src/renderer/src/components/Base/BaseChip.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<script setup lang="ts">
import { computed } from 'vue';
import { chipVariants } from './variants';

/** A small label over a video frame. The classes are `chipVariants`. */
const props = defineProps<{ numeric?: boolean }>();

const classes = computed(() => chipVariants({ numeric: props.numeric }));
</script>

<template>
<span :class="classes"><slot /></span>
</template>
49 changes: 37 additions & 12 deletions src/renderer/src/components/Base/BaseEmptyState.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
<script setup lang="ts">
import { Icon } from '@iconify/vue';
import { computed, useSlots } from 'vue';
import BaseButton from './BaseButton.vue';
import { emptyStateVariants, type EmptyStateSize, type EmptyStateTone } from './variants';

/**
* Nothing here, and what to do about it.
Expand All @@ -10,37 +12,60 @@ import BaseButton from './BaseButton.vue';
* everything, or a search had matched nothing, and the advice, "try adding
* some clips to your library", was wrong in two of those three cases. Every
* caller now says which case it is and offers the way out of it.
*
* **Two sizes.** `page` is a whole screen with nothing on it. `panel` is one
* section of a screen that has other things, drawn in the bordered panel the
* rest of that screen uses. Six of the second kind were written out by hand in
* 3.5.0, and one of them first shipped telling the reader to go and find a
* setting rather than offering a button to it: a component with an `actions`
* slot is harder to forget than a paragraph is.
*
* **The tone is the icon's, and only the icon's.** `success` is news, like a
* list that is empty because everything was done; `warning` is a service that
* did not answer. The words stay the body colour either way.
*/
interface Props {
icon?: string;
title: string;
description: string;
/** Text for the way out. Nothing is drawn without it. */
description?: string;
/** Text for a single way out. For more than one, use the `actions` slot. */
actionLabel?: string;
size?: EmptyStateSize;
tone?: EmptyStateTone;
}

interface Emits {
(e: 'action'): void;
}

withDefaults(defineProps<Props>(), {
const props = withDefaults(defineProps<Props>(), {
icon: 'material-symbols:inbox',
description: '',
actionLabel: '',
size: 'page',
tone: 'neutral',
});

const emit = defineEmits<Emits>();
const slots = useSlots();

const parts = computed(() => emptyStateVariants({ size: props.size, tone: props.tone }));
const hasActions = computed(() => Boolean(props.actionLabel || slots.actions));
</script>

<template>
<div class="py-20 flex flex-col items-center justify-center text-center gap-4">
<Icon :icon="icon" class="size-8 block text-muted-300" />
<div class="space-y-1">
<h2 class="font-display text-lg font-medium text-foreground">{{ title }}</h2>
<p class="text-sm text-muted-500 max-w-md">{{ description }}</p>
<div :class="parts.root()">
<Icon :icon="icon" :class="parts.icon()" />
<div :class="parts.text()">
<h2 :class="parts.title()">{{ title }}</h2>
<p v-if="description || $slots.default" :class="parts.description()">
<slot>{{ description }}</slot>
</p>
</div>
<div v-if="hasActions" :class="parts.actions()">
<slot name="actions">
<BaseButton tone="strong" @click="emit('action')">{{ actionLabel }}</BaseButton>
</slot>
</div>
<BaseButton v-if="actionLabel" variant="primary" class="mt-1" @click="emit('action')">
{{ actionLabel }}
</BaseButton>
</div>
</template>

19 changes: 11 additions & 8 deletions src/renderer/src/components/Base/BasePager.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { computed } from 'vue';
import { Icon } from '@iconify/vue';
import BaseButton from './BaseButton.vue';

/**
* Back, a position, forward.
Expand Down Expand Up @@ -43,16 +44,17 @@ const widthCh = computed(() => {

<template>
<div class="inline-flex items-center gap-1">
<button
type="button"
class="size-7 inline-flex items-center justify-center shrink-0 rounded-md text-muted-500 hover:bg-muted-100 hover:text-foreground outline-none focus-visible:focus-ring transition-colors duration-150 disabled:opacity-30 disabled:pointer-events-none"
<BaseButton
tone="quiet"
size="dense"
icon-only
:disabled="!hasPrevious"
:title="`Previous ${noun} ([)`"
:aria-label="`Previous ${noun}`"
@click="$emit('previous')"
>
<Icon icon="material-symbols:chevron-left" class="size-4 shrink-0 block" />
</button>
</BaseButton>

<span
class="font-mono text-xs tabular-nums text-muted-500 text-center select-none"
Expand All @@ -62,15 +64,16 @@ const widthCh = computed(() => {
{{ position }} of {{ total }}
</span>

<button
type="button"
class="size-7 inline-flex items-center justify-center shrink-0 rounded-md text-muted-500 hover:bg-muted-100 hover:text-foreground outline-none focus-visible:focus-ring transition-colors duration-150 disabled:opacity-30 disabled:pointer-events-none"
<BaseButton
tone="quiet"
size="dense"
icon-only
:disabled="!hasNext"
:title="`Next ${noun} (])`"
:aria-label="`Next ${noun}`"
@click="$emit('next')"
>
<Icon icon="material-symbols:chevron-right" class="size-4 shrink-0 block" />
</button>
</BaseButton>
</div>
</template>
26 changes: 26 additions & 0 deletions src/renderer/src/components/Base/BasePanel.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<script setup lang="ts">
import { computed } from 'vue';
import { panelVariants } from './variants';

/**
* A bordered panel above the page. The classes are `panelVariants`.
*
* `as` because some of these are a `section` and some a list item, and a panel
* that forces a `div` is a panel somebody copies the classes out of instead.
*/
const props = withDefaults(
defineProps<{
as?: string;
padding?: 'md' | 'none';
}>(),
{ as: 'div', padding: 'md' },
);

const classes = computed(() => panelVariants({ padding: props.padding }));
</script>

<template>
<component :is="as" :class="classes">
<slot />
</component>
</template>
12 changes: 4 additions & 8 deletions src/renderer/src/components/Base/BaseToggle.vue
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { switchThumbVariants, switchTrackVariants } from './variants';

/**
* The on/off switch, wherever the app has one.
*
Expand Down Expand Up @@ -51,13 +53,7 @@ function toggle(): void {
filling with the accent's colour at the accent's weight rather than by
becoming a block of it.
-->
<span
class="absolute left-px h-6 w-11 rounded-full border transition-colors duration-150"
:class="modelValue ? 'bg-accent-sunk border-accent' : 'bg-muted-100 border-line-strong'"
/>
<span
class="absolute left-[5px] size-4 rounded-full transition-[transform,background-color] duration-150"
:class="modelValue ? 'translate-x-5 bg-accent' : 'translate-x-0 bg-muted-400'"
/>
<span :class="switchTrackVariants({ on: modelValue })" />
<span :class="switchThumbVariants({ on: modelValue })" />
</button>
</template>
Loading
Loading