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 |
|
| Wrapper | Implementation | Status |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `LumotiaButton` | native + `.btn-*` class system; primary / secondary / tertiary / destructive | pending |
|
| `LumotiaButton` | native + `.btn-*` class system; primary / secondary / tertiary / destructive | ✅ Phase 5 |
|
||||||
| `LumotiaIconButton` | native + lucide-svelte; standard sizing + tooltip slot | pending |
|
| `LumotiaIconButton` | native + lucide-svelte; standard sizing + tooltip slot | ✅ Phase 5 |
|
||||||
| `LumotiaNotice` | custom; info / caution / danger / success inline notices | pending |
|
| `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 | pending |
|
| `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 | pending |
|
| `LumotiaField` | native + Formsnap Field integration; text + textarea + label + error wrapper | ✅ Phase 5 |
|
||||||
| `LumotiaSelect` | Bits UI Select | pending |
|
| `LumotiaSelect` | Bits UI Select | ✅ Phase 5 |
|
||||||
| `LumotiaCombobox` | Bits UI Combobox | pending |
|
| `LumotiaCombobox` | Bits UI Combobox | ✅ Phase 5 |
|
||||||
| `LumotiaDialog` | Bits UI Dialog | pending |
|
| `LumotiaDialog` | Bits UI Dialog | ✅ Phase 5 |
|
||||||
| `LumotiaTabs` | Bits UI Tabs | pending |
|
| `LumotiaTabs` | Bits UI Tabs | ✅ Phase 5 |
|
||||||
| `LumotiaTooltip` | Bits UI Tooltip | pending |
|
| `LumotiaTooltip` | Bits UI Tooltip | ✅ Phase 5 |
|
||||||
| `LumotiaMenu` / `LumotiaPopover` | Bits UI DropdownMenu / Popover | pending |
|
| `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
|
### 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. |
|
| 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). |
|
| 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. |
|
| 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 |
|
| 6 | pending | Shell split |
|
||||||
| 7.1 ShutdownRitualPage | pending | |
|
| 7.1 ShutdownRitualPage | pending | |
|
||||||
| 7.2 FilesPage | 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