Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<html>`, not on `prefers-color-scheme`: the user toggle overrides the
OS, so `<picture media="(prefers-color-scheme: dark)">` 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

Expand Down
11 changes: 9 additions & 2 deletions FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down
75 changes: 75 additions & 0 deletions docs/UI_KIT.md
Original file line number Diff line number Diff line change
@@ -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 `<dialog>`, `Accordion` is `<details>`,
`Select` is `<select>`, `Switch` is a checkbox with `role="switch"`. Native
elements bring focus handling, Escape and assistive-technology support that
a hand-built version has to re-earn.
3. **Labelled and described.** Every form control is built on `Field`, which
renders a real `<label for>`, joins the hint and error into
`aria-describedby`, and sets `aria-invalid` when there is an error.
4. **Keyboard complete.** `Tabs` and `Menu` use roving focus (arrows, Home,
End, disabled items skipped). `Menu` closes on Escape and returns focus to
its button. `Tooltip` shows on focus as well as hover, and Escape hides it.
5. **Motion respects the setting.** Spinners slow down, skeletons stop
shimmering, cards stop lifting under `prefers-reduced-motion`.
6. **Logic in `logic.ts`.** Anything an element works out — page ranges,
roving focus, sorting, initials, percentages, ids — is a pure function there.
`*.svelte` is excluded from coverage, so this is what keeps the kit tested.

---

## Toasts

The root layout renders one `<Toaster />`. Raise a toast from anywhere:

```ts
toast.success('Saved');
toast.danger('Could not reach the server', 0); // 0 = stays until dismissed
const id = toast.info('Uploading…', 0);
toast.dismiss(id);
```

---

## Adding an element

1. Write the logic test, then the logic, in `logic.ts` if it decides anything.
2. Write the component in `src/lib/ui/`, and export it from `index.ts`.
3. Add behaviour tests to `ui.test.ts` (a slot-filling fixture lives in
`tests/fixtures/UiKitHarness.svelte`).
4. Add an entry to `catalog.ts` and a demo to `src/routes/components/+page.svelte`.
`catalog.test.ts` fails until the entry exists.
5. Mention it in `/documentation` and `FEATURES.md` in the same change
(AGENTS.md §7).
7 changes: 5 additions & 2 deletions docs/WIDGET_BOARD.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@
A columned board of draggable widgets, and the drag behaviour underneath it.
Works with a mouse, a finger, or a keyboard alone.

The widget registry ships **empty** — the kit gives you the mechanism, not
someone else's widgets.
The registry ships six standard widgets — stat, clock, checklist, meter, notes
and links — shown running at `/components`. They are examples of the contract as
much as features: delete the ones your app does not use.

---

Expand Down Expand Up @@ -162,6 +163,8 @@ Every component added to this library ships with all six:
4. Tests, written first, holding the 95% coverage floor (AGENTS.md §1).
5. A `/documentation` entry, in the same change (AGENTS.md §7).
6. A `FEATURES.md` bullet.
7. An entry in `src/lib/ui/catalog.ts` and a demo on `/components`. The catalog
test fails without the entry.

---

Expand Down
13 changes: 13 additions & 0 deletions src/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,15 @@
--color-success: #1a7f37;
--color-warning: #ffc107;

/* Solid fills with white text on them (UI kit buttons). Separate from
--color-primary because the dark theme's primary is tuned for text on a
dark ground, and white on it is only 3.7:1. Every pair here is >= 4.5:1. */
--color-primary-solid: #0066cc;
--color-primary-solid-hover: #0052a3;
--color-danger-solid: #c92a35;
--color-danger-solid-hover: #a61e29;
--color-on-solid: #ffffff;

/* Chart series (admin stats). A fixed categorical order — a series keeps its
hue regardless of rank, so filtering never repaints the survivors. Both
modes are stepped independently against their own surface and validated for
Expand Down Expand Up @@ -83,6 +92,10 @@
--color-danger: #ef4444; /* destructive accents; 4.6:1 on the dark background */
--color-success: #10b981;
--color-warning: #f59e0b;
--color-primary-solid: #2563eb;
--color-primary-solid-hover: #1d4ed8;
--color-danger-solid: #dc2626;
--color-danger-solid-hover: #b91c1c;

/* Chart series — stepped for the dark surface, not derived from the light
values. See the note in :root. */
Expand Down
1 change: 1 addition & 0 deletions src/lib/agent-discovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ export interface SitemapRoute {
export const SITEMAP_ROUTES: readonly SitemapRoute[] = [
{ path: '/', changefreq: 'weekly', priority: 1.0 },
{ path: '/documentation', changefreq: 'weekly', priority: 0.8 },
{ path: '/components', changefreq: 'weekly', priority: 0.7 },
{ path: '/chat', changefreq: 'monthly', priority: 0.6 },
{ path: '/contact', changefreq: 'monthly', priority: 0.5 },
{ path: '/privacy', changefreq: 'yearly', priority: 0.3 },
Expand Down
20 changes: 18 additions & 2 deletions src/lib/components/CommandPalette.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -359,7 +368,14 @@
on:click={closeCommandPalette}
aria-label="Close command palette"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
Expand Down
26 changes: 26 additions & 0 deletions src/lib/components/CommandPalette.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
3 changes: 3 additions & 0 deletions src/lib/components/Footer.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,9 @@
<li>
<a href="/documentation">Documentation</a>
</li>
<li>
<a href="/components">Components</a>
</li>
<li>
<a href={repoUrl} target="_blank" rel="noopener noreferrer"> GitHub </a>
</li>
Expand Down
8 changes: 8 additions & 0 deletions src/lib/components/Footer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
86 changes: 86 additions & 0 deletions src/lib/ui/Accordion.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
<!--
Accordion — built on <details>, so it opens with no script, works with
find-in-page, and is announced correctly. `exclusive` lets only one item
be open at a time (the native `name` attribute).
-->
<script lang="ts">
import { uid } from './logic';

export let items: { title: string; content?: string; open?: boolean }[] = [];
export let exclusive = false;

const group = uid('accordion');
// An action rather than a `name` attribute: Svelte 4's element typings do not
// know <details name>, and a spread would turn `open` into a property write.
function grouped(node: HTMLDetailsElement, name: string | null) {
const apply = (value: string | null) =>
value ? node.setAttribute('name', value) : node.removeAttribute('name');
apply(name);
return { update: apply };
}
</script>

<div class="accordion">
{#each items as item, i (i)}
<details use:grouped={exclusive ? group : null} open={item.open}>
<summary>{item.title}</summary>
<div class="accordion__content">
<slot {item} index={i}>{item.content ?? ''}</slot>
</div>
</details>
{/each}
</div>

<style>
.accordion {
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
overflow: hidden;
}

details + details {
border-top: 1px solid var(--color-border);
}

summary {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-md);
padding: var(--spacing-md);
background: var(--color-surface);
color: var(--color-text);
font-weight: 600;
list-style: none;
cursor: pointer;
}

summary::-webkit-details-marker {
display: none;
}

summary::after {
content: '+';
font-size: 1.25rem;
line-height: 1;
color: var(--color-text-secondary);
}

details[open] summary::after {
content: '−';
}

summary:hover {
background: var(--color-surface-hover);
}

summary:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: -2px;
}

.accordion__content {
padding: var(--spacing-md);
color: var(--color-text-secondary);
}
</style>
Loading
Loading