v0.2 Phase 5: 11 primitives + gated design-system-v2 preview

Custom-styled primitives (no headless dep):
  LumotiaButton       — primary/secondary/tertiary/destructive × sm/md/lg
  LumotiaIconButton   — square icon-only; ghost/filled/destructive
  LumotiaNotice       — info/caution/danger/success inline notice
  LumotiaProgress     — native <progress> + token theming
  LumotiaField        — plain + Formsnap modes share the same markup

Bits UI 2.18.1 wrappers (warm-brutalist styling):
  LumotiaSelect       — single-select, options=[]
  LumotiaCombobox     — searchable; one-way inputValue + oninput
  LumotiaDialog       — controlled open; closable + footer snippet
  LumotiaTabs         — orchestrates List/Trigger/Content from a tabs array
  LumotiaTooltip      — wraps Provider + Root + Trigger + Content
  LumotiaMenu         — DropdownMenu items=[] with destructive variant

design-system-v2 preview route:
  src/routes/design-system-v2/+page.ts gates with VITE_LUMOTIA_DESIGN_SYSTEM_V2=1.
  Without the flag the load() throws 404 — route-level gate, not nav-
  hidden. Run via VITE_LUMOTIA_DESIGN_SYSTEM_V2=1 npm run dev:frontend
  to see the showcase.

Browser-mode component test:
  src/lib/ui/LumotiaButton.browser.test.ts. Covers render, click, and
  disabled-blocks-click. Validates that vitest-browser-svelte + the
  @vitest/browser-playwright provider land Phase 1's tooling
  contract end-to-end. 3/3 passing in Chromium.

Type fix: LumotiaIconButton and LumotiaMenu accept icon: any so
lucide-svelte's legacy SvelteComponentTyped shape composes with our
Svelte 5 wrappers without forcing a // @ts-nocheck escape hatch on
every call site. Tightens to Component<…> once lucide-svelte ships
a Svelte 5 build.

Type fix: LumotiaCombobox honours Bits UI 2.x Combobox.Root's
one-way inputValue contract. The wrapper drops bind:inputValue
and exposes an oninput callback so caller-owned filter pipelines
(HistoryPage FTS5, ModelDownloader) can drive options upstream.

Phase 5 per-page gate green: npm run check (0/0/5700 files),
npm test, npm run test:browser (3/3 in Chromium),
npm run test:e2e (16/16), guard-no-skeleton clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-15 08:47:53 +01:00
parent 8c9708a508
commit c60f0aa5a5
15 changed files with 1121 additions and 12 deletions

View File

@@ -96,17 +96,17 @@ Filled progressively during Phases 45. Wrappers live in `src/lib/ui/`; bespok
| Wrapper | Implementation | Status |
|---|---|---|
| `LumotiaButton` | native + `.btn-*` class system; primary / secondary / tertiary / destructive | pending |
| `LumotiaIconButton` | native + lucide-svelte; standard sizing + tooltip slot | pending |
| `LumotiaNotice` | custom; info / caution / danger / success inline notices | pending |
| `LumotiaProgress` | native `<progress>` + tokens; fall back to `role="progressbar"` only if native styling proves inconsistent | pending |
| `LumotiaField` | native + Formsnap Field integration; text + textarea + label + error wrapper | pending |
| `LumotiaSelect` | Bits UI Select | pending |
| `LumotiaCombobox` | Bits UI Combobox | pending |
| `LumotiaDialog` | Bits UI Dialog | pending |
| `LumotiaTabs` | Bits UI Tabs | pending |
| `LumotiaTooltip` | Bits UI Tooltip | pending |
| `LumotiaMenu` / `LumotiaPopover` | Bits UI DropdownMenu / Popover | pending |
| `LumotiaButton` | native + `.btn-*` class system; primary / secondary / tertiary / destructive | ✅ Phase 5 |
| `LumotiaIconButton` | native + lucide-svelte; standard sizing + tooltip slot | ✅ Phase 5 |
| `LumotiaNotice` | custom; info / caution / danger / success inline notices | ✅ Phase 5 |
| `LumotiaProgress` | native `<progress>` + tokens; fall back to `role="progressbar"` only if native styling proves inconsistent | ✅ Phase 5 (native `<progress>` held up; no fallback needed) |
| `LumotiaField` | native + Formsnap Field integration; text + textarea + label + error wrapper | ✅ Phase 5 |
| `LumotiaSelect` | Bits UI Select | ✅ Phase 5 |
| `LumotiaCombobox` | Bits UI Combobox | ✅ Phase 5 |
| `LumotiaDialog` | Bits UI Dialog | ✅ Phase 5 |
| `LumotiaTabs` | Bits UI Tabs | ✅ Phase 5 |
| `LumotiaTooltip` | Bits UI Tooltip | ✅ Phase 5 |
| `LumotiaMenu` (DropdownMenu) | Bits UI DropdownMenu | ✅ Phase 5 (Popover deferred — DropdownMenu covers the v0.2 use cases) |
### 6.3 Bespoke surfaces — DO NOT wrap, DO NOT rewrite
@@ -202,7 +202,7 @@ Filled during execution. Each entry: phase #, date, what landed, what's next.
| 2 | ✅ complete | bits-ui 2.18.1, formsnap 2.0.1, sveltekit-superforms 2.30.1, zod 4.4.3, @internationalized/date 3.12.1. `npm audit signatures` 273 verified + 93 attestations. |
| 3 | ✅ complete | New tokens `--color-caution`, `--color-info`, `--color-accent-environment` (dark + light), `--color-warning` aliased to `var(--color-caution)`. KI-05 resolved: `theme` dropped from SettingsState type + defaults, all four route-layout migration `$effect`s deleted, two SettingsPage SegmentedButton bindings repointed to `preferences.theme` via Svelte 5 function bindings, one-shot legacy-theme migration on first mount strips the field after copying. Gate green (check 0/0, vitest 13/13, e2e 16/16). |
| 4 | ✅ complete | Six wrapper aliases under `src/lib/ui/`: Lumotia{Card,StatusPill,Toggle,SettingsGroup,EmptyState,PostCaptureCard}. Same prop APIs, $bindable forwarded for Toggle. Underlying `src/lib/components/*.svelte` untouched. |
| 5 | pending | New primitives + design-system-v2 preview |
| 5 | ✅ complete | 11 primitives shipped under `src/lib/ui/`. `design-system-v2` preview route gated behind `VITE_LUMOTIA_DESIGN_SYSTEM_V2=1` (route-level 404 via `+page.ts` load, not nav-hidden). Browser-mode component test (LumotiaButton): 3/3 passing in Chromium. Gate green (check 0/0/5700 files, vitest 0/0, test:browser 3/3, e2e 16/16). |
| 6 | pending | Shell split |
| 7.1 ShutdownRitualPage | pending | |
| 7.2 FilesPage | pending | |

View File

@@ -0,0 +1,41 @@
// v0.2 Phase 5 — minimal browser-mode component test for LumotiaButton.
// Verifies the primary contract: a click on the rendered button fires
// the onclick handler the parent passed. Covers the most common Phase 7
// migration path (replacing ad-hoc <button> blocks with LumotiaButton).
import { describe, it, expect, vi } from "vitest";
import { render } from "vitest-browser-svelte";
import LumotiaButton from "./LumotiaButton.svelte";
describe("LumotiaButton", () => {
it("renders its children", async () => {
const { getByRole } = render(LumotiaButton, {
props: { children: ((() => "Save") as unknown as never) },
});
await expect.element(getByRole("button")).toBeInTheDocument();
});
it("fires onclick when clicked", async () => {
const onclick = vi.fn();
const { getByRole } = render(LumotiaButton, {
props: {
onclick,
children: ((() => "Save") as unknown as never),
},
});
await getByRole("button").click();
expect(onclick).toHaveBeenCalledTimes(1);
});
it("does not fire onclick when disabled", async () => {
const onclick = vi.fn();
const { getByRole } = render(LumotiaButton, {
props: {
onclick,
disabled: true,
children: ((() => "Save") as unknown as never),
},
});
await getByRole("button").click({ force: true }).catch(() => {});
expect(onclick).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,81 @@
<script lang="ts">
// v0.2 Phase 5 primitive — native <button> + token-driven styling.
// Replaces the ~60 ad-hoc button blocks scattered across pages with
// one grammar (primary | secondary | tertiary | destructive × sm | md | lg).
// Bypasses Bits UI deliberately: <button> is already accessible and
// the headless library would only add weight here.
import type { Snippet } from "svelte";
type Variant = "primary" | "secondary" | "tertiary" | "destructive";
type Size = "sm" | "md" | "lg";
interface Props {
variant?: Variant;
size?: Size;
type?: "button" | "submit" | "reset";
disabled?: boolean;
loading?: boolean;
fullWidth?: boolean;
title?: string;
ariaLabel?: string;
onclick?: (e: MouseEvent) => void;
children?: Snippet;
classes?: string;
}
let {
variant = "secondary" as Variant,
size = "md" as Size,
type = "button",
disabled = false,
loading = false,
fullWidth = false,
title,
ariaLabel,
onclick,
children,
classes = "",
}: Props = $props();
const sizeClass: Record<Size, string> = {
sm: "btn-sm",
md: "btn-md",
lg: "btn-lg",
};
// Variant palette uses semantic tokens; primary fills with --color-accent
// (amber/copper) and pairs with .btn-filled-text for AA. Destructive
// mirrors primary on --color-danger.
const variantClass: Record<Variant, string> = {
primary:
"bg-accent btn-filled-text font-medium hover:bg-accent-hover shadow-[var(--shadow-accent-pill)]",
secondary:
"bg-bg-elevated text-text border border-border-subtle hover:bg-hover",
tertiary:
"bg-transparent text-text-secondary hover:bg-hover hover:text-text",
destructive:
"bg-danger btn-filled-text font-medium hover:bg-danger/90",
};
const busy = $derived(loading || disabled);
</script>
<button
{type}
class="rounded-lg font-medium select-none disabled:opacity-50 disabled:cursor-not-allowed
{sizeClass[size]}
{variantClass[variant]}
{fullWidth ? 'w-full' : ''}
{classes}"
style="transition-duration: var(--duration-ui)"
disabled={busy}
aria-busy={loading ? "true" : undefined}
aria-label={ariaLabel}
{title}
{onclick}
>
{#if loading}
<span class="inline-block animate-spin" aria-hidden="true"></span>
{/if}
{#if children}{@render children()}{/if}
</button>

View File

@@ -0,0 +1,103 @@
<script lang="ts">
// v0.2 Phase 5 primitive — searchable combobox over Bits UI Combobox.
// Use cases in v0.2: HistoryPage FTS5 transcript search, ModelDownloader
// model picker. Caller supplies `options` filtered against `inputValue`
// upstream (so the combobox stays headless about filter semantics).
import { Combobox } from "bits-ui";
import { Check, ChevronsUpDown, X } from "lucide-svelte";
type Option = { value: string; label: string; disabled?: boolean };
interface Props {
value: string;
/**
* Read-only initial / programmatic input string. Bits UI's
* Combobox.Root treats `inputValue` as one-way controlled — to
* react to typing, subscribe via `oninput` below and update
* your filtered options upstream.
*/
inputValue?: string;
options: Option[];
placeholder?: string;
label?: string;
disabled?: boolean;
allowClear?: boolean;
onValueChange?: (value: string) => void;
/** Fired on every keystroke in the combobox input. */
oninput?: (e: Event & { currentTarget: HTMLInputElement }) => void;
onClear?: () => void;
}
let {
value = $bindable(""),
inputValue,
options,
placeholder = "Search…",
label,
disabled = false,
allowClear = true,
onValueChange,
oninput,
onClear,
}: Props = $props();
</script>
<Combobox.Root
type="single"
bind:value
inputValue={inputValue}
{disabled}
onValueChange={onValueChange}
>
<div class="lumotia-combobox-shell relative">
<Combobox.Input
class="w-full bg-bg-input border border-border-subtle rounded-md pl-3 pr-16 py-2 text-[13px] text-text focus:outline-none focus:border-accent disabled:opacity-50"
aria-label={label}
{placeholder}
{oninput}
/>
<div class="absolute right-1.5 top-1/2 -translate-y-1/2 flex items-center gap-0.5">
{#if allowClear && (inputValue || value)}
<button
type="button"
class="size-6 rounded flex items-center justify-center text-text-tertiary hover:text-text hover:bg-hover"
aria-label="Clear"
onclick={() => { value = ""; onClear?.(); }}
>
<X size={12} />
</button>
{/if}
<Combobox.Trigger
class="size-6 rounded flex items-center justify-center text-text-tertiary hover:text-text hover:bg-hover"
aria-label="Open options"
>
<ChevronsUpDown size={12} />
</Combobox.Trigger>
</div>
</div>
<Combobox.Portal>
<Combobox.Content
class="z-50 rounded-md bg-bg-card border border-border-subtle shadow-xl py-1 max-h-[280px] overflow-auto"
sideOffset={4}
>
{#each options as opt (opt.value)}
<Combobox.Item
value={opt.value}
label={opt.label}
disabled={opt.disabled}
class="px-3 py-1.5 text-[13px] text-text data-[highlighted]:bg-hover data-[disabled]:opacity-50 cursor-pointer flex items-center gap-2"
>
{#snippet children({ selected })}
<span class="size-4 flex items-center justify-center shrink-0">
{#if selected}<Check size={14} class="text-accent" />{/if}
</span>
<span class="truncate">{opt.label}</span>
{/snippet}
</Combobox.Item>
{/each}
{#if options.length === 0}
<p class="px-3 py-2 text-[12px] text-text-tertiary italic">No matches</p>
{/if}
</Combobox.Content>
</Combobox.Portal>
</Combobox.Root>

View File

@@ -0,0 +1,70 @@
<script lang="ts">
// v0.2 Phase 5 primitive — Lumotia-tinted Bits UI Dialog.
// Consumers control open via bind:open. Trigger is rendered by the
// caller (a button + LumotiaIconButton + anchor — anything that
// can flip open). Keeping the trigger out of the wrapper means
// /preview can omit the dialog entirely (Wayland-hardened) without
// pulling in the Bits UI tree.
import { Dialog } from "bits-ui";
import { X } from "lucide-svelte";
import type { Snippet } from "svelte";
interface Props {
open: boolean;
onOpenChange?: (open: boolean) => void;
title?: string;
description?: string;
closeLabel?: string;
children?: Snippet;
footer?: Snippet;
}
let {
open = $bindable(false),
onOpenChange,
title,
description,
closeLabel = "Close",
children,
footer,
}: Props = $props();
</script>
<Dialog.Root bind:open {onOpenChange}>
<Dialog.Portal>
<Dialog.Overlay
class="fixed inset-0 z-40"
style="background: var(--color-overlay-dim)"
/>
<Dialog.Content
class="fixed left-1/2 top-1/2 z-50 -translate-x-1/2 -translate-y-1/2 w-[min(90vw,520px)] max-h-[85vh] overflow-auto rounded-2xl bg-bg-card border border-border-subtle shadow-2xl p-6"
>
{#if title || description}
<div class="mb-4">
{#if title}
<Dialog.Title class="text-[16px] font-semibold text-text">{title}</Dialog.Title>
{/if}
{#if description}
<Dialog.Description class="text-[13px] text-text-secondary mt-1">{description}</Dialog.Description>
{/if}
</div>
{/if}
{#if children}
<div class="text-[13px] text-text">
{@render children()}
</div>
{/if}
{#if footer}
<div class="mt-5 flex justify-end gap-2 pt-4 border-t border-border-subtle">
{@render footer()}
</div>
{/if}
<Dialog.Close
class="absolute top-3 right-3 size-7 rounded-md flex items-center justify-center text-text-tertiary hover:text-text hover:bg-hover"
aria-label={closeLabel}
>
<X size={16} />
</Dialog.Close>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>

View File

@@ -0,0 +1,127 @@
<script lang="ts">
// v0.2 Phase 5 primitive — labelled form field. Two modes:
// 1. Plain mode (default): native <input>/<textarea> with label
// + optional error text. Used by 80% of Settings rows.
// 2. Formsnap mode: when given a `form` prop, mounts inside a
// formsnap <Field>/<Control>/<Label>/<FieldErrors> tree so the
// Superforms validation flow drives error rendering.
//
// The two modes share the same visible markup so a row can opt
// into Formsnap without changing layout.
import { Field, Control, Label as FsLabel, FieldErrors } from "formsnap";
import type { Snippet } from "svelte";
type FieldType = "text" | "email" | "password" | "url" | "number" | "textarea";
interface Props {
label: string;
name?: string;
type?: FieldType;
value?: string | number;
placeholder?: string;
description?: string;
error?: string;
disabled?: boolean;
required?: boolean;
rows?: number;
form?: unknown; // SuperForm — kept loose to avoid leaking generic
children?: Snippet;
}
let {
label,
name,
type = "text" as FieldType,
value = $bindable(""),
placeholder,
description,
error,
disabled = false,
required = false,
rows = 3,
form,
children,
}: Props = $props();
const id = $derived(name ? `lumotia-field-${name}` : undefined);
</script>
{#if form && name}
<Field form={form as never} {name}>
<Control>
{#snippet children({ props })}
<div class="lumotia-field flex flex-col gap-1.5">
<FsLabel class="text-[12px] font-medium text-text-secondary uppercase tracking-wider">{label}</FsLabel>
{#if description}
<p class="text-[12px] text-text-tertiary -mt-1">{description}</p>
{/if}
{#if type === "textarea"}
<textarea
{...props}
bind:value
{placeholder}
{disabled}
{required}
{rows}
class="bg-bg-input border border-border-subtle rounded-md px-3 py-2 text-[13px] text-text resize-y focus:outline-none focus:border-accent"
></textarea>
{:else}
<input
{...props}
bind:value
{type}
{placeholder}
{disabled}
{required}
class="bg-bg-input border border-border-subtle rounded-md px-3 py-2 text-[13px] text-text focus:outline-none focus:border-accent"
/>
{/if}
<FieldErrors class="text-[12px] text-danger" />
</div>
{/snippet}
</Control>
</Field>
{:else}
<div class="lumotia-field flex flex-col gap-1.5">
<label
for={id}
class="text-[12px] font-medium text-text-secondary uppercase tracking-wider"
>{label}</label>
{#if description}
<p class="text-[12px] text-text-tertiary -mt-1">{description}</p>
{/if}
{#if type === "textarea"}
<textarea
{id}
{name}
bind:value
{placeholder}
{disabled}
{required}
{rows}
class="bg-bg-input border border-border-subtle rounded-md px-3 py-2 text-[13px] text-text resize-y focus:outline-none focus:border-accent"
aria-invalid={error ? "true" : undefined}
aria-describedby={error ? `${id}-err` : undefined}
></textarea>
{:else}
<input
{id}
{name}
bind:value
{type}
{placeholder}
{disabled}
{required}
class="bg-bg-input border border-border-subtle rounded-md px-3 py-2 text-[13px] text-text focus:outline-none focus:border-accent"
aria-invalid={error ? "true" : undefined}
aria-describedby={error ? `${id}-err` : undefined}
/>
{/if}
{#if error}
<p id={`${id}-err`} class="text-[12px] text-danger">{error}</p>
{/if}
{#if children}
<div>{@render children()}</div>
{/if}
</div>
{/if}

View File

@@ -0,0 +1,64 @@
<script lang="ts">
// v0.2 Phase 5 primitive — square icon-only button. Always carries a
// visible aria-label so screen readers announce purpose. Optional
// tooltip slot (rendered via native title for now; a tooltip primitive
// pass in Phase 7 will swap to LumotiaTooltip where layout permits).
type Size = "sm" | "md" | "lg";
type Variant = "ghost" | "filled" | "destructive";
// `icon` accepts both Svelte 5 `Component<…>` and the legacy
// `SvelteComponentTyped` shape lucide-svelte still exports as of
// 0.577.0. Type widened to keep call sites simple; the runtime
// requires nothing more than a renderable Svelte component.
// svelte-ignore: cross-version icon compat.
/* eslint-disable @typescript-eslint/no-explicit-any */
interface Props {
icon: any;
label: string;
size?: Size;
variant?: Variant;
disabled?: boolean;
type?: "button" | "submit" | "reset";
onclick?: (e: MouseEvent) => void;
classes?: string;
}
let {
icon: Icon,
label,
size = "md" as Size,
variant = "ghost" as Variant,
disabled = false,
type = "button",
onclick,
classes = "",
}: Props = $props();
const boxClass: Record<Size, string> = {
sm: "size-7",
md: "size-9",
lg: "size-11",
};
const iconSize: Record<Size, number> = { sm: 14, md: 16, lg: 18 };
const variantClass: Record<Variant, string> = {
ghost: "bg-transparent text-text-secondary hover:bg-hover hover:text-text",
filled: "bg-bg-elevated text-text border border-border-subtle hover:bg-hover",
destructive: "bg-transparent text-danger hover:bg-danger/10",
};
</script>
<button
{type}
class="rounded-md flex items-center justify-center select-none disabled:opacity-50 disabled:cursor-not-allowed
{boxClass[size]}
{variantClass[variant]}
{classes}"
style="transition-duration: var(--duration-ui)"
aria-label={label}
title={label}
{disabled}
{onclick}
>
<Icon size={iconSize[size]} />
</button>

View File

@@ -0,0 +1,67 @@
<script lang="ts">
// v0.2 Phase 5 primitive — Lumotia-tinted Bits UI DropdownMenu.
// Used by HistoryPage row actions, future overflow menus. Caller
// owns the trigger (any element); the wrapper takes the items list.
import { DropdownMenu } from "bits-ui";
import type { Snippet } from "svelte";
// `icon` accepts both Svelte 5 Component<…> and lucide-svelte's
// legacy SvelteComponentTyped shape. See LumotiaIconButton for the
// same rationale.
type MenuItem =
| {
kind?: "item";
value: string;
label: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
icon?: any;
destructive?: boolean;
disabled?: boolean;
onSelect: () => void;
}
| { kind: "separator" };
interface Props {
items: MenuItem[];
align?: "start" | "center" | "end";
sideOffset?: number;
trigger: Snippet;
triggerClass?: string;
}
let { items, align = "end", sideOffset = 6, trigger, triggerClass = "" }: Props = $props();
</script>
<DropdownMenu.Root>
<DropdownMenu.Trigger class={triggerClass}>
{@render trigger()}
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content
{align}
{sideOffset}
class="z-50 min-w-[180px] rounded-md bg-bg-card border border-border-subtle shadow-xl py-1"
>
{#each items as item, i (i)}
{#if item.kind === "separator"}
<DropdownMenu.Separator class="my-1 h-px bg-border-subtle" />
{:else}
<DropdownMenu.Item
disabled={item.disabled}
onSelect={item.onSelect}
class="px-3 py-1.5 text-[13px] flex items-center gap-2 cursor-pointer
data-[highlighted]:bg-hover
data-[disabled]:opacity-50 data-[disabled]:cursor-not-allowed
{item.destructive ? 'text-danger' : 'text-text'}"
>
{#if item.icon}
{@const I = item.icon}
<I size={14} class="text-text-tertiary shrink-0" />
{/if}
<span class="truncate">{item.label}</span>
</DropdownMenu.Item>
{/if}
{/each}
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>

View File

@@ -0,0 +1,92 @@
<script lang="ts">
// v0.2 Phase 5 primitive — inline notice block. Replaces the ~10
// ad-hoc <div class="bg-{danger,warning,success}/10 …"> patterns
// scattered across pages. Four tones map to the semantic tokens
// introduced in Phase 3:
// info — soft blue-grey (--color-info)
// caution — tuned amber (--color-caution)
// danger — tuned red (--color-danger)
// success — moss (--color-success)
// The icon and aria-role are tone-aware. Dismissible variant is
// opt-in for transient toasts inline; persistent notices stay
// visible.
import { Info, AlertTriangle, AlertOctagon, CheckCircle, X } from "lucide-svelte";
import type { Snippet } from "svelte";
type Tone = "info" | "caution" | "danger" | "success";
interface Props {
tone?: Tone;
title?: string;
dismissible?: boolean;
onDismiss?: () => void;
children?: Snippet;
classes?: string;
}
let {
tone = "info" as Tone,
title,
dismissible = false,
onDismiss,
children,
classes = "",
}: Props = $props();
const palette: Record<Tone, { bg: string; border: string; fg: string; icon: typeof Info }> = {
info: {
bg: "bg-info/10",
border: "border-info/30",
fg: "text-info",
icon: Info,
},
caution: {
bg: "bg-caution/10",
border: "border-caution/30",
fg: "text-caution",
icon: AlertTriangle,
},
danger: {
bg: "bg-danger/10",
border: "border-danger/30",
fg: "text-danger",
icon: AlertOctagon,
},
success: {
bg: "bg-success/10",
border: "border-success/30",
fg: "text-success",
icon: CheckCircle,
},
};
// Danger gets role="alert" so screen readers announce immediately;
// the others use the less-interrupting role="status".
const ariaRole = $derived(tone === "danger" ? "alert" : "status");
const tonePal = $derived(palette[tone]);
const ToneIcon = $derived(tonePal.icon);
</script>
<div
role={ariaRole}
class="px-4 py-3 rounded-lg border text-[13px] flex items-start gap-2.5
{tonePal.bg} {tonePal.border} {classes}"
>
<ToneIcon size={16} class="mt-0.5 shrink-0 {tonePal.fg}" aria-hidden="true" />
<div class="flex-1 min-w-0 leading-relaxed text-text">
{#if title}
<p class="font-semibold text-text mb-1">{title}</p>
{/if}
{#if children}{@render children()}{/if}
</div>
{#if dismissible}
<button
type="button"
class="shrink-0 text-text-tertiary hover:text-text size-5 flex items-center justify-center rounded"
aria-label="Dismiss notice"
onclick={onDismiss}
>
<X size={14} />
</button>
{/if}
</div>

View File

@@ -0,0 +1,69 @@
<script lang="ts">
// v0.2 Phase 5 primitive — native <progress> styled with Lumotia
// tokens. The plan called for falling back to role="progressbar"
// only if native styling proved inconsistent; Chromium on Linux/
// macOS/Windows now renders ::-webkit-progress-* uniformly, so
// native <progress> stands.
interface Props {
value: number;
max?: number;
label?: string; // visible label (above bar)
ariaLabel?: string; // sr-only label when no visible label
showValue?: boolean; // render "X / max" beside label
tone?: "default" | "success" | "caution" | "danger";
}
let {
value,
max = 100,
label,
ariaLabel,
showValue = false,
tone = "default",
}: Props = $props();
const fillToken = $derived(
tone === "success" ? "var(--color-success)"
: tone === "caution" ? "var(--color-caution)"
: tone === "danger" ? "var(--color-danger)"
: "var(--color-accent)"
);
const pct = $derived(max > 0 ? Math.min(100, Math.max(0, (value / max) * 100)) : 0);
</script>
{#if label}
<div class="flex items-baseline justify-between mb-1.5">
<span class="text-[12px] text-text-secondary">{label}</span>
{#if showValue}
<span class="text-[12px] text-text-tertiary tabular-nums">{value} / {max}</span>
{/if}
</div>
{/if}
<progress
{value}
{max}
aria-label={ariaLabel ?? label}
class="lumotia-progress block w-full h-2 rounded-full overflow-hidden"
style="--lumotia-progress-fill: {fillToken}; --lumotia-progress-pct: {pct}%"
>{value} / {max}</progress>
<style>
.lumotia-progress {
appearance: none;
-webkit-appearance: none;
background-color: var(--color-bg-elevated);
border: 1px solid var(--color-border-subtle);
}
/* Chromium / Edge */
.lumotia-progress::-webkit-progress-bar {
background-color: var(--color-bg-elevated);
}
.lumotia-progress::-webkit-progress-value {
background-color: var(--lumotia-progress-fill);
transition: width var(--duration-ui) ease-out;
}
/* Firefox */
.lumotia-progress::-moz-progress-bar {
background-color: var(--lumotia-progress-fill);
}
</style>

View File

@@ -0,0 +1,68 @@
<script lang="ts">
// v0.2 Phase 5 primitive — single-select dropdown over Bits UI Select.
// High-level API: `value` (bindable) + `options` ([{value, label}]).
// For multiselect, a separate LumotiaMultiSelect primitive can land
// later — keeping the v0.2 surface narrow.
import { Select } from "bits-ui";
import { Check, ChevronDown } from "lucide-svelte";
type Option = { value: string; label: string; disabled?: boolean };
interface Props {
value: string;
options: Option[];
placeholder?: string;
disabled?: boolean;
label?: string;
name?: string; // hidden form input for native form submit
onValueChange?: (value: string) => void;
triggerClass?: string;
}
let {
value = $bindable(""),
options,
placeholder = "Select…",
disabled = false,
label,
name,
onValueChange,
triggerClass = "",
}: Props = $props();
const selectedLabel = $derived(
options.find((o) => o.value === value)?.label ?? placeholder,
);
</script>
<Select.Root type="single" bind:value {disabled} onValueChange={onValueChange} {name}>
<Select.Trigger
class="lumotia-select-trigger inline-flex items-center justify-between gap-2 rounded-md bg-bg-input border border-border-subtle px-3 py-2 text-[13px] text-text min-w-[160px] focus:outline-none focus:border-accent disabled:opacity-50 {triggerClass}"
aria-label={label}
>
<span class="truncate">{selectedLabel}</span>
<ChevronDown size={14} class="text-text-tertiary shrink-0" aria-hidden="true" />
</Select.Trigger>
<Select.Portal>
<Select.Content
class="z-50 rounded-md bg-bg-card border border-border-subtle shadow-xl py-1 min-w-[160px] max-h-[280px] overflow-auto"
sideOffset={4}
>
{#each options as opt (opt.value)}
<Select.Item
value={opt.value}
label={opt.label}
disabled={opt.disabled}
class="px-3 py-1.5 text-[13px] text-text data-[highlighted]:bg-hover data-[disabled]:opacity-50 cursor-pointer flex items-center gap-2"
>
{#snippet children({ selected })}
<span class="size-4 flex items-center justify-center shrink-0">
{#if selected}<Check size={14} class="text-accent" />{/if}
</span>
<span class="truncate">{opt.label}</span>
{/snippet}
</Select.Item>
{/each}
</Select.Content>
</Select.Portal>
</Select.Root>

View File

@@ -0,0 +1,53 @@
<script lang="ts">
// v0.2 Phase 5 primitive — Lumotia-tinted Bits UI Tabs.
// High-level API: `value` (bindable), `tabs` (list of {value, label})
// and a `panel` snippet rendered per-tab. Consumers can also drop to
// the explicit <Tabs.Content> form if they need different markup per
// tab — re-exported below.
import { Tabs } from "bits-ui";
import type { Snippet } from "svelte";
type Tab = { value: string; label: string; disabled?: boolean };
interface Props {
value: string;
tabs: Tab[];
onValueChange?: (value: string) => void;
panel?: Snippet<[{ value: string }]>;
listClass?: string;
}
let {
value = $bindable(""),
tabs,
onValueChange,
panel,
listClass = "",
}: Props = $props();
</script>
<Tabs.Root bind:value onValueChange={onValueChange}>
<Tabs.List
class="lumotia-tabs-list inline-flex bg-bg-elevated rounded-[10px] p-[3px] gap-[2px] {listClass}"
>
{#each tabs as tab (tab.value)}
<Tabs.Trigger
value={tab.value}
disabled={tab.disabled}
class="rounded-lg px-3.5 py-[6px] text-[12px] font-medium text-text-secondary
hover:text-text hover:bg-hover
data-[state=active]:bg-accent data-[state=active]:btn-filled-text data-[state=active]:shadow-[var(--shadow-accent-pill)]
data-[disabled]:opacity-50 data-[disabled]:cursor-not-allowed
transition-colors"
style="transition-duration: var(--duration-ui)"
>
{tab.label}
</Tabs.Trigger>
{/each}
</Tabs.List>
{#each tabs as tab (tab.value)}
<Tabs.Content value={tab.value}>
{#if panel}{@render panel({ value: tab.value })}{/if}
</Tabs.Content>
{/each}
</Tabs.Root>

View File

@@ -0,0 +1,41 @@
<script lang="ts">
// v0.2 Phase 5 primitive — Lumotia-tinted Bits UI Tooltip.
// Used by LumotiaIconButton (Phase 7 sweep) and any control where a
// visible label would crowd the UI. Honours prefers-reduced-motion
// through Bits UI's default delay logic; we just style the panel.
import { Tooltip } from "bits-ui";
import type { Snippet } from "svelte";
interface Props {
text: string;
side?: "top" | "right" | "bottom" | "left";
sideOffset?: number;
delayDuration?: number;
trigger?: Snippet;
}
let {
text,
side = "top",
sideOffset = 6,
delayDuration = 250,
trigger,
}: Props = $props();
</script>
<Tooltip.Provider {delayDuration}>
<Tooltip.Root>
<Tooltip.Trigger class="inline-flex">
{#if trigger}{@render trigger()}{/if}
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content
{side}
{sideOffset}
class="z-50 px-2 py-1 rounded-md bg-bg-card border border-border-subtle text-[11px] text-text shadow-lg"
>
{text}
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
</Tooltip.Provider>

View File

@@ -0,0 +1,206 @@
<script lang="ts">
// v0.2 Phase 5 — primitives showcase. Gated by +page.ts; if you can
// see this you started Vite with VITE_LUMOTIA_DESIGN_SYSTEM_V2=1.
//
// Each section renders a Lumotia primitive in the variants it ships
// with so the Phase 7 page-migration sweep has a visual reference
// for "what should this look like in the new grammar".
import LumotiaButton from "$lib/ui/LumotiaButton.svelte";
import LumotiaIconButton from "$lib/ui/LumotiaIconButton.svelte";
import LumotiaNotice from "$lib/ui/LumotiaNotice.svelte";
import LumotiaProgress from "$lib/ui/LumotiaProgress.svelte";
import LumotiaField from "$lib/ui/LumotiaField.svelte";
import LumotiaSelect from "$lib/ui/LumotiaSelect.svelte";
import LumotiaCombobox from "$lib/ui/LumotiaCombobox.svelte";
import LumotiaDialog from "$lib/ui/LumotiaDialog.svelte";
import LumotiaTabs from "$lib/ui/LumotiaTabs.svelte";
import LumotiaTooltip from "$lib/ui/LumotiaTooltip.svelte";
import LumotiaMenu from "$lib/ui/LumotiaMenu.svelte";
import LumotiaCard from "$lib/ui/LumotiaCard.svelte";
import LumotiaStatusPill from "$lib/ui/LumotiaStatusPill.svelte";
import LumotiaToggle from "$lib/ui/LumotiaToggle.svelte";
import LumotiaEmptyState from "$lib/ui/LumotiaEmptyState.svelte";
import { Save, Settings, Trash2, FileText, Edit3 } from "lucide-svelte";
let dialogOpen = $state(false);
let toggleOn = $state(true);
let toggleOff = $state(false);
let selectValue = $state("base");
let comboboxValue = $state("");
let comboboxInput = $state("");
let tabValue = $state("input");
let textValue = $state("");
let progressValue = $state(40);
const modelOptions = [
{ value: "tiny", label: "Tiny" },
{ value: "base", label: "Base" },
{ value: "small", label: "Small" },
{ value: "medium", label: "Medium" },
];
const tabs = [
{ value: "input", label: "Input" },
{ value: "output", label: "Output" },
{ value: "device", label: "Device" },
];
</script>
<svelte:head><title>Lumotia design system v0.2 preview</title></svelte:head>
<div class="p-8 max-w-5xl mx-auto space-y-12">
<header>
<h1 class="text-[28px] font-display italic text-text">v0.2 primitives</h1>
<p class="text-[14px] text-text-secondary mt-1">
Warm brutalist notebook cockpit. Calm, local, tactile, low-noise. Not SaaS.
</p>
</header>
<section class="space-y-3">
<h2 class="text-[14px] text-text-secondary uppercase tracking-wider">LumotiaButton</h2>
<div class="flex flex-wrap gap-3 items-center">
<LumotiaButton variant="primary">Record</LumotiaButton>
<LumotiaButton variant="secondary">Cancel</LumotiaButton>
<LumotiaButton variant="tertiary">Skip</LumotiaButton>
<LumotiaButton variant="destructive">Delete</LumotiaButton>
<LumotiaButton variant="primary" size="sm">Small</LumotiaButton>
<LumotiaButton variant="primary" size="lg">Large</LumotiaButton>
<LumotiaButton variant="primary" loading>Loading</LumotiaButton>
<LumotiaButton variant="secondary" disabled>Disabled</LumotiaButton>
</div>
</section>
<section class="space-y-3">
<h2 class="text-[14px] text-text-secondary uppercase tracking-wider">LumotiaIconButton</h2>
<div class="flex flex-wrap gap-3 items-center">
<LumotiaIconButton icon={Save} label="Save" />
<LumotiaIconButton icon={Settings} label="Settings" variant="filled" />
<LumotiaIconButton icon={Trash2} label="Delete" variant="destructive" />
<LumotiaIconButton icon={Edit3} label="Edit" size="sm" />
<LumotiaIconButton icon={Edit3} label="Edit" size="lg" />
</div>
</section>
<section class="space-y-3">
<h2 class="text-[14px] text-text-secondary uppercase tracking-wider">LumotiaNotice</h2>
<div class="space-y-3 max-w-2xl">
<LumotiaNotice tone="info" title="Audio source">Using default input device.</LumotiaNotice>
<LumotiaNotice tone="caution" title="Model not downloaded">Download the Whisper-Base model before recording.</LumotiaNotice>
<LumotiaNotice tone="danger" title="Recording failed">Check the input group permissions on /dev/input/event*.</LumotiaNotice>
<LumotiaNotice tone="success" title="Saved">Transcript copied to clipboard.</LumotiaNotice>
<LumotiaNotice tone="info" dismissible onDismiss={() => {}}>Dismissible variant.</LumotiaNotice>
</div>
</section>
<section class="space-y-3">
<h2 class="text-[14px] text-text-secondary uppercase tracking-wider">LumotiaProgress</h2>
<div class="space-y-3 max-w-md">
<LumotiaProgress value={progressValue} max={100} label="Downloading" showValue />
<LumotiaProgress value={80} max={100} label="Transcribing" tone="success" />
<LumotiaProgress value={15} max={100} label="Disk space" tone="caution" />
<div class="flex gap-2">
<LumotiaButton size="sm" onclick={() => progressValue = Math.max(0, progressValue - 10)}>-10</LumotiaButton>
<LumotiaButton size="sm" onclick={() => progressValue = Math.min(100, progressValue + 10)}>+10</LumotiaButton>
</div>
</div>
</section>
<section class="space-y-3">
<h2 class="text-[14px] text-text-secondary uppercase tracking-wider">LumotiaField</h2>
<div class="space-y-4 max-w-md">
<LumotiaField label="Display name" placeholder="Jake" bind:value={textValue} />
<LumotiaField label="Notes" type="textarea" placeholder="…" bind:value={textValue} rows={3} />
<LumotiaField label="Required field" required placeholder="…" error="This field is required" />
</div>
</section>
<section class="space-y-3">
<h2 class="text-[14px] text-text-secondary uppercase tracking-wider">LumotiaSelect</h2>
<div class="flex items-center gap-3">
<LumotiaSelect bind:value={selectValue} options={modelOptions} label="Model" />
<span class="text-[13px] text-text-tertiary">selected: <code class="text-text">{selectValue}</code></span>
</div>
</section>
<section class="space-y-3">
<h2 class="text-[14px] text-text-secondary uppercase tracking-wider">LumotiaCombobox</h2>
<div class="max-w-md">
<LumotiaCombobox
bind:value={comboboxValue}
inputValue={comboboxInput}
options={modelOptions}
label="Model search"
oninput={(e) => comboboxInput = e.currentTarget.value}
/>
</div>
</section>
<section class="space-y-3">
<h2 class="text-[14px] text-text-secondary uppercase tracking-wider">LumotiaTabs</h2>
<LumotiaTabs bind:value={tabValue} {tabs}>
{#snippet panel({ value })}
<LumotiaCard classes="p-4 mt-3">
<p class="text-[13px] text-text">Panel: <code class="text-accent">{value}</code></p>
</LumotiaCard>
{/snippet}
</LumotiaTabs>
</section>
<section class="space-y-3">
<h2 class="text-[14px] text-text-secondary uppercase tracking-wider">LumotiaTooltip</h2>
<div class="flex items-center gap-3">
<LumotiaTooltip text="Save the current dictation">
{#snippet trigger()}
<LumotiaButton variant="secondary" size="sm">Hover me</LumotiaButton>
{/snippet}
</LumotiaTooltip>
</div>
</section>
<section class="space-y-3">
<h2 class="text-[14px] text-text-secondary uppercase tracking-wider">LumotiaMenu</h2>
<LumotiaMenu
items={[
{ value: "rename", label: "Rename", icon: Edit3, onSelect: () => {} },
{ value: "export", label: "Export", icon: FileText, onSelect: () => {} },
{ kind: "separator" as const },
{ value: "delete", label: "Delete", icon: Trash2, destructive: true, onSelect: () => {} },
]}
>
{#snippet trigger()}
<LumotiaButton variant="secondary" size="sm">More actions ⌄</LumotiaButton>
{/snippet}
</LumotiaMenu>
</section>
<section class="space-y-3">
<h2 class="text-[14px] text-text-secondary uppercase tracking-wider">LumotiaDialog</h2>
<LumotiaButton variant="secondary" onclick={() => dialogOpen = true}>Open dialog</LumotiaButton>
<LumotiaDialog bind:open={dialogOpen} title="Confirm deletion" description="This will permanently remove the transcript and its tasks.">
<p class="text-[13px] text-text-secondary">Last opened five minutes ago.</p>
{#snippet footer()}
<LumotiaButton variant="tertiary" onclick={() => dialogOpen = false}>Cancel</LumotiaButton>
<LumotiaButton variant="destructive" onclick={() => dialogOpen = false}>Delete</LumotiaButton>
{/snippet}
</LumotiaDialog>
</section>
<section class="space-y-3">
<h2 class="text-[14px] text-text-secondary uppercase tracking-wider">Wrappers — Card / StatusPill / Toggle / EmptyState</h2>
<div class="flex flex-wrap items-center gap-4">
<LumotiaCard classes="p-4 max-w-xs"><p class="text-[13px] text-text">Card body</p></LumotiaCard>
<LumotiaStatusPill status="recording" />
<LumotiaStatusPill status="cleaning" />
<LumotiaStatusPill status="saved" />
</div>
<div class="max-w-md space-y-1">
<LumotiaToggle label="Enable cleanup" bind:checked={toggleOn} />
<LumotiaToggle label="Save raw audio" bind:checked={toggleOff} description="Doubles storage usage." />
</div>
<LumotiaEmptyState
icon={FileText}
headline="No transcripts yet"
message="Hit Ctrl+Shift+R to start your first recording."
/>
</section>
</div>

View File

@@ -0,0 +1,27 @@
import { error } from "@sveltejs/kit";
// v0.2 Phase 5 — route-level gate.
//
// design-system-v2 is a developer-only preview surface for the new
// Lumotia primitives. It MUST NOT be reachable from production
// builds: a hidden-but-routable URL is still a URL. This load
// function throws 404 unless the build was started with
// VITE_LUMOTIA_DESIGN_SYSTEM_V2=1, which means:
//
// - `npm run dev:frontend` exposes the route only when the env
// var is set in the developer's shell
// - the static `npm run build` skips the route entirely because
// `prerender = false` keeps the static adapter from running
// `load`, and the SPA fallback returns the SvelteKit error
// page on direct navigation
//
// To open the preview locally:
// VITE_LUMOTIA_DESIGN_SYSTEM_V2=1 npm run dev:frontend
export const prerender = false;
export function load() {
if (import.meta.env.VITE_LUMOTIA_DESIGN_SYSTEM_V2 !== "1") {
throw error(404, "Not Found");
}
return {};
}