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:
@@ -96,17 +96,17 @@ Filled progressively during Phases 4–5. 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 | |
|
||||
|
||||
41
src/lib/ui/LumotiaButton.browser.test.ts
Normal file
41
src/lib/ui/LumotiaButton.browser.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
81
src/lib/ui/LumotiaButton.svelte
Normal file
81
src/lib/ui/LumotiaButton.svelte
Normal 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>
|
||||
103
src/lib/ui/LumotiaCombobox.svelte
Normal file
103
src/lib/ui/LumotiaCombobox.svelte
Normal 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>
|
||||
70
src/lib/ui/LumotiaDialog.svelte
Normal file
70
src/lib/ui/LumotiaDialog.svelte
Normal 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>
|
||||
127
src/lib/ui/LumotiaField.svelte
Normal file
127
src/lib/ui/LumotiaField.svelte
Normal 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}
|
||||
64
src/lib/ui/LumotiaIconButton.svelte
Normal file
64
src/lib/ui/LumotiaIconButton.svelte
Normal 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>
|
||||
67
src/lib/ui/LumotiaMenu.svelte
Normal file
67
src/lib/ui/LumotiaMenu.svelte
Normal 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>
|
||||
92
src/lib/ui/LumotiaNotice.svelte
Normal file
92
src/lib/ui/LumotiaNotice.svelte
Normal 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>
|
||||
69
src/lib/ui/LumotiaProgress.svelte
Normal file
69
src/lib/ui/LumotiaProgress.svelte
Normal 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>
|
||||
68
src/lib/ui/LumotiaSelect.svelte
Normal file
68
src/lib/ui/LumotiaSelect.svelte
Normal 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>
|
||||
53
src/lib/ui/LumotiaTabs.svelte
Normal file
53
src/lib/ui/LumotiaTabs.svelte
Normal 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>
|
||||
41
src/lib/ui/LumotiaTooltip.svelte
Normal file
41
src/lib/ui/LumotiaTooltip.svelte
Normal 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>
|
||||
206
src/routes/design-system-v2/+page.svelte
Normal file
206
src/routes/design-system-v2/+page.svelte
Normal 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>
|
||||
27
src/routes/design-system-v2/+page.ts
Normal file
27
src/routes/design-system-v2/+page.ts
Normal 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 {};
|
||||
}
|
||||
Reference in New Issue
Block a user