diff --git a/AGENTS.md b/AGENTS.md index 0cc14b6..f0aec85 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,6 +26,12 @@ the command list, repository map, and architecture explanation. - Keep robots, sitemap, API catalog, agent skills, `auth.md`, Markdown negotiation, and WebMCP honest. Add public pages to `SITEMAP_ROUTES` or explicitly exclude them. NebulaKit is an OAuth client, not an OAuth or MCP server. Keep `[x+2e]well-known`; the escape preserves TypeScript inclusion. +- A screenshot of another website ships as a pair: one capture in that site's light mode, one in + its dark mode. Show the one that matches this page's current theme. Switch on + `[data-theme='dark']` on ``, not on `prefers-color-scheme`: the user toggle overrides the + OS, so `` shows the wrong capture after a toggle. + Give both images the same size and `alt`. If the site has only one mode, say so beside the + single image. ## Security Boundaries diff --git a/FEATURES.md b/FEATURES.md index 0288ff2..6fd5697 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -128,13 +128,20 @@ build/deploy. See [docs/CLOUDFLARE_SETUP.md](./docs/CLOUDFLARE_SETUP.md). - Open Graph, Twitter, canonical, and article metadata - Pointer, touch, and keyboard dragging built on Pointer Events, with a live-region announcement for every move -- A columned widget board over a registry-driven widget catalogue, and a pure - `reorder()` engine usable on its own +- A UI kit of 27 standard elements in `$lib/ui` — buttons, menus, every form + control, alerts, toasts, progress, dialogs, tooltips, tabs, accordion, + breadcrumbs, pagination, cards, sortable tables and more — themed with CSS + variables and usable by keyboard +- A live catalog of every element and widget, with code, at `/components` +- A columned widget board over a registry-driven widget catalogue, six standard + widgets (stat, clock, checklist, meter, notes, links), and a pure `reorder()` + engine usable on its own - A "Proudly built with NebulaKit" footer badge, on by default and removed with one `false` in `site.config.ts` — MIT-licensed, so it is a courtesy rather than a condition See [docs/THEME_SYSTEM.md](./docs/THEME_SYSTEM.md), +[docs/UI_KIT.md](./docs/UI_KIT.md), [docs/COMMAND_PALETTE.md](./docs/COMMAND_PALETTE.md), and [docs/WIDGET_BOARD.md](./docs/WIDGET_BOARD.md). diff --git a/docs/UI_KIT.md b/docs/UI_KIT.md new file mode 100644 index 0000000..1c4f811 --- /dev/null +++ b/docs/UI_KIT.md @@ -0,0 +1,75 @@ +# UI Kit + +Standard elements in `src/lib/ui`, imported from one place: + +```ts +import { Button, TextInput, Dialog, Tabs, Table, toast } from '$lib/ui'; +``` + +Every element is shown running, with the code for it, at **`/components`**. + +--- + +## What is in it + +| Group | Components | +| ------------ | ---------------------------------------------------------------------------------------- | +| Actions | `Button`, `Menu` | +| Forms | `Field`, `TextInput`, `Textarea`, `Select`, `Checkbox`, `RadioGroup`, `Switch`, `Slider` | +| Feedback | `Alert`, `Toaster` + `toast`, `Badge`, `Progress`, `Spinner`, `Skeleton` | +| Overlays | `Dialog`, `Tooltip` | +| Navigation | `Tabs`, `Accordion`, `Breadcrumbs`, `Pagination` | +| Data display | `Card`, `Table`, `Avatar`, `Kbd`, `EmptyState` | + +Widgets for the board (`stat`, `clock`, `checklist`, `meter`, `notes`, `links`) +live in `src/lib/widgets`; see [WIDGET_BOARD.md](./WIDGET_BOARD.md). + +--- + +## The rules every element follows + +1. **CSS variables only** (AGENTS.md §3). Solid fills with white text use + `--color-primary-solid`, `--color-danger-solid` and `--color-on-solid`. + The dark theme's `--color-primary` is tuned for text on a dark ground, and + white on it is only 3.7:1; every `*-solid` pair is at least 4.5:1. +2. **Native first.** `Dialog` is ``, `Accordion` is `
`, + `Select` is ` + + {label} + {#if hint}{hint}{/if} + + + + diff --git a/src/lib/ui/Dialog.svelte b/src/lib/ui/Dialog.svelte new file mode 100644 index 0000000..bcc8e34 --- /dev/null +++ b/src/lib/ui/Dialog.svelte @@ -0,0 +1,129 @@ + + + + +
+
+

{title}

+ +
+ {#if description}

{description}

{/if} +
+ {#if $$slots.actions}
{/if} +
+
+ + diff --git a/src/lib/ui/EmptyState.svelte b/src/lib/ui/EmptyState.svelte new file mode 100644 index 0000000..f47f36f --- /dev/null +++ b/src/lib/ui/EmptyState.svelte @@ -0,0 +1,52 @@ + + + +
+ {#if icon}{/if} +

{title}

+ {#if description}

{description}

{/if} + {#if $$slots.default}
{/if} +
+ + diff --git a/src/lib/ui/Field.svelte b/src/lib/ui/Field.svelte new file mode 100644 index 0000000..171cca6 --- /dev/null +++ b/src/lib/ui/Field.svelte @@ -0,0 +1,72 @@ + + + +
+ + + {#if hint}

{hint}

{/if} + {#if error}

{error}

{/if} +
+ + diff --git a/src/lib/ui/Kbd.svelte b/src/lib/ui/Kbd.svelte new file mode 100644 index 0000000..316a79b --- /dev/null +++ b/src/lib/ui/Kbd.svelte @@ -0,0 +1,36 @@ + + + + + {#each keys as key, i (i)} + {#if i > 0}{/if} + {key} + {/each} + {#if keys.length === 0}{/if} + + + diff --git a/src/lib/ui/Menu.svelte b/src/lib/ui/Menu.svelte new file mode 100644 index 0000000..d13760b --- /dev/null +++ b/src/lib/ui/Menu.svelte @@ -0,0 +1,200 @@ + + + + + + + + diff --git a/src/lib/ui/Pagination.svelte b/src/lib/ui/Pagination.svelte new file mode 100644 index 0000000..65d483e --- /dev/null +++ b/src/lib/ui/Pagination.svelte @@ -0,0 +1,137 @@ + + + +{#if total > 1} + +{/if} + + diff --git a/src/lib/ui/Progress.svelte b/src/lib/ui/Progress.svelte new file mode 100644 index 0000000..a949de2 --- /dev/null +++ b/src/lib/ui/Progress.svelte @@ -0,0 +1,98 @@ + + + +
+
+ {label} + {#if showValue && pct !== undefined}{Math.round(pct)}%{/if} +
+
+
+
+
+ + diff --git a/src/lib/ui/RadioGroup.svelte b/src/lib/ui/RadioGroup.svelte new file mode 100644 index 0000000..a36988e --- /dev/null +++ b/src/lib/ui/RadioGroup.svelte @@ -0,0 +1,85 @@ + + + +
+ {legend} + {#each options as option (option.value)} + + {/each} +
+ + diff --git a/src/lib/ui/Select.svelte b/src/lib/ui/Select.svelte new file mode 100644 index 0000000..6ef8075 --- /dev/null +++ b/src/lib/ui/Select.svelte @@ -0,0 +1,49 @@ + + + + + + + + diff --git a/src/lib/ui/Skeleton.svelte b/src/lib/ui/Skeleton.svelte new file mode 100644 index 0000000..4b3493d --- /dev/null +++ b/src/lib/ui/Skeleton.svelte @@ -0,0 +1,65 @@ + + + + + + diff --git a/src/lib/ui/Slider.svelte b/src/lib/ui/Slider.svelte new file mode 100644 index 0000000..56ad2df --- /dev/null +++ b/src/lib/ui/Slider.svelte @@ -0,0 +1,108 @@ + + + + +
+ + {value}{unit} +
+
+ + diff --git a/src/lib/ui/Spinner.svelte b/src/lib/ui/Spinner.svelte new file mode 100644 index 0000000..fba4c28 --- /dev/null +++ b/src/lib/ui/Spinner.svelte @@ -0,0 +1,49 @@ + + + + + + {label} + + + diff --git a/src/lib/ui/Switch.svelte b/src/lib/ui/Switch.svelte new file mode 100644 index 0000000..e20e8b0 --- /dev/null +++ b/src/lib/ui/Switch.svelte @@ -0,0 +1,84 @@ + + + + + + diff --git a/src/lib/ui/Table.svelte b/src/lib/ui/Table.svelte new file mode 100644 index 0000000..a748f11 --- /dev/null +++ b/src/lib/ui/Table.svelte @@ -0,0 +1,152 @@ + + + +
+ + {#if caption}{/if} + + + {#each columns as column (column.key)} + + {/each} + + + + {#each shown as row, r (r)} + + {#each columns as column (column.key)} + + {/each} + + {:else} + + {/each} + +
{caption}
+ {#if column.sortable} + + {:else} + {column.label} + {/if} +
+ {row[column.key] ?? ''} +
{empty}
+
+ + diff --git a/src/lib/ui/Tabs.svelte b/src/lib/ui/Tabs.svelte new file mode 100644 index 0000000..136bcf3 --- /dev/null +++ b/src/lib/ui/Tabs.svelte @@ -0,0 +1,120 @@ + + + +
+
+ {#each tabs as tab, i (tab.id)} + + {/each} +
+
+ +
+
+ + diff --git a/src/lib/ui/TextInput.svelte b/src/lib/ui/TextInput.svelte new file mode 100644 index 0000000..380230c --- /dev/null +++ b/src/lib/ui/TextInput.svelte @@ -0,0 +1,55 @@ + + + + + + + + diff --git a/src/lib/ui/Textarea.svelte b/src/lib/ui/Textarea.svelte new file mode 100644 index 0000000..4e06400 --- /dev/null +++ b/src/lib/ui/Textarea.svelte @@ -0,0 +1,60 @@ + + + + + + {#if maxlength} +

{value.length} / {maxlength}

+ {/if} +
+ + diff --git a/src/lib/ui/Toaster.svelte b/src/lib/ui/Toaster.svelte new file mode 100644 index 0000000..6fb663b --- /dev/null +++ b/src/lib/ui/Toaster.svelte @@ -0,0 +1,75 @@ + + + +
+
    + {#each $toast as item (item.id)} +
  1. + {item.message} + +
  2. + {/each} +
+
+ + diff --git a/src/lib/ui/Tooltip.svelte b/src/lib/ui/Tooltip.svelte new file mode 100644 index 0000000..f012aed --- /dev/null +++ b/src/lib/ui/Tooltip.svelte @@ -0,0 +1,89 @@ + + + + (hidden = false)} + on:focusout={() => (hidden = false)} +> + + {text} + + + diff --git a/src/lib/ui/catalog.test.ts b/src/lib/ui/catalog.test.ts new file mode 100644 index 0000000..83b5ff8 --- /dev/null +++ b/src/lib/ui/catalog.test.ts @@ -0,0 +1,34 @@ +import { readdirSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; +import { widgetManifest } from '$lib/widgets/manifest'; +import { CATALOG_GROUPS, catalog, catalogEntry } from './catalog'; + +describe('component catalog', () => { + it('has an entry for every component in $lib/ui', () => { + const components = readdirSync('src/lib/ui') + .filter((f) => f.endsWith('.svelte')) + .map((f) => f.replace('.svelte', '')); + const names = catalog.filter((e) => e.group !== 'Widgets').map((e) => e.name); + expect(components.filter((c) => !names.includes(c))).toEqual([]); + expect(names.filter((n) => !components.includes(n))).toEqual([]); + }); + + it('has an entry for every registered widget', () => { + const widgets = catalog.filter((e) => e.group === 'Widgets').map((e) => e.id); + expect(widgets.sort()).toEqual(widgetManifest.map((w) => `widget-${w.name}`).sort()); + }); + + it('uses unique ids, known groups, and says something for each', () => { + expect(new Set(catalog.map((e) => e.id)).size).toBe(catalog.length); + for (const entry of catalog) { + expect(CATALOG_GROUPS).toContain(entry.group); + expect(entry.summary.length).toBeGreaterThan(10); + expect(entry.code.length).toBeGreaterThan(10); + } + }); + + it('looks entries up by id, and refuses an unknown one', () => { + expect(catalogEntry('button').name).toBe('Button'); + expect(() => catalogEntry('nope')).toThrow(/nope/); + }); +}); diff --git a/src/lib/ui/catalog.ts b/src/lib/ui/catalog.ts new file mode 100644 index 0000000..835ef36 --- /dev/null +++ b/src/lib/ui/catalog.ts @@ -0,0 +1,351 @@ +/** + * The /components catalog: every element in `$lib/ui` and every registered + * widget, with what it is for and how to use it. + * + * `catalog.test.ts` fails when a component is exported from `$lib/ui`, or a + * widget is registered, without an entry here — so the catalog cannot fall + * behind the kit. + */ + +export const CATALOG_GROUPS = [ + 'Actions', + 'Forms', + 'Feedback', + 'Overlays', + 'Navigation', + 'Data display', + 'Widgets' +] as const; + +export type CatalogGroup = (typeof CATALOG_GROUPS)[number]; + +export interface CatalogEntry { + /** Anchor id on the page, and the key the demo is looked up by. */ + id: string; + /** The export name for an element; the manifest label for a widget. */ + name: string; + group: CatalogGroup; + summary: string; + code: string; +} + +const ui = (names: string) => `import { ${names} } from '$lib/ui';\n\n`; + +export const catalog: CatalogEntry[] = [ + { + id: 'button', + name: 'Button', + group: 'Actions', + summary: + 'Four variants and three sizes. With an href it renders a link; loading keeps focus and blocks clicks.', + code: + ui('Button') + + ` + + + +` + }, + { + id: 'menu', + name: 'Menu', + group: 'Actions', + summary: + 'A button that opens a list of actions. Arrow keys move, Escape closes and returns focus.', + code: + ui('Menu') + + ` run(e.detail.id)} +/>` + }, + { + id: 'text-input', + name: 'TextInput', + group: 'Forms', + summary: + 'A labelled input with a hint and an error, wired with aria-describedby and aria-invalid.', + code: + ui('TextInput') + + `` + }, + { + id: 'textarea', + name: 'Textarea', + group: 'Forms', + summary: 'Multi-line input, with a live character count when it has a maxlength.', + code: ui('Textarea') + ` + + diff --git a/src/lib/widgets/StatWidget.svelte b/src/lib/widgets/StatWidget.svelte new file mode 100644 index 0000000..70bc7e5 --- /dev/null +++ b/src/lib/widgets/StatWidget.svelte @@ -0,0 +1,89 @@ + + + +
+

{label}

+
+

{value}

+ {#if delta !== null} + {deltaLabel} + {/if} +
+ {#if points} + + {/if} +
+ + diff --git a/src/lib/widgets/index.ts b/src/lib/widgets/index.ts index fa3e557..f93b970 100644 --- a/src/lib/widgets/index.ts +++ b/src/lib/widgets/index.ts @@ -8,8 +8,21 @@ */ import type { ComponentType, SvelteComponent } from 'svelte'; +import ChecklistWidget from './ChecklistWidget.svelte'; +import ClockWidget from './ClockWidget.svelte'; +import LinksWidget from './LinksWidget.svelte'; +import MeterWidget from './MeterWidget.svelte'; +import NotesWidget from './NotesWidget.svelte'; +import StatWidget from './StatWidget.svelte'; -const widgetComponents: Record> = {}; +const widgetComponents: Record> = { + notes: NotesWidget as ComponentType, + stat: StatWidget as ComponentType, + clock: ClockWidget as ComponentType, + checklist: ChecklistWidget as ComponentType, + meter: MeterWidget as ComponentType, + links: LinksWidget as ComponentType +}; export function getWidgetComponent(name: string): ComponentType | null { // Own-property check, not a bare lookup: type names come from stored layout diff --git a/src/lib/widgets/manifest.ts b/src/lib/widgets/manifest.ts index e0f1760..abcf881 100644 --- a/src/lib/widgets/manifest.ts +++ b/src/lib/widgets/manifest.ts @@ -6,8 +6,8 @@ * Add entries here and a matching component in ./index.ts to register a widget * type for your project. Registering a widget never means editing the board. * - * Ships empty on purpose: the board is the feature, the widgets belong to the app - * that mounts it. + * Ships with a standard set, shown live at /components. Remove what your app + * does not use; add your own the same way. */ export interface WidgetDefinition { @@ -20,7 +20,44 @@ export interface WidgetDefinition { defaultProps: Record; } -export const widgetManifest: WidgetDefinition[] = []; +export const widgetManifest: WidgetDefinition[] = [ + { + name: 'notes', + label: 'Notes', + description: 'A scratch pad. Keeps its own local state and nothing else.', + defaultProps: { text: '' } + }, + { + name: 'stat', + label: 'Stat', + description: 'A number, its change, and a sparkline in the chart palette.', + defaultProps: { label: 'Metric', value: '0', delta: null, series: [], accent: 'views' } + }, + { + name: 'clock', + label: 'Clock', + description: 'Ticks every second and reports a live title, never a stored one.', + defaultProps: { label: 'Local time' } + }, + { + name: 'checklist', + label: 'Checklist', + description: 'A short to-do list with a done count.', + defaultProps: { items: [] } + }, + { + name: 'meter', + label: 'Meter', + description: 'Progress toward a goal, or usage against a limit.', + defaultProps: { label: 'Goal', value: 0, goal: 100, unit: '', limit: false } + }, + { + name: 'links', + label: 'Links', + description: 'A short list of bookmarks.', + defaultProps: { links: [] } + } +]; export function getWidgetDefinition(name: string): WidgetDefinition | undefined { return widgetManifest.find((widget) => widget.name === name); diff --git a/src/lib/widgets/registry.test.ts b/src/lib/widgets/registry.test.ts index 6c1e709..bde1b2a 100644 --- a/src/lib/widgets/registry.test.ts +++ b/src/lib/widgets/registry.test.ts @@ -3,8 +3,24 @@ import { getWidgetComponent } from './index'; import { getWidgetDefinition, widgetManifest } from './manifest'; describe('widget manifest', () => { - it('ships empty, so a project brings its own widgets', () => { - expect(widgetManifest).toEqual([]); + it('ships the standard set', () => { + expect(widgetManifest.map((widget) => widget.name)).toEqual([ + 'notes', + 'stat', + 'clock', + 'checklist', + 'meter', + 'links' + ]); + }); + + it('gives every widget the metadata a picker would need', () => { + for (const widget of widgetManifest) { + expect(widget.name).toMatch(/^[a-z0-9-]+$/); + expect(widget.label).toBeTruthy(); + expect(widget.description).toBeTruthy(); + expect(widget.defaultProps).toBeTypeOf('object'); + } }); it('returns nothing for an unregistered type', () => { @@ -12,22 +28,17 @@ describe('widget manifest', () => { }); it('finds a registered definition by name', () => { - widgetManifest.push({ - name: 'notes', - label: 'Notes', - description: 'A scratch pad.', - defaultProps: { text: '' } - }); - - try { - expect(getWidgetDefinition('notes')?.label).toBe('Notes'); - } finally { - widgetManifest.length = 0; - } + expect(getWidgetDefinition('notes')?.label).toBe('Notes'); }); }); describe('widget component registry', () => { + it('has a component for every manifest entry, and no orphans', () => { + for (const widget of widgetManifest) { + expect(getWidgetComponent(widget.name)).not.toBeNull(); + } + }); + it('returns null for an unregistered type', () => { expect(getWidgetComponent('nope')).toBeNull(); }); diff --git a/src/lib/widgets/widgets.test.ts b/src/lib/widgets/widgets.test.ts new file mode 100644 index 0000000..1e403af --- /dev/null +++ b/src/lib/widgets/widgets.test.ts @@ -0,0 +1,205 @@ +import { render, screen, fireEvent } from '@testing-library/svelte'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import Harness from '../../../tests/fixtures/UiKitHarness.svelte'; +import ChecklistWidget from './ChecklistWidget.svelte'; +import ClockWidget from './ClockWidget.svelte'; +import LinksWidget from './LinksWidget.svelte'; +import MeterWidget from './MeterWidget.svelte'; +import NotesWidget from './NotesWidget.svelte'; +import StatWidget from './StatWidget.svelte'; + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('NotesWidget', () => { + it('invites the first note', () => { + render(NotesWidget); + + expect(screen.getByLabelText('Notes')).toHaveAttribute( + 'placeholder', + 'Type anything. It stays here.' + ); + }); + + it('starts from the text it was given', () => { + render(NotesWidget, { props: { text: 'remember the milk' } }); + + expect(screen.getByLabelText('Notes')).toHaveValue('remember the milk'); + }); + + it('takes an edit', async () => { + render(NotesWidget); + const field = screen.getByLabelText('Notes'); + + await fireEvent.input(field, { target: { value: 'typed' } }); + + expect(field).toHaveValue('typed'); + }); +}); + +describe('StatWidget', () => { + it('shows the label and the number', () => { + render(StatWidget, { props: { label: 'Deploys', value: '128' } }); + + expect(screen.getByText('Deploys')).toBeInTheDocument(); + expect(screen.getByText('128')).toBeInTheDocument(); + }); + + it('marks a rise and a fall differently', () => { + const { unmount } = render(StatWidget, { props: { delta: 12 } }); + + expect(screen.getByText('+12%')).toHaveAttribute('data-direction', 'up'); + unmount(); + + render(StatWidget, { props: { delta: -4 } }); + + expect(screen.getByText('-4%')).toHaveAttribute('data-direction', 'down'); + }); + + it('treats no movement as flat rather than as a fall', () => { + const { container } = render(StatWidget, { props: { delta: 0 } }); + const badge = container.querySelector('.stat__delta'); + + expect(badge?.textContent?.trim()).toBe('0%'); + expect(badge).toHaveAttribute('data-direction', 'flat'); + }); + + it('hides the badge when there is no change to report', () => { + const { container } = render(StatWidget, { props: { delta: null } }); + + expect(container.querySelector('.stat__delta')).toBeNull(); + }); + + it('draws a sparkline once there are two readings', () => { + const { container } = render(StatWidget, { props: { series: [1, 4, 2] } }); + + expect(container.querySelector('polyline')?.getAttribute('points')).toBeTruthy(); + }); + + it('draws no sparkline for a series with no shape', () => { + const { container } = render(StatWidget, { props: { series: [1] } }); + + expect(container.querySelector('svg')).toBeNull(); + }); + + it('colours itself from the chart palette', () => { + const { container } = render(StatWidget, { props: { accent: 'users', series: [1, 2] } }); + + expect(container.querySelector('.stat')?.getAttribute('style')).toContain('var(--chart-users)'); + }); +}); + +describe('ClockWidget', () => { + it('shows a time and the day it belongs to', () => { + render(ClockWidget, { props: { timeZone: 'UTC', label: 'UTC' } }); + + expect(screen.getByTestId('clock-time').textContent).toMatch(/\d{1,2}:\d{2}:\d{2}/); + expect(screen.getByText(/UTC$/)).toBeInTheDocument(); + }); + + it('reports a live title instead of writing one to state', async () => { + vi.useFakeTimers(); + const live = vi.fn(); + render(Harness, { + props: { + which: 'clock', + props: { timeZone: 'UTC' }, + onEvent: (_: string, d: string) => live(d) + } + }); + + await vi.advanceTimersByTimeAsync(1000); + + expect(live).toHaveBeenCalled(); + expect(live.mock.lastCall?.[0]).toMatch(/\d{1,2}:\d{2}:\d{2}/); + expect(screen.getByTestId('clock-time').textContent).toBe(live.mock.lastCall?.[0]); + }); + + it('keeps ticking rather than blanking on an unusable time zone', () => { + render(ClockWidget, { props: { timeZone: 'Not/AZone' } }); + + expect(screen.getByTestId('clock-time').textContent).toMatch(/\d{1,2}:\d{2}:\d{2}/); + }); + + it('stops its timer when it goes away', () => { + vi.useFakeTimers(); + const clear = vi.spyOn(globalThis, 'clearInterval'); + const { unmount } = render(ClockWidget); + + unmount(); + + expect(clear).toHaveBeenCalled(); + }); +}); + +describe('ChecklistWidget', () => { + const items = [ + { id: 'a', text: 'Write tests', done: true }, + { id: 'b', text: 'Ship it' } + ]; + + it('counts what is done, and ticks an item', async () => { + render(ChecklistWidget, { props: { items: items.map((i) => ({ ...i })) } }); + expect(screen.getByText('1 of 2 done')).toBeInTheDocument(); + await fireEvent.click(screen.getByLabelText('Ship it')); + expect(screen.getByText('2 of 2 done')).toBeInTheDocument(); + }); + + it('says so when empty', () => { + render(ChecklistWidget); + expect(screen.getByText('Nothing to do.')).toBeInTheDocument(); + }); +}); + +describe('MeterWidget', () => { + const toneOf = (props: Record) => { + const { container, unmount } = render(MeterWidget, { props }); + const tone = container.querySelector('.meter')?.getAttribute('data-tone'); + unmount(); + return tone; + }; + + it('shows the value against the goal, with a unit', () => { + render(MeterWidget, { props: { label: 'Storage', value: 1200, goal: 2000, unit: ' MB' } }); + expect(screen.getByText(/1,200 MB/)).toBeInTheDocument(); + expect(screen.getByRole('progressbar', { name: 'Storage' })).toHaveAttribute( + 'aria-valuenow', + '1200' + ); + }); + + it('goes green on reaching a target', () => { + expect(toneOf({ value: 50, goal: 100 })).toBe('primary'); + expect(toneOf({ value: 100, goal: 100 })).toBe('success'); + }); + + it('warns near a limit and turns red past it', () => { + expect(toneOf({ value: 10, goal: 100, limit: true })).toBe('success'); + expect(toneOf({ value: 85, goal: 100, limit: true })).toBe('warning'); + expect(toneOf({ value: 120, goal: 100, limit: true })).toBe('danger'); + expect(toneOf({ value: 5, goal: 0 })).toBe('primary'); + }); +}); + +describe('LinksWidget', () => { + it('opens external links in a new tab and says so', () => { + render(LinksWidget, { + props: { + links: [ + { label: 'Docs', href: '/documentation' }, + { label: 'GitHub', href: 'https://github.com' } + ] + } + }); + const external = screen.getByRole('link', { name: /GitHub/ }); + expect(external).toHaveAttribute('target', '_blank'); + expect(external.textContent).toContain('opens in a new tab'); + expect(screen.getByRole('link', { name: 'Docs' })).not.toHaveAttribute('target'); + }); + + it('says so when empty', () => { + render(LinksWidget); + expect(screen.getByText('No links yet.')).toBeInTheDocument(); + }); +}); diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 17e5e57..2b8a270 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -1,4 +1,5 @@ + + + +
+
+

Component catalog

+

Every standard element, running

+

+ {catalog.filter((e) => e.group !== 'Widgets').length} elements and + {inGroup('Widgets').length} widgets, all themed with CSS variables, all usable by keyboard, all + in light and dark. Import from $lib/ui; register widgets in + $lib/widgets. +

+ +
+ + {#each CATALOG_GROUPS as group (group)} +
+

{group}

+
+ {#if group === 'Actions'} + + + + + + + + + + (lastAction = e.detail.id)} + /> + {lastAction ? `Picked: ${lastAction}` : ''} + + {:else if group === 'Forms'} + +
+ +
+
+ +
+