@poodle64/ui 2026.9.16 → 2026.9.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -85,30 +85,31 @@ app — the page chrome is. These are the cross-cutting surfaces every route
85
85
  composes from, so a household app gets its layout language from the package
86
86
  rather than rebuilding it:
87
87
 
88
- | Import | What it is |
89
- | ----------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
90
- | `page-header` | The only page-title pattern: optional `breadcrumbs` and `icon` snippets, eyebrow, an optional title, one clamped subtitle, an `info` tooltip, a `meta` row and an `actions` slot. Omit `title` for a header that is a breadcrumb bar. |
91
- | `panel` | The generic titled card: optional icon, subtitle and trailing actions over a body that can opt out of padding. |
92
- | `detail-panel` | The entity-detail surface: header with icon/eyebrow/title/`StatusBadge`/close, scrollable body, footer of actions. |
93
- | `context-column` | The persistent right-hand column: a standing `StatList` plus an optional detail that flows in on select. Below `xl` (1280px), where the column has nowhere to sit, the same content opens from a floating trigger instead of disappearing. |
94
- | `app-dialog` | The dialogue frame: titled header, scrollable body, footer action bar, five sizes (`xs`…`xl`), and an `onOpenChange` for the dismissals the caller did not drive. |
95
- | `dialog-section` | One section of a dialogue body; adjacent sections are divided automatically. |
96
- | `stat-card` | A single metric that earns its space (label, value, unit, sub, status dot, and `valueTone` to colour the figure itself). |
97
- | `stat-list` | A route's low-context integers as a label→value list. Zero-aware: `muted` keeps a healthy zero quiet. |
98
- | `arc-gauge` | A radial capacity/percentage ring for a single 0–100 metric, in a footprint too compact for a `stat-card`. |
99
- | `bar-row` | A labelled horizontal bar with a trailing tabular value, for a ranked list (usage, rank, token burn). |
100
- | `scorecard` | A compact 0/1/2 dot-row health strip for several independent checks read at a glance. |
101
- | `sparkline` | An inline multi-series area+line trend for a row or card with room for a trend but not a full chart. |
102
- | `status` / `status-badge` | The fixed five-state vocabulary (`success \| warning \| error \| info \| neutral`) and the one state chip, with `pulse` for a state still in motion and `class` for placement. `status-badge` alone also accepts `'primary'`, a brand-emphasis extension outside the shared vocabulary — `stat-card`, `stat-list` and `data-table-toolbar` never see it. |
103
- | `empty-state` / `error-state` / `loading-state` | The shared blank, error and loading surfaces. Never hand-roll one. |
104
- | `info-tip` | One tooltip pattern: a small info trigger, or wrap an existing affordance as children. |
105
- | `data-table-toolbar` | Search field plus filter-chip groups for a TanStack table. Owns no state; fires callbacks. |
106
- | `schema-form` | The renderer for a config object the server described: a JSON Schema plus a JSON Forms UI Schema in, a form out. Anything it cannot dispatch renders flagged, never blank; see [Server-described forms](#server-described-forms) below. |
107
- | `data-table-tanstack` | The TanStack-backed table: global search, column filters, master-detail row select, opt-in bulk selection, responsive column hiding, a first-class empty branch. |
108
- | `library-browse` | The faceted catalogue index: search, facet rail, active filter chips, the document table and a pager, all over plain props (`LibraryDocument[]`, `LibraryFacet[]`). The page fetches, maps and routes; the component renders. Library data is fixed components because its shape is stable; configuration is `schema-form` because its shape changes (#30). |
109
- | `collection-detail` | One collection's surface: identity (`detail-panel`), an at-a-glance `stat-list`, and the documents it holds, with `actions` and `children` slots for the app-specific rest. |
110
- | `document-detail` | One document's surface: identity fields, locations, tags and collection memberships, each section present exactly when its data is. Membership links are the app's own via `collectionHref`. |
111
- | `search-results` | A ranked retrieval answer: title, the matched passage with accent-tinted highlights, source chip, mapped state and a mono relevance figure per hit; plus the before-any-search and matched-nothing empties. |
88
+ | Import | What it is |
89
+ | ----------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
90
+ | `page-header` | The only page-title pattern: optional `breadcrumbs` and `icon` snippets, eyebrow, an optional title, one clamped subtitle, an `info` tooltip, a `meta` row and an `actions` slot. Omit `title` for a header that is a breadcrumb bar. |
91
+ | `panel` | The generic titled card, and the settings section: optional icon, a clamped one-line `subtitle` or a wrapping `description`, trailing `action`s, a body that can opt out of padding, an optional `footer` strip for Save/Cancel, and `tone="destructive"` for a danger zone. There is no `SettingsSection`; see [The settings destination](#the-settings-destination). |
92
+ | `detail-panel` | The entity-detail surface: header with icon/eyebrow/title/`StatusBadge`/close, scrollable body, footer of actions. |
93
+ | `settings-shell` | The settings destination: a grouped section list of `NavItem` rows beside the section you are reading, both scrolling independently from `md`, stacked below it. Render it inside `AppShell` with `padded={false}`. |
94
+ | `context-column` | The persistent right-hand column: a standing `StatList` plus an optional detail that flows in on select. Below `xl` (1280px), where the column has nowhere to sit, the same content opens from a floating trigger instead of disappearing. |
95
+ | `app-dialog` | The dialogue frame: titled header, scrollable body, footer action bar, five sizes (`xs`…`xl`), and an `onOpenChange` for the dismissals the caller did not drive. |
96
+ | `dialog-section` | One section of a dialogue body; adjacent sections are divided automatically. |
97
+ | `stat-card` | A single metric that earns its space (label, value, unit, sub, status dot, and `valueTone` to colour the figure itself). |
98
+ | `stat-list` | A route's low-context integers as a label→value list. Zero-aware: `muted` keeps a healthy zero quiet. |
99
+ | `arc-gauge` | A radial capacity/percentage ring for a single 0–100 metric, in a footprint too compact for a `stat-card`. |
100
+ | `bar-row` | A labelled horizontal bar with a trailing tabular value, for a ranked list (usage, rank, token burn). |
101
+ | `scorecard` | A compact 0/1/2 dot-row health strip for several independent checks read at a glance. |
102
+ | `sparkline` | An inline multi-series area+line trend for a row or card with room for a trend but not a full chart. |
103
+ | `status` / `status-badge` | The fixed five-state vocabulary (`success \| warning \| error \| info \| neutral`) and the one state chip, with `pulse` for a state still in motion and `class` for placement. `status-badge` alone also accepts `'primary'`, a brand-emphasis extension outside the shared vocabulary — `stat-card`, `stat-list` and `data-table-toolbar` never see it. |
104
+ | `empty-state` / `error-state` / `loading-state` | The shared blank, error and loading surfaces. Never hand-roll one. |
105
+ | `info-tip` | One tooltip pattern: a small info trigger, or wrap an existing affordance as children. |
106
+ | `data-table-toolbar` | Search field plus filter-chip groups for a TanStack table. Owns no state; fires callbacks. |
107
+ | `schema-form` | The renderer for a config object the server described: a JSON Schema plus a JSON Forms UI Schema in, a form out. Anything it cannot dispatch renders flagged, never blank; see [Server-described forms](#server-described-forms) below. |
108
+ | `data-table-tanstack` | The TanStack-backed table: global search, column filters, master-detail row select, opt-in bulk selection, responsive column hiding, a first-class empty branch. |
109
+ | `library-browse` | The faceted catalogue index: search, facet rail, active filter chips, the document table and a pager, all over plain props (`LibraryDocument[]`, `LibraryFacet[]`). The page fetches, maps and routes; the component renders. Library data is fixed components because its shape is stable; configuration is `schema-form` because its shape changes (#30). |
110
+ | `collection-detail` | One collection's surface: identity (`detail-panel`), an at-a-glance `stat-list`, and the documents it holds, with `actions` and `children` slots for the app-specific rest. |
111
+ | `document-detail` | One document's surface: identity fields, locations, tags and collection memberships, each section present exactly when its data is. Membership links are the app's own via `collectionHref`. |
112
+ | `search-results` | A ranked retrieval answer: title, the matched passage with accent-tinted highlights, source chip, mapped state and a mono relevance figure per hit; plus the before-any-search and matched-nothing empties. |
112
113
 
113
114
  **The application shell** (`app-shell`, `command-palette`). Page chrome is not
114
115
  what makes an app feel like an app either — the shell is. Five household
@@ -302,6 +303,105 @@ palette:
302
303
  `AppNav` used outside the chrome (a navigation list inside a page) keeps the
303
304
  page's ink, so inverting the rail does not drag it along.
304
305
 
306
+ ### The settings destination
307
+
308
+ Settings is the one destination with a second column, and it takes one shape in
309
+ every app. `SettingsShell` is that shape: a grouped section list beside the
310
+ section you are reading, both panes scrolling independently from `md`.
311
+
312
+ ```svelte
313
+ <script lang="ts">
314
+ import { page } from '$app/state';
315
+ import AppShell from '@poodle64/ui/app-shell';
316
+ import type { NavSource } from '@poodle64/ui/app-shell';
317
+ import SettingsShell from '@poodle64/ui/settings-shell';
318
+ import Users from '@lucide/svelte/icons/users';
319
+ import Gavel from '@lucide/svelte/icons/gavel';
320
+
321
+ // Grouped by WHOSE setting it is: the person's, then the workspace's. It is
322
+ // the only split a reader can predict: "yours" changes what you see, "this
323
+ // company" changes what everyone sees. A group with no items renders nothing,
324
+ // so an app with no personal sections yet can leave it in the list.
325
+ const sections: NavSource = [
326
+ { heading: 'Yours', items: [] },
327
+ {
328
+ heading: 'This company',
329
+ items: [
330
+ { label: 'Users', href: '/settings/users', icon: Users },
331
+ { label: 'Delegation limits', href: '/settings/delegation-limits', icon: Gavel }
332
+ ]
333
+ }
334
+ ];
335
+ </script>
336
+
337
+ <!-- padded={false}: the list's rule and tint have to reach the content area's
338
+ edges, and SettingsShell pads its own content pane to the same rhythm. -->
339
+ <AppShell {nav} currentPath={page.url.pathname} settingsHref="/settings" padded={false}>
340
+ <SettingsShell {sections} currentPath={page.url.pathname}>
341
+ {@render children()}
342
+ </SettingsShell>
343
+ </AppShell>
344
+ ```
345
+
346
+ | Prop | Purpose |
347
+ | ------------- | ------------------------------------------------------------------------------ |
348
+ | `sections` | Groups of `NavItem`, bare items, or a mix: the rail's own vocabulary. |
349
+ | `currentPath` | Which row is the page. Prefix-matched on the segment boundary, as the rail is. |
350
+ | `listLabel` | The list's accessible name. Defaults to `Settings sections`. |
351
+
352
+ A row at the settings ROOT (`/settings`) needs `exact: true`, or its prefix match
353
+ keeps it lit on every section beneath it alongside the section's own row.
354
+
355
+ Three things are settled here and are not props:
356
+
357
+ **Rows are icon plus label, with nothing beneath.** A description long enough to
358
+ be worth reading does not fit a 240px column; every one of them truncated to an
359
+ ellipsis and told the reader less than the label already did.
360
+
361
+ **There is no "Settings" heading above the list.** The bar already names the
362
+ section you are on, so a heading here is the same word twice.
363
+
364
+ **Below `md` the list stacks above the content and the page scrolls as one.** A
365
+ horizontally scrollable strip of the same rows was the alternative and loses on
366
+ the requirement it was meant to serve: it fits about two and a half rows at
367
+ 375px, so the rest are behind a sideways swipe with no scrollbar advertising
368
+ them — a swipe _then_ a tap, where a stack is a scroll the reader is already
369
+ doing. It also has to drop the group labels for width.
370
+
371
+ Density works here as it does in the rail, because the list _is_ the rail's
372
+ list: the controls inside a section ride the `--ds-control-*` ramp and shrink
373
+ under `data-ds-density="compact"`, while a section row keeps `AppNav`'s own
374
+ 36px whatever the density. A row that shrank here and not in the rail would be
375
+ the drift, not the fix.
376
+
377
+ #### The sections themselves are `Panel`s
378
+
379
+ There is no `SettingsSection`, and deliberately so: it would have been `Panel`
380
+ with a different name (same surface, same header, same title), and the estate
381
+ would then hold two answers to "a titled section of a route", which is the drift
382
+ this package exists to end. What settings actually needed was three props, and
383
+ all three serve any route:
384
+
385
+ ```svelte
386
+ <Panel title="Delegation limits"
387
+ description="Who can approve a bill, and up to what value.">
388
+ <!-- controls -->
389
+ {#snippet footer()}
390
+ <Button variant="ghost" size="sm">Cancel</Button>
391
+ <Button size="sm">Save</Button>
392
+ {/snippet}
393
+ </Panel>
394
+
395
+ <Panel title="Delete this workspace" tone="destructive" icon={Trash2}
396
+ description="Every project, document and filing decision goes with it.">
397
+ <Button variant="destructive" size="sm">Delete</Button>
398
+ </Panel>
399
+ ```
400
+
401
+ `description` wraps; `subtitle` is the one-line qualifier beside the title and
402
+ truncates. Carry one or the other. A page writes a run of `Panel`s and no
403
+ wrapper: the pane stacks them on the package's section rhythm itself.
404
+
305
405
  ### The content measure
306
406
 
307
407
  How wide the page body may get, chosen once in the layout from a scale of four:
@@ -79,6 +79,7 @@
79
79
  */
80
80
  import type { Snippet } from 'svelte';
81
81
  import type { ShellMeasure } from './measure.js';
82
+ import { CONTENT_PADDING } from './padding.js';
82
83
  import type { ShellTexture } from './texture.js';
83
84
  import { toggleMode } from 'mode-watcher';
84
85
  import Menu from '@lucide/svelte/icons/menu';
@@ -917,8 +918,7 @@
917
918
  'flex min-h-0 min-w-0 flex-1 flex-col',
918
919
  'w-full',
919
920
  capped && 'ds-shell-measure',
920
- padded &&
921
- 'px-4 pt-5 pb-[calc(1.25rem+env(safe-area-inset-bottom))] sm:px-6 md:px-8 md:pt-7 md:pb-[calc(1.75rem+env(safe-area-inset-bottom))] 2xl:px-12'
921
+ padded && CONTENT_PADDING
922
922
  )}
923
923
  data-measure={capped ?? undefined}
924
924
  >
@@ -0,0 +1,20 @@
1
+ /**
2
+ * The page padding the shell gives its content area.
3
+ *
4
+ * A constant rather than a literal in app-shell.svelte, because it is no longer
5
+ * declared in one place: SettingsShell runs inside an UNPADDED content area (its
6
+ * section list has to reach the area's edges to read as a pane rather than a
7
+ * floating card) and then pads its own content pane, which must land on the same
8
+ * rhythm as every other page or the settings destination is the one route whose
9
+ * text sits somewhere else.
10
+ *
11
+ * Utilities rather than a class in styles.css, deliberately: both consumers pass
12
+ * this through `cn()`, so an app overriding the padding — `mainClass="px-0"`, a
13
+ * flush table — still resolves through tailwind-merge. A base-layer class would
14
+ * be invisible to it, and the utility would win by layer order instead of by
15
+ * being last, which is the cascade trap recorded against `.ds-nav`'s ink.
16
+ *
17
+ * The bottom pad carries `env(safe-area-inset-bottom)` so the last row of a page
18
+ * clears a home-indicator gesture bar.
19
+ */
20
+ export declare const CONTENT_PADDING = "px-4 pt-5 pb-[calc(1.25rem+env(safe-area-inset-bottom))] sm:px-6 md:px-8 md:pt-7 md:pb-[calc(1.75rem+env(safe-area-inset-bottom))] 2xl:px-12";
@@ -0,0 +1,20 @@
1
+ /**
2
+ * The page padding the shell gives its content area.
3
+ *
4
+ * A constant rather than a literal in app-shell.svelte, because it is no longer
5
+ * declared in one place: SettingsShell runs inside an UNPADDED content area (its
6
+ * section list has to reach the area's edges to read as a pane rather than a
7
+ * floating card) and then pads its own content pane, which must land on the same
8
+ * rhythm as every other page or the settings destination is the one route whose
9
+ * text sits somewhere else.
10
+ *
11
+ * Utilities rather than a class in styles.css, deliberately: both consumers pass
12
+ * this through `cn()`, so an app overriding the padding — `mainClass="px-0"`, a
13
+ * flush table — still resolves through tailwind-merge. A base-layer class would
14
+ * be invisible to it, and the utility would win by layer order instead of by
15
+ * being last, which is the cascade trap recorded against `.ds-nav`'s ink.
16
+ *
17
+ * The bottom pad carries `env(safe-area-inset-bottom)` so the last row of a page
18
+ * clears a home-indicator gesture bar.
19
+ */
20
+ export const CONTENT_PADDING = 'px-4 pt-5 pb-[calc(1.25rem+env(safe-area-inset-bottom))] sm:px-6 md:px-8 md:pt-7 md:pb-[calc(1.75rem+env(safe-area-inset-bottom))] 2xl:px-12';
@@ -1,17 +1,28 @@
1
1
  <script lang="ts">
2
2
  import type { Snippet, Component } from 'svelte';
3
3
  import type { HTMLAttributes } from 'svelte/elements';
4
- import type { WithElementRef } from '../../../utils.js';
4
+ import { cn, type WithElementRef } from '../../../utils.js';
5
5
 
6
6
  // The generic titled card. Where DetailPanel is the *entity* surface (and owns
7
7
  // a status and a close affordance), Panel is the plain sectioning card any
8
8
  // route reaches for: an optional header with icon, subtitle and trailing
9
9
  // actions, over a body that can opt out of padding for a flush table.
10
+ //
11
+ // It is also the settings SECTION, and there is no second component for that.
12
+ // A `SettingsSection` would have been this card with a different name — same
13
+ // surface, same header, same title — and the estate would then hold two
14
+ // answers to "a titled section of a route", which is the drift this package
15
+ // exists to end. What settings actually needed that this did not have is three
16
+ // props, and all three serve any route: a description that wraps, a footer for
17
+ // the Save/Cancel pair, and a destructive tone for a danger zone.
10
18
  let {
11
19
  title,
12
20
  subtitle,
21
+ description,
13
22
  icon: Icon,
14
23
  action,
24
+ footer,
25
+ tone = 'default',
15
26
  children,
16
27
  pad = true,
17
28
  class: klass = '',
@@ -19,29 +30,94 @@
19
30
  ...restProps
20
31
  }: WithElementRef<HTMLAttributes<HTMLElement>> & {
21
32
  title?: string;
33
+ /**
34
+ * A one-line qualifier beside the title. It TRUNCATES, and every existing
35
+ * call site is written against that: a panel header is one rhythm, and a
36
+ * subtitle that wrapped would change it everywhere at once.
37
+ *
38
+ * For the sentence or two that explains what the section is for, use
39
+ * `description`, which wraps. Carry one or the other, not both.
40
+ */
22
41
  subtitle?: string;
42
+ /**
43
+ * The sentence or two under the title, wrapped, explaining what this
44
+ * section does or what changing it costs.
45
+ *
46
+ * PageHeader deliberately has no equivalent — a page's long explanation
47
+ * goes in its `info` tip so no route carries a standing explainer banner —
48
+ * and that position does not carry down to here. A settings section's
49
+ * description IS the setting's meaning ("who can approve a bill, and up to
50
+ * what value"), and hiding it behind an (i) on the one destination whose
51
+ * whole job is explaining consequential toggles is worse than the banner
52
+ * that rule was written against. GitHub, Linear, Vercel and Stripe all
53
+ * wrap a description under the section title.
54
+ */
55
+ description?: string;
23
56
  icon?: Component<{ class?: string }>;
24
57
  action?: Snippet;
58
+ /**
59
+ * The section's actions — a Save/Cancel pair — on a top-bordered muted
60
+ * strip at the foot, right-aligned. Same treatment as CardFooter's, so a
61
+ * panel's foot and a card's foot are the same foot.
62
+ */
63
+ footer?: Snippet;
64
+ /**
65
+ * `destructive` is the danger zone: a section whose controls delete or
66
+ * revoke something. It moves the rule and the title to the destructive
67
+ * ink — the treatment Alert and Button already use for it — and nothing
68
+ * else, so the section is marked without being shouted.
69
+ */
70
+ tone?: 'default' | 'destructive';
25
71
  children: Snippet;
26
72
  pad?: boolean;
27
73
  } = $props();
74
+
75
+ const destructive = $derived(tone === 'destructive');
28
76
  </script>
29
77
 
30
78
  <section
31
79
  bind:this={ref}
32
- class="bg-card border-border ds-edge flex flex-col overflow-hidden rounded-lg border {klass}"
80
+ data-tone={tone === 'default' ? undefined : tone}
81
+ class={cn(
82
+ 'bg-card ds-edge flex flex-col overflow-hidden rounded-lg border',
83
+ destructive ? 'border-destructive/40' : 'border-border',
84
+ klass
85
+ )}
33
86
  {...restProps}
34
87
  >
35
88
  {#if title}
36
- <header class="border-border flex items-center gap-2.5 border-b px-4 py-3">
89
+ <!--
90
+ `items-start` once a description is present: a wrapping description makes
91
+ the title block taller than the action row, and centring the actions
92
+ against three lines of prose floats them in the middle of the header.
93
+ -->
94
+ <header
95
+ class={cn(
96
+ 'flex gap-2.5 border-b px-4 py-3',
97
+ description ? 'items-start' : 'items-center',
98
+ destructive ? 'border-destructive/25' : 'border-border'
99
+ )}
100
+ >
37
101
  {#if Icon}
38
- <Icon class="text-muted-foreground size-4" />
102
+ <Icon
103
+ class={cn('size-4', destructive ? 'text-destructive' : 'text-muted-foreground')}
104
+ />
39
105
  {/if}
40
106
  <div class="min-w-0">
41
- <h2 class="text-body leading-tight font-semibold tracking-tight">{title}</h2>
107
+ <h2
108
+ class={cn(
109
+ 'text-body leading-tight font-semibold tracking-tight',
110
+ destructive && 'text-destructive'
111
+ )}
112
+ >
113
+ {title}
114
+ </h2>
42
115
  {#if subtitle}
43
116
  <p class="text-muted-foreground truncate text-xs">{subtitle}</p>
44
117
  {/if}
118
+ {#if description}
119
+ <p class="text-muted-foreground mt-1 text-sm">{description}</p>
120
+ {/if}
45
121
  </div>
46
122
  {#if action}
47
123
  <div class="ml-auto flex items-center gap-1.5">{@render action()}</div>
@@ -51,4 +127,20 @@
51
127
  <div class={pad ? 'p-4' : ''}>
52
128
  {@render children()}
53
129
  </div>
130
+ {#if footer}
131
+ <!--
132
+ `mt-auto` so a footer sits at the bottom of a panel that has been given a
133
+ height (a fixed-height column, a grid row) rather than floating under
134
+ short content.
135
+ -->
136
+ <div
137
+ class={cn(
138
+ 'bg-muted/50 mt-auto flex items-center justify-end gap-2 border-t px-4 py-3',
139
+ destructive ? 'border-destructive/25' : 'border-border'
140
+ )}
141
+ data-slot="panel-footer"
142
+ >
143
+ {@render footer()}
144
+ </div>
145
+ {/if}
54
146
  </section>
@@ -1,13 +1,48 @@
1
1
  import type { Snippet, Component } from 'svelte';
2
2
  import type { HTMLAttributes } from 'svelte/elements';
3
- import type { WithElementRef } from '../../../utils.js';
3
+ import { type WithElementRef } from '../../../utils.js';
4
4
  type $$ComponentProps = WithElementRef<HTMLAttributes<HTMLElement>> & {
5
5
  title?: string;
6
+ /**
7
+ * A one-line qualifier beside the title. It TRUNCATES, and every existing
8
+ * call site is written against that: a panel header is one rhythm, and a
9
+ * subtitle that wrapped would change it everywhere at once.
10
+ *
11
+ * For the sentence or two that explains what the section is for, use
12
+ * `description`, which wraps. Carry one or the other, not both.
13
+ */
6
14
  subtitle?: string;
15
+ /**
16
+ * The sentence or two under the title, wrapped, explaining what this
17
+ * section does or what changing it costs.
18
+ *
19
+ * PageHeader deliberately has no equivalent — a page's long explanation
20
+ * goes in its `info` tip so no route carries a standing explainer banner —
21
+ * and that position does not carry down to here. A settings section's
22
+ * description IS the setting's meaning ("who can approve a bill, and up to
23
+ * what value"), and hiding it behind an (i) on the one destination whose
24
+ * whole job is explaining consequential toggles is worse than the banner
25
+ * that rule was written against. GitHub, Linear, Vercel and Stripe all
26
+ * wrap a description under the section title.
27
+ */
28
+ description?: string;
7
29
  icon?: Component<{
8
30
  class?: string;
9
31
  }>;
10
32
  action?: Snippet;
33
+ /**
34
+ * The section's actions — a Save/Cancel pair — on a top-bordered muted
35
+ * strip at the foot, right-aligned. Same treatment as CardFooter's, so a
36
+ * panel's foot and a card's foot are the same foot.
37
+ */
38
+ footer?: Snippet;
39
+ /**
40
+ * `destructive` is the danger zone: a section whose controls delete or
41
+ * revoke something. It moves the rule and the title to the destructive
42
+ * ink — the treatment Alert and Button already use for it — and nothing
43
+ * else, so the section is marked without being shouted.
44
+ */
45
+ tone?: 'default' | 'destructive';
11
46
  children: Snippet;
12
47
  pad?: boolean;
13
48
  };
@@ -0,0 +1,2 @@
1
+ export { default as SettingsShell } from './settings-shell.svelte';
2
+ export { default } from './settings-shell.svelte';
@@ -0,0 +1,2 @@
1
+ export { default as SettingsShell } from './settings-shell.svelte';
2
+ export { default } from './settings-shell.svelte';
@@ -0,0 +1,111 @@
1
+ <script lang="ts">
2
+ /**
3
+ * The settings destination: a grouped section list beside the section you are
4
+ * reading, inside AppShell's content area.
5
+ *
6
+ * Two PANES, not a panel and not a drawer. The distinction is load-bearing:
7
+ * a panel overlays and closes, and this package carries a standing directive
8
+ * against side panels for work you are actually doing. These panes divide and
9
+ * persist, each scrolling on its own, so a long settings page never pushes the
10
+ * section list out of reach.
11
+ *
12
+ * The list renders through AppNav rather than a row renderer of its own, and
13
+ * that is the whole reason this component is small. AppNav already owns the
14
+ * group eyebrow, the active row's leading edge and tint and weight, the
15
+ * `aria-current="page"`, the segment-safe prefix match, truncation, and the
16
+ * density ramp — and it is exported in its own right precisely so a navigation
17
+ * list that belongs in a PAGE is built from the same affordance rather than
18
+ * hand-copied. The estate has already paid for the alternative once: one app's
19
+ * inner nav had reimplemented the active indicator independently of its own
20
+ * top bar. A second row renderer here would re-commit that drift one
21
+ * destination down, on the one surface every app is about to adopt at once.
22
+ *
23
+ * Grouping is by WHOSE setting it is — the person's, then the workspace's —
24
+ * because that is the only split a reader can predict: "yours" changes what
25
+ * you see, "this company" changes what everyone sees. A group whose `items`
26
+ * is empty renders nothing (`toGroups` drops it), so an app with no personal
27
+ * sections yet can leave the group in its list rather than stubbing a row that
28
+ * leads nowhere.
29
+ *
30
+ * One label per row, nothing beneath it. A description long enough to be worth
31
+ * reading does not fit a 240px column: every one of them truncated to an
32
+ * ellipsis and told the reader less than the label already did (measured
33
+ * 16/09/2026 in a consumer, removed there the same day). The page introduces
34
+ * its own sections — that is what Panel's `description` is for.
35
+ *
36
+ * No "Settings" heading. The shell's bar names the section it is on as of
37
+ * 2026.9.16, so a heading here is the same word twice, one above the other.
38
+ *
39
+ * BELOW md the list STACKS above the content and the page scrolls as one.
40
+ * A horizontally scrollable strip of the same rows was the obvious
41
+ * alternative and loses on the requirement it was meant to serve — every
42
+ * section reachable in one tap. A strip fits about two and a half rows at
43
+ * 375px, so the rest are behind a sideways swipe with no scrollbar to
44
+ * advertise them: a swipe THEN a tap, where a stack is a scroll the reader is
45
+ * already doing. It also has to drop the group labels for width, which throws
46
+ * away the one split the reader could predict, and it needs a second row
47
+ * renderer in a horizontal mode — the drift this component exists to avoid.
48
+ *
49
+ * USE IT WITH `padded={false}` on AppShell. The list's border and tint have to
50
+ * reach the content area's edges or the two panes read as a floating card, and
51
+ * this component pads its own content pane to the shell's own rhythm
52
+ * (`CONTENT_PADDING`) so the settings destination is not the one route whose
53
+ * text sits somewhere else.
54
+ */
55
+ import type { Snippet } from 'svelte';
56
+ import type { HTMLAttributes } from 'svelte/elements';
57
+ import AppNav from '../app-shell/app-nav.svelte';
58
+ import { CONTENT_PADDING } from '../app-shell/padding.js';
59
+ import type { NavSource } from '../app-shell/types.js';
60
+ import { cn, type WithElementRef } from '../../../utils.js';
61
+
62
+ let {
63
+ sections,
64
+ currentPath,
65
+ listLabel = 'Settings sections',
66
+ children,
67
+ ref = $bindable(null),
68
+ class: className,
69
+ ...restProps
70
+ }: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
71
+ /**
72
+ * The sections, as the package's own navigation vocabulary: groups of
73
+ * `NavItem` (`{ heading, items }`), bare items, or a mix. The same type the
74
+ * rail takes, so a row is the same row everywhere in the household and an
75
+ * app types its settings list against something it already has.
76
+ *
77
+ * A row at the settings ROOT (`/settings`) needs `exact: true`, or its
78
+ * prefix match keeps it lit on every section beneath it alongside the
79
+ * section's own row.
80
+ */
81
+ sections: NavSource;
82
+ /** The active path. Apps pass `page.url.pathname`, as they do to AppShell. */
83
+ currentPath?: string;
84
+ /** The list's accessible name. */
85
+ listLabel?: string;
86
+ children: Snippet;
87
+ } = $props();
88
+ </script>
89
+
90
+ <div bind:this={ref} class={cn('ds-settings', className)} {...restProps}>
91
+ <!--
92
+ Width, tint and rule as utilities rather than in styles.css, because AppNav
93
+ declares `flex-1` on this same element: a base-layer `flex: none` loses to
94
+ a utility by layer, whatever order it is written in, while a utility passed
95
+ through `cn()` is resolved by tailwind-merge — `flex-none` and `flex-1` are
96
+ one group, so the last one wins and it is this one.
97
+
98
+ 15rem (240px) is measured, not picked: it is the width at which a
99
+ two-word section label ("Overhead suppliers", "Delegation limits") sits on
100
+ one line beside its icon at comfortable density.
101
+ -->
102
+ <AppNav
103
+ nav={sections}
104
+ {currentPath}
105
+ label={listLabel}
106
+ class="border-border bg-muted/30 flex-none border-b md:w-60 md:border-b-0 md:border-e"
107
+ />
108
+ <div class={cn('ds-settings-pane', CONTENT_PADDING)}>
109
+ {@render children()}
110
+ </div>
111
+ </div>
@@ -0,0 +1,78 @@
1
+ /**
2
+ * The settings destination: a grouped section list beside the section you are
3
+ * reading, inside AppShell's content area.
4
+ *
5
+ * Two PANES, not a panel and not a drawer. The distinction is load-bearing:
6
+ * a panel overlays and closes, and this package carries a standing directive
7
+ * against side panels for work you are actually doing. These panes divide and
8
+ * persist, each scrolling on its own, so a long settings page never pushes the
9
+ * section list out of reach.
10
+ *
11
+ * The list renders through AppNav rather than a row renderer of its own, and
12
+ * that is the whole reason this component is small. AppNav already owns the
13
+ * group eyebrow, the active row's leading edge and tint and weight, the
14
+ * `aria-current="page"`, the segment-safe prefix match, truncation, and the
15
+ * density ramp — and it is exported in its own right precisely so a navigation
16
+ * list that belongs in a PAGE is built from the same affordance rather than
17
+ * hand-copied. The estate has already paid for the alternative once: one app's
18
+ * inner nav had reimplemented the active indicator independently of its own
19
+ * top bar. A second row renderer here would re-commit that drift one
20
+ * destination down, on the one surface every app is about to adopt at once.
21
+ *
22
+ * Grouping is by WHOSE setting it is — the person's, then the workspace's —
23
+ * because that is the only split a reader can predict: "yours" changes what
24
+ * you see, "this company" changes what everyone sees. A group whose `items`
25
+ * is empty renders nothing (`toGroups` drops it), so an app with no personal
26
+ * sections yet can leave the group in its list rather than stubbing a row that
27
+ * leads nowhere.
28
+ *
29
+ * One label per row, nothing beneath it. A description long enough to be worth
30
+ * reading does not fit a 240px column: every one of them truncated to an
31
+ * ellipsis and told the reader less than the label already did (measured
32
+ * 16/09/2026 in a consumer, removed there the same day). The page introduces
33
+ * its own sections — that is what Panel's `description` is for.
34
+ *
35
+ * No "Settings" heading. The shell's bar names the section it is on as of
36
+ * 2026.9.16, so a heading here is the same word twice, one above the other.
37
+ *
38
+ * BELOW md the list STACKS above the content and the page scrolls as one.
39
+ * A horizontally scrollable strip of the same rows was the obvious
40
+ * alternative and loses on the requirement it was meant to serve — every
41
+ * section reachable in one tap. A strip fits about two and a half rows at
42
+ * 375px, so the rest are behind a sideways swipe with no scrollbar to
43
+ * advertise them: a swipe THEN a tap, where a stack is a scroll the reader is
44
+ * already doing. It also has to drop the group labels for width, which throws
45
+ * away the one split the reader could predict, and it needs a second row
46
+ * renderer in a horizontal mode — the drift this component exists to avoid.
47
+ *
48
+ * USE IT WITH `padded={false}` on AppShell. The list's border and tint have to
49
+ * reach the content area's edges or the two panes read as a floating card, and
50
+ * this component pads its own content pane to the shell's own rhythm
51
+ * (`CONTENT_PADDING`) so the settings destination is not the one route whose
52
+ * text sits somewhere else.
53
+ */
54
+ import type { Snippet } from 'svelte';
55
+ import type { HTMLAttributes } from 'svelte/elements';
56
+ import type { NavSource } from '../app-shell/types.js';
57
+ import { type WithElementRef } from '../../../utils.js';
58
+ type $$ComponentProps = WithElementRef<HTMLAttributes<HTMLDivElement>> & {
59
+ /**
60
+ * The sections, as the package's own navigation vocabulary: groups of
61
+ * `NavItem` (`{ heading, items }`), bare items, or a mix. The same type the
62
+ * rail takes, so a row is the same row everywhere in the household and an
63
+ * app types its settings list against something it already has.
64
+ *
65
+ * A row at the settings ROOT (`/settings`) needs `exact: true`, or its
66
+ * prefix match keeps it lit on every section beneath it alongside the
67
+ * section's own row.
68
+ */
69
+ sections: NavSource;
70
+ /** The active path. Apps pass `page.url.pathname`, as they do to AppShell. */
71
+ currentPath?: string;
72
+ /** The list's accessible name. */
73
+ listLabel?: string;
74
+ children: Snippet;
75
+ };
76
+ declare const SettingsShell: import("svelte").Component<$$ComponentProps, {}, "ref">;
77
+ type SettingsShell = ReturnType<typeof SettingsShell>;
78
+ export default SettingsShell;
package/dist/styles.css CHANGED
@@ -770,27 +770,24 @@
770
770
  color: var(--ds-nav-ink);
771
771
  }
772
772
 
773
- /* The disclosed rows. The indent is measured against the parent's own
774
- geometry rather than picked: .ds-nav-item pads 0.625rem and the icon is
775
- 1.125rem, so the icon's centre line sits at 1.1875rem — where the guide
776
- border goes — and 0.625rem of padding here plus the child's own 0.625rem
777
- lands its label at 2.5rem, exactly under the parent's. The border is a real
778
- affordance at fifteen rows, not decoration: it is what makes the run
779
- scannable as one section. */
780
773
  /* The disclosed rows carry no guide rule. Indentation and the children's own
781
774
  icons already read as one group, and a line down the side is a second
782
- signal for a grouping that was never ambiguous. The indent is still
783
- measured against the parent's geometry rather than picked: `.ds-nav-item`
784
- pads 0.625rem and the icon is 1.125rem, so 1.1875rem plus 0.625rem here
785
- plus the child's own 0.625rem lands its label at 2.5rem, exactly under the
786
- parent's. */
775
+ signal for a grouping that was never ambiguous. 8f39b02 removed the rule
776
+ but not the 1px it was contributing to the indent (design-system#38): the
777
+ border sat between this margin and this padding, so the child label
778
+ landed 1px further right than the padding alone now gives it. Restored
779
+ here instead, so the geometry — not a rule nobody can see — still carries
780
+ it: `.ds-nav-item` pads 0.625rem and the icon is 1.125rem, so 1.1875rem
781
+ (margin) + 0.625rem plus the former border's 1px (this padding) +
782
+ 0.625rem (the child's own padding) lands its label at 2.5rem, exactly
783
+ under the parent's. */
787
784
  .ds-nav-children {
788
785
  display: flex;
789
786
  flex-direction: column;
790
787
  gap: 0.125rem;
791
788
  margin-top: 0.125rem;
792
789
  margin-left: 1.1875rem;
793
- padding-left: 0.625rem;
790
+ padding-left: calc(0.625rem + 1px);
794
791
  }
795
792
 
796
793
  /* The rail's foot: where you configure the app, and which build you are on.
@@ -881,6 +878,64 @@
881
878
  padding-inline: 0;
882
879
  }
883
880
 
881
+ /* The settings destination's two panes (SettingsShell).
882
+
883
+ Only the SHAPE is here; the list's width, tint and rule are utilities on the
884
+ AppNav element itself, because AppNav declares `flex-1` there and a
885
+ base-layer `flex: none` would lose to it by layer order rather than by
886
+ position — the cascade trap recorded against `.ds-nav`'s ink. What is left is
887
+ the axis swap and the scroll bounding, which nothing else declares.
888
+
889
+ 48rem is Tailwind's own --breakpoint-md, written literally for the reason
890
+ `.ds-shell-rail` writes it literally: a media query cannot read a custom
891
+ property, so an app that retunes md retunes this with it. */
892
+ .ds-settings {
893
+ display: flex;
894
+ flex-direction: column;
895
+ flex: 1 1 auto;
896
+ }
897
+
898
+ /* `min-height: 0` is what buys the two independent scrollers, and it is
899
+ deliberately NOT set below md. A flex item is floored at its content height
900
+ unless it is allowed to shrink past it, so without this neither pane ever
901
+ scrolls and the whole content area does instead. Below md that floor is
902
+ exactly what is wanted: the list stacks above the content and the page
903
+ scrolls as one, which is the only sane arrangement on a phone — two nested
904
+ scrollers at 375px is the sideways-scroll hazard this package already carries
905
+ a gate for. */
906
+ @media (min-width: 48rem) {
907
+ .ds-settings {
908
+ flex-direction: row;
909
+ min-height: 0;
910
+ }
911
+ }
912
+
913
+ .ds-settings-pane {
914
+ flex: 1 1 auto;
915
+ min-width: 0;
916
+ }
917
+
918
+ /* The pane stacks its sections itself, so a settings page writes a run of
919
+ Panels and no wrapper — the framing markup this surface exists to stop a
920
+ consumer writing. 1.25rem is the package's existing section rhythm, the same
921
+ gap `.ds-dialog-section` puts between adjacent sections.
922
+
923
+ A margin rule rather than `display: flex; gap`, because at md and up this box
924
+ is a height-bounded scroller: as a flex column its children carry
925
+ `flex-shrink: 1` and get squashed toward min-content before the scrollbar is
926
+ ever reached, so a tall section would compress instead of scrolling. Block
927
+ flow overflows, which is what a scroller wants. */
928
+ .ds-settings-pane > * + * {
929
+ margin-top: 1.25rem;
930
+ }
931
+
932
+ @media (min-width: 48rem) {
933
+ .ds-settings-pane {
934
+ min-height: 0;
935
+ overflow-y: auto;
936
+ }
937
+ }
938
+
884
939
  /* A hard ceiling on the whole shell, in the one unit that names the actual
885
940
  drawn surface rather than a percentage of an ancestor. `.ds-shell` is
886
941
  already `flex` with `overflow-hidden` in the component markup, which
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@poodle64/ui",
3
- "version": "2026.9.16",
3
+ "version": "2026.9.17",
4
4
  "description": "Household shared component layer: shadcn-svelte primitives (bits-ui) plus the composed page chrome every app builds its routes from, restyled by each app's @poodle64/design-tokens alias layer. One fix reaches every app.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "meta": {
3
3
  "package": "@poodle64/ui",
4
- "version": "2026.9.16",
4
+ "version": "2026.9.17",
5
5
  "generatedBy": "scripts/generate-registry.mjs",
6
6
  "source": "scripts/situations.json + package source (DO NOT EDIT the outputs by hand)",
7
- "componentCount": 57,
8
- "situationCount": 15
7
+ "componentCount": 58,
8
+ "situationCount": 16
9
9
  },
10
10
  "situations": [
11
11
  {
@@ -150,8 +150,8 @@
150
150
  "name": "Panel",
151
151
  "dir": "panel",
152
152
  "import": "@poodle64/ui/panel",
153
- "props": "children, title?, subtitle?, icon?, action?, pad?",
154
- "insteadOf": "raw Card + CardHeader + CardTitle assembled by hand on every route"
153
+ "props": "children, title?, subtitle?, description?, icon?, action?, footer?, tone?, pad?",
154
+ "insteadOf": "raw Card + CardHeader + CardTitle assembled by hand on every route; also the settings section — there is no separate SettingsSection, `description` wraps, `footer` carries Save/Cancel, `tone=\"destructive\"` is the danger zone"
155
155
  }
156
156
  ]
157
157
  },
@@ -323,6 +323,20 @@
323
323
  }
324
324
  ]
325
325
  },
326
+ {
327
+ "key": "settings-destination",
328
+ "title": "The settings destination",
329
+ "description": "The one settings surface: a grouped section list beside the section you are reading, inside AppShell’s content area (give AppShell padded={false}). Sections are grouped by whose setting it is — the person’s, then the workspace’s. The sections themselves are Panels.",
330
+ "components": [
331
+ {
332
+ "name": "SettingsShell",
333
+ "dir": "settings-shell",
334
+ "import": "@poodle64/ui/settings-shell",
335
+ "props": "sections, children, currentPath?, listLabel?",
336
+ "insteadOf": "a hand-built two-pane settings layout, or a card of tab links in a grid, per app"
337
+ }
338
+ ]
339
+ },
326
340
  {
327
341
  "key": "dialog-and-overlay",
328
342
  "title": "A dialog or overlay",
@@ -1,7 +1,7 @@
1
1
  # @poodle64/ui — situation → component map
2
2
 
3
3
  <!-- GENERATED by scripts/generate-registry.mjs from scripts/situations.json + package source. DO NOT EDIT. -->
4
- Generated from `@poodle64/ui@2026.9.16`. 57 components, 15 situations.
4
+ Generated from `@poodle64/ui@2026.9.17`. 58 components, 16 situations.
5
5
 
6
6
  **Read this before writing a `<div>`.** Find the SITUATION you are in below, then compose the component named for it — do not hand-build it from raw `Card` or utility classes. Import is `import { Name } from '<import path>'`. Props marked `?` are optional. This map is the retrieval step the [`frontend-design` skill] makes mandatory; the [CHI 2026 study] measured composing-from-a-registry at 95% design-system compliance against 71% for writing the CSS from a prose style guide.
7
7
 
@@ -81,7 +81,7 @@ _The generic titled card every route reaches for — an optional header with ico
81
81
 
82
82
  | Component | Import | Key props | Reach for it instead of |
83
83
  | --- | --- | --- | --- |
84
- | `Panel` | `@poodle64/ui/panel` | `children, title?, subtitle?, icon?, action?, pad?` | raw Card + CardHeader + CardTitle assembled by hand on every route |
84
+ | `Panel` | `@poodle64/ui/panel` | `children, title?, subtitle?, description?, icon?, action?, footer?, tone?, pad?` | raw Card + CardHeader + CardTitle assembled by hand on every route; also the settings section — there is no separate SettingsSection, `description` wraps, `footer` carries Save/Cancel, `tone="destructive"` is the danger zone |
85
85
 
86
86
  ## A form the app itself describes
87
87
 
@@ -131,6 +131,14 @@ _The standing chrome — the app shell and its nav, the one page-title treatment
131
131
  | `ContextColumn` | `@poodle64/ui/context-column` | `stats, statsTitle?, statsInfo?, detail?, ariaLabel?` | a hand-built right-hand <aside> with a stat card and a detail pane |
132
132
  | `AppIdentity` | `@poodle64/ui/app-identity` | `user, workspace?, role?, entitlements?, onSignOut, onAccountSettings?, onSwitchTheme?` | a hand-rolled account/avatar dropdown wired to raw DropdownMenu.* + Avatar.* |
133
133
 
134
+ ## The settings destination
135
+
136
+ _The one settings surface: a grouped section list beside the section you are reading, inside AppShell’s content area (give AppShell padded={false}). Sections are grouped by whose setting it is — the person’s, then the workspace’s. The sections themselves are Panels._
137
+
138
+ | Component | Import | Key props | Reach for it instead of |
139
+ | --- | --- | --- | --- |
140
+ | `SettingsShell` | `@poodle64/ui/settings-shell` | `sections, children, currentPath?, listLabel?` | a hand-built two-pane settings layout, or a card of tab links in a grid, per app |
141
+
134
142
  ## A dialog or overlay
135
143
 
136
144
  _A modal task and the pieces around it — the dialog frame, a section within its body, a ⌘K palette, an inline (i) hint._