From 7b1ce870e34318d86cd3072b972c77dfc8c560ed Mon Sep 17 00:00:00 2001 From: David Monaghan Date: Sat, 26 Sep 2026 19:22:21 -0400 Subject: [PATCH 1/6] agents: screenshots of other sites ship in light and dark MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Appended as the last Release Rules bullet so existing Β§ numbers hold. Switch on data-theme, not prefers-color-scheme: the toggle overrides the OS. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WMicDfQtzkyrzjKLk4KExV --- AGENTS.md | 6 ++++++ 1 file changed, 6 insertions(+) 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 From 9ed5fed16241bf6ea354d8ec50e77dcc2a5e4b8b Mon Sep 17 00:00:00 2001 From: David Monaghan Date: Sat, 3 Oct 2026 00:22:42 -0400 Subject: [PATCH 2/6] palette: leave Escape alone while a modal dialog is open A bare Escape opens the command palette, and its preventDefault cancelled a native 's own close. A modal could not be dismissed by key, and the palette opened over it instead. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011fq6DwPCHzHvvHZsMPrReN --- src/lib/components/CommandPalette.svelte | 20 +++++++++++++++-- src/lib/components/CommandPalette.test.ts | 26 +++++++++++++++++++++++ 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/src/lib/components/CommandPalette.svelte b/src/lib/components/CommandPalette.svelte index 2f0d302..ca659f4 100644 --- a/src/lib/components/CommandPalette.svelte +++ b/src/lib/components/CommandPalette.svelte @@ -115,6 +115,13 @@ action: () => goto('/documentation'), icon: 'πŸ“š' }, + { + id: 'components', + label: 'Components', + description: 'Every UI element and widget, running', + action: () => goto('/components'), + icon: '🧩' + }, ...cmsCommands.map((command) => ({ id: command.id, label: command.label, @@ -238,7 +245,9 @@ function handleKeydown(e: KeyboardEvent) { if (!show) { - if (e.key === 'Escape' && !e.defaultPrevented) { + // An open modal dialog owns Escape: preventDefault here would cancel its close. + const inDialog = document.querySelector('dialog[open]') !== null; + if (e.key === 'Escape' && !e.defaultPrevented && !inDialog) { e.preventDefault(); show = true; } @@ -359,7 +368,14 @@ on:click={closeCommandPalette} aria-label="Close command palette" > - + diff --git a/src/lib/components/CommandPalette.test.ts b/src/lib/components/CommandPalette.test.ts index 0d85c78..53b2835 100644 --- a/src/lib/components/CommandPalette.test.ts +++ b/src/lib/components/CommandPalette.test.ts @@ -248,6 +248,32 @@ describe('CommandPalette', () => { expect(component.show).toBe(true); }); + // A bare Escape opens the palette, but inside an open modal dialog Escape + // belongs to the dialog. Calling preventDefault there cancelled the + // dialog's own close, so the UI kit's Dialog could not be dismissed by key. + it('leaves Escape alone while a modal dialog is open', async () => { + const dialog = document.createElement('dialog'); + dialog.setAttribute('open', ''); + const inside = document.createElement('button'); + dialog.append(inside); + document.body.append(dialog); + try { + const { component } = render(CommandPalette, { props: { show: false } }); + const event = new KeyboardEvent('keydown', { + key: 'Escape', + bubbles: true, + cancelable: true + }); + inside.dispatchEvent(event); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(event.defaultPrevented).toBe(false); + expect(component.show).toBe(false); + } finally { + dialog.remove(); + } + }); + it('should display command icons', () => { const { container } = render(CommandPalette, { props: { show: true } }); const icons = container.querySelectorAll('.command-icon'); From 4ab108234ec1f3e183df00ec43b6faf4d3f8e660 Mon Sep 17 00:00:00 2001 From: David Monaghan Date: Sat, 3 Oct 2026 00:22:42 -0400 Subject: [PATCH 3/6] ui: a standard element library, six widgets, and a live catalog $lib/ui adds 27 standard elements: - Actions: Button, Menu - Forms: Field, TextInput, Textarea, Select, Checkbox, RadioGroup, Switch, Slider - Feedback: Alert, Toaster with a toast store, Badge, Progress, Spinner, Skeleton - Overlays: Dialog (native ), Tooltip - Navigation: Tabs, Accordion (
), Breadcrumbs, Pagination - Data display: Card, Table (sortable), Avatar, Kbd, EmptyState All use CSS variables only and work in light and dark. axe finds no violations in either theme, and all work by keyboard. Decision logic (page ranges, roving focus, sorting, initials) is in logic.ts at 100% coverage. Behaviour tests are in ui.test.ts. New *-solid tokens give white text at least 4.5:1. White on the dark theme's --color-primary measures only 3.7:1. The widget registry no longer ships empty. Stat, clock and notes come from the marketing site, and checklist, meter and links are new. The clock test asserted >= 0 and used the removed $on API. It now checks that the reported time is the one shown. /components shows every element and widget running, with its code. catalog.test.ts fails when a component or widget has no catalog entry. The root layout renders . The page is in the sitemap, the footer and the command palette. /documentation has a UI Kit section, and docs/UI_KIT.md has the reference. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011fq6DwPCHzHvvHZsMPrReN --- FEATURES.md | 11 +- docs/UI_KIT.md | 75 +++ docs/WIDGET_BOARD.md | 7 +- src/app.css | 13 + src/lib/agent-discovery.ts | 1 + src/lib/components/Footer.svelte | 3 + src/lib/components/Footer.test.ts | 8 + src/lib/ui/Accordion.svelte | 78 ++++ src/lib/ui/Alert.svelte | 108 +++++ src/lib/ui/Avatar.svelte | 59 +++ src/lib/ui/Badge.svelte | 48 ++ src/lib/ui/Breadcrumbs.svelte | 54 +++ src/lib/ui/Button.svelte | 165 +++++++ src/lib/ui/Card.svelte | 94 ++++ src/lib/ui/Checkbox.svelte | 51 ++ src/lib/ui/Dialog.svelte | 122 +++++ src/lib/ui/EmptyState.svelte | 52 +++ src/lib/ui/Field.svelte | 72 +++ src/lib/ui/Kbd.svelte | 36 ++ src/lib/ui/Menu.svelte | 200 ++++++++ src/lib/ui/Pagination.svelte | 132 ++++++ src/lib/ui/Progress.svelte | 98 ++++ src/lib/ui/RadioGroup.svelte | 85 ++++ src/lib/ui/Select.svelte | 49 ++ src/lib/ui/Skeleton.svelte | 65 +++ src/lib/ui/Slider.svelte | 57 +++ src/lib/ui/Spinner.svelte | 49 ++ src/lib/ui/Switch.svelte | 84 ++++ src/lib/ui/Table.svelte | 151 ++++++ src/lib/ui/Tabs.svelte | 120 +++++ src/lib/ui/TextInput.svelte | 55 +++ src/lib/ui/Textarea.svelte | 58 +++ src/lib/ui/Toaster.svelte | 75 +++ src/lib/ui/Tooltip.svelte | 103 ++++ src/lib/ui/catalog.test.ts | 34 ++ src/lib/ui/catalog.ts | 352 ++++++++++++++ src/lib/ui/index.ts | 37 ++ src/lib/ui/logic.test.ts | 168 +++++++ src/lib/ui/logic.ts | 148 ++++++ src/lib/ui/toast.test.ts | 50 ++ src/lib/ui/toast.ts | 57 +++ src/lib/ui/ui.test.ts | 394 ++++++++++++++++ src/lib/utils/sparkline.test.ts | 41 ++ src/lib/utils/sparkline.ts | 41 ++ src/lib/widgets/ChecklistWidget.svelte | 64 +++ src/lib/widgets/ClockWidget.svelte | 73 +++ src/lib/widgets/LinksWidget.svelte | 47 ++ src/lib/widgets/MeterWidget.svelte | 52 +++ src/lib/widgets/NotesWidget.svelte | 32 ++ src/lib/widgets/StatWidget.svelte | 89 ++++ src/lib/widgets/index.ts | 15 +- src/lib/widgets/manifest.ts | 43 +- src/lib/widgets/registry.test.ts | 39 +- src/lib/widgets/widgets.test.ts | 201 ++++++++ src/routes/+layout.svelte | 4 + src/routes/components/+page.svelte | 572 +++++++++++++++++++++++ src/routes/components/CatalogItem.svelte | 98 ++++ src/routes/documentation/+page.svelte | 49 +- tests/fixtures/UiKitHarness.svelte | 53 +++ tests/unit/documentation-page.test.ts | 20 + 60 files changed, 5187 insertions(+), 24 deletions(-) create mode 100644 docs/UI_KIT.md create mode 100644 src/lib/ui/Accordion.svelte create mode 100644 src/lib/ui/Alert.svelte create mode 100644 src/lib/ui/Avatar.svelte create mode 100644 src/lib/ui/Badge.svelte create mode 100644 src/lib/ui/Breadcrumbs.svelte create mode 100644 src/lib/ui/Button.svelte create mode 100644 src/lib/ui/Card.svelte create mode 100644 src/lib/ui/Checkbox.svelte create mode 100644 src/lib/ui/Dialog.svelte create mode 100644 src/lib/ui/EmptyState.svelte create mode 100644 src/lib/ui/Field.svelte create mode 100644 src/lib/ui/Kbd.svelte create mode 100644 src/lib/ui/Menu.svelte create mode 100644 src/lib/ui/Pagination.svelte create mode 100644 src/lib/ui/Progress.svelte create mode 100644 src/lib/ui/RadioGroup.svelte create mode 100644 src/lib/ui/Select.svelte create mode 100644 src/lib/ui/Skeleton.svelte create mode 100644 src/lib/ui/Slider.svelte create mode 100644 src/lib/ui/Spinner.svelte create mode 100644 src/lib/ui/Switch.svelte create mode 100644 src/lib/ui/Table.svelte create mode 100644 src/lib/ui/Tabs.svelte create mode 100644 src/lib/ui/TextInput.svelte create mode 100644 src/lib/ui/Textarea.svelte create mode 100644 src/lib/ui/Toaster.svelte create mode 100644 src/lib/ui/Tooltip.svelte create mode 100644 src/lib/ui/catalog.test.ts create mode 100644 src/lib/ui/catalog.ts create mode 100644 src/lib/ui/index.ts create mode 100644 src/lib/ui/logic.test.ts create mode 100644 src/lib/ui/logic.ts create mode 100644 src/lib/ui/toast.test.ts create mode 100644 src/lib/ui/toast.ts create mode 100644 src/lib/ui/ui.test.ts create mode 100644 src/lib/utils/sparkline.test.ts create mode 100644 src/lib/utils/sparkline.ts create mode 100644 src/lib/widgets/ChecklistWidget.svelte create mode 100644 src/lib/widgets/ClockWidget.svelte create mode 100644 src/lib/widgets/LinksWidget.svelte create mode 100644 src/lib/widgets/MeterWidget.svelte create mode 100644 src/lib/widgets/NotesWidget.svelte create mode 100644 src/lib/widgets/StatWidget.svelte create mode 100644 src/lib/widgets/widgets.test.ts create mode 100644 src/routes/components/+page.svelte create mode 100644 src/routes/components/CatalogItem.svelte create mode 100644 tests/fixtures/UiKitHarness.svelte 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 `
  • Documentation
  • +
  • + Components +
  • GitHub
  • diff --git a/src/lib/components/Footer.test.ts b/src/lib/components/Footer.test.ts index 18e90c4..24b871c 100644 --- a/src/lib/components/Footer.test.ts +++ b/src/lib/components/Footer.test.ts @@ -63,6 +63,14 @@ describe('Footer', () => { expect(signupLink).toHaveAttribute('href', '/auth/signup'); }); + it('links the component catalog', () => { + render(Footer); + expect(screen.getByRole('link', { name: /^components$/i })).toHaveAttribute( + 'href', + '/components' + ); + }); + it('should contain documentation link pointing to /documentation', () => { render(Footer); const docsLink = screen.getByRole('link', { name: /documentation/i }); diff --git a/src/lib/ui/Accordion.svelte b/src/lib/ui/Accordion.svelte new file mode 100644 index 0000000..5b6531b --- /dev/null +++ b/src/lib/ui/Accordion.svelte @@ -0,0 +1,78 @@ + + + +
    + {#each items as item, i (i)} +
    + {item.title} +
    + {item.content ?? ''} +
    +
    + {/each} +
    + + diff --git a/src/lib/ui/Alert.svelte b/src/lib/ui/Alert.svelte new file mode 100644 index 0000000..e83bcc1 --- /dev/null +++ b/src/lib/ui/Alert.svelte @@ -0,0 +1,108 @@ + + + +{#if open} +
    + +
    + {#if title}

    {title}

    {/if} +
    +
    + {#if dismissible} + + {/if} +
    +{/if} + + diff --git a/src/lib/ui/Avatar.svelte b/src/lib/ui/Avatar.svelte new file mode 100644 index 0000000..e66c7a9 --- /dev/null +++ b/src/lib/ui/Avatar.svelte @@ -0,0 +1,59 @@ + + + + + {#if src && !failed} + (failed = true)} /> + {:else} + + {/if} + + + diff --git a/src/lib/ui/Badge.svelte b/src/lib/ui/Badge.svelte new file mode 100644 index 0000000..8d0773a --- /dev/null +++ b/src/lib/ui/Badge.svelte @@ -0,0 +1,48 @@ + + + + + + diff --git a/src/lib/ui/Breadcrumbs.svelte b/src/lib/ui/Breadcrumbs.svelte new file mode 100644 index 0000000..c0dcb83 --- /dev/null +++ b/src/lib/ui/Breadcrumbs.svelte @@ -0,0 +1,54 @@ + + + + + + diff --git a/src/lib/ui/Button.svelte b/src/lib/ui/Button.svelte new file mode 100644 index 0000000..92493c0 --- /dev/null +++ b/src/lib/ui/Button.svelte @@ -0,0 +1,165 @@ + + + +{#if href && !disabled} + + {#if loading}{/if} + + +{:else} + +{/if} + + diff --git a/src/lib/ui/Card.svelte b/src/lib/ui/Card.svelte new file mode 100644 index 0000000..f2e6b52 --- /dev/null +++ b/src/lib/ui/Card.svelte @@ -0,0 +1,94 @@ + + + + + {#if title || $$slots.header} +
    + {#if title}{title}{/if} + +
    + {/if} +
    + {#if $$slots.footer} +
    + {/if} +
    + + diff --git a/src/lib/ui/Checkbox.svelte b/src/lib/ui/Checkbox.svelte new file mode 100644 index 0000000..7f248e2 --- /dev/null +++ b/src/lib/ui/Checkbox.svelte @@ -0,0 +1,51 @@ + + + + + + diff --git a/src/lib/ui/Dialog.svelte b/src/lib/ui/Dialog.svelte new file mode 100644 index 0000000..2dfd5d6 --- /dev/null +++ b/src/lib/ui/Dialog.svelte @@ -0,0 +1,122 @@ + + + + +
    +
    +

    {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..e13c2ec --- /dev/null +++ b/src/lib/ui/Pagination.svelte @@ -0,0 +1,132 @@ + + + +{#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..7ab2dbf --- /dev/null +++ b/src/lib/ui/Slider.svelte @@ -0,0 +1,57 @@ + + + + +
    + + {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..ff1baed --- /dev/null +++ b/src/lib/ui/Table.svelte @@ -0,0 +1,151 @@ + + + + + +
    + + {#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..20e8ed5 --- /dev/null +++ b/src/lib/ui/Textarea.svelte @@ -0,0 +1,58 @@ + + + + + + {#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..79868b7 --- /dev/null +++ b/src/lib/ui/Tooltip.svelte @@ -0,0 +1,103 @@ + + + + (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..6946273 --- /dev/null +++ b/src/lib/ui/catalog.ts @@ -0,0 +1,352 @@ +/** + * 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 type CatalogGroup = + 'Actions' | 'Forms' | 'Feedback' | 'Overlays' | 'Navigation' | 'Data display' | 'Widgets'; + +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; +} + +export const CATALOG_GROUPS: CatalogGroup[] = [ + 'Actions', + 'Forms', + 'Feedback', + 'Overlays', + 'Navigation', + 'Data display', + 'Widgets' +]; + +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..4645acd --- /dev/null +++ b/src/lib/widgets/widgets.test.ts @@ -0,0 +1,201 @@ +import { render, screen, fireEvent } from '@testing-library/svelte'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +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(ClockWidget, { + props: { timeZone: 'UTC' }, + events: { live: (event: CustomEvent) => live(event.detail) } + }); + + 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'} + +
    + +
    +
    + +
    + + {#if maxlength}

    {value.length} / {maxlength}

    {/if} diff --git a/src/lib/ui/catalog.ts b/src/lib/ui/catalog.ts index 6946273..835ef36 100644 --- a/src/lib/ui/catalog.ts +++ b/src/lib/ui/catalog.ts @@ -7,8 +7,17 @@ * behind the kit. */ -export type CatalogGroup = - 'Actions' | 'Forms' | 'Feedback' | 'Overlays' | 'Navigation' | 'Data display' | 'Widgets'; +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. */ @@ -20,16 +29,6 @@ export interface CatalogEntry { code: string; } -export const CATALOG_GROUPS: CatalogGroup[] = [ - 'Actions', - 'Forms', - 'Feedback', - 'Overlays', - 'Navigation', - 'Data display', - 'Widgets' -]; - const ui = (names: string) => `import { ${names} } from '$lib/ui';\n\n`; export const catalog: CatalogEntry[] = [ From 7a1ef91790db384939161bf8aad7c9f729647c72 Mon Sep 17 00:00:00 2001 From: David Monaghan Date: Sat, 3 Oct 2026 01:33:45 -0400 Subject: [PATCH 6/6] test(e2e): find the command palette button by its accessible name The button lost its aria-label when its visible text became its accessible name (WCAG 2.5.3). Two e2e tests still looked for the label, never found the button, and failed CI on main. Match by role and name instead. Co-Authored-By: Claude Sonnet 5.5 --- tests/e2e/app.test.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/e2e/app.test.ts b/tests/e2e/app.test.ts index 06909e8..5fb1e6e 100644 --- a/tests/e2e/app.test.ts +++ b/tests/e2e/app.test.ts @@ -9,8 +9,9 @@ test.describe('Homepage', () => { test('should navigate to documentation page via command palette', async ({ page }) => { await page.goto('/'); - // Open command palette - const commandPaletteBtn = page.locator('button[aria-label="Open command palette"]'); + // Open command palette. The name comes from visually-hidden text, not an + // aria-label, so match by role and accessible name. + const commandPaletteBtn = page.getByRole('button', { name: /open command palette/i }); await expect(commandPaletteBtn).toBeVisible(); await page.waitForLoadState('networkidle'); @@ -39,7 +40,7 @@ test.describe('Homepage', () => { await page.goto('/'); // Wait for hydration so the keyboard handler from onMount is registered - const commandPaletteBtn = page.locator('button[aria-label="Open command palette"]'); + const commandPaletteBtn = page.getByRole('button', { name: /open command palette/i }); await expect(commandPaletteBtn).toBeVisible(); await page.waitForLoadState('networkidle');