@poodle64/ui 2026.9.1 → 2026.9.3
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 +22 -22
- package/bin/check-colour-surface.mjs +1 -1
- package/dist/components/ui/app-identity/app-identity.svelte +138 -0
- package/dist/components/ui/app-identity/app-identity.svelte.d.ts +21 -0
- package/dist/components/ui/app-identity/index.d.ts +2 -0
- package/dist/components/ui/app-identity/index.js +2 -0
- package/dist/components/ui/app-shell/app-shell.svelte +22 -7
- package/dist/components/ui/context-column/context-column.svelte +36 -8
- package/dist/styles.css +9 -0
- package/package.json +102 -101
- package/registry/component-map.json +9 -2
- package/registry/component-map.md +2 -1
package/README.md
CHANGED
|
@@ -83,10 +83,10 @@ rather than rebuilding it:
|
|
|
83
83
|
|
|
84
84
|
| Import | What it is |
|
|
85
85
|
| ----------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
86
|
-
| `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.
|
|
86
|
+
| `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. |
|
|
87
87
|
| `panel` | The generic titled card: optional icon, subtitle and trailing actions over a body that can opt out of padding. |
|
|
88
88
|
| `detail-panel` | The entity-detail surface: header with icon/eyebrow/title/`StatusBadge`/close, scrollable body, footer of actions. |
|
|
89
|
-
| `context-column` | The persistent right-hand column: a standing `StatList` plus an optional detail that flows in on select.
|
|
89
|
+
| `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. |
|
|
90
90
|
| `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. |
|
|
91
91
|
| `dialog-section` | One section of a dialogue body; adjacent sections are divided automatically. |
|
|
92
92
|
| `stat-card` | A single metric that earns its space (label, value, unit, sub, status dot, and `valueTone` to colour the figure itself). |
|
|
@@ -380,10 +380,10 @@ top-right — painted once, by the shell, on the region that scrolls:
|
|
|
380
380
|
</AppShell>
|
|
381
381
|
```
|
|
382
382
|
|
|
383
|
-
| Value | What it paints | Reach for it when
|
|
384
|
-
| ------ | ------------------------------------------------------- |
|
|
385
|
-
| `grid` | a dot-grid floor plus one accent vignette in the corner | the default — the house "instrument-grade" surface
|
|
386
|
-
| `none` | nothing at all | an app arguing a deliberate exception
|
|
383
|
+
| Value | What it paints | Reach for it when |
|
|
384
|
+
| ------ | ------------------------------------------------------- | -------------------------------------------------- |
|
|
385
|
+
| `grid` | a dot-grid floor plus one accent vignette in the corner | the default — the house "instrument-grade" surface |
|
|
386
|
+
| `none` | nothing at all | an app arguing a deliberate exception |
|
|
387
387
|
|
|
388
388
|
`grid` is the default since `2026.8.8`, so a shell that does not mention
|
|
389
389
|
`texture` wears the house atmosphere. It shipped `none`-by-default in `2026.8.4`
|
|
@@ -1038,20 +1038,20 @@ Money, dates and times, percentages and plain numbers, in `en-AU` / AUD /
|
|
|
1038
1038
|
`Australia/Brisbane`. No dependencies and no DOM — it is `Intl` and arithmetic,
|
|
1039
1039
|
so it runs in a load function as happily as in a component.
|
|
1040
1040
|
|
|
1041
|
-
| Function
|
|
1042
|
-
|
|
|
1043
|
-
| `formatCurrency(dollars, opts)`
|
|
1044
|
-
| `formatCurrencyFromCents(cents, opts)`
|
|
1045
|
-
| `formatCurrencyString(str, opts)`
|
|
1046
|
-
| `compactCurrency(value, opts)`
|
|
1047
|
-
| `isNegativeMoney(value)`
|
|
1048
|
-
| `dollarsToCents(dollars)`
|
|
1049
|
-
| `formatNumber(value, opts)`
|
|
1050
|
-
| `formatPercentage(value, opts)`
|
|
1051
|
-
| `formatRatioAsPercentage(value, opts)`
|
|
1052
|
-
| `formatDate(value, opts)`
|
|
1053
|
-
| `formatDateTime(value, opts)`
|
|
1054
|
-
| `parseApiDate(iso)`
|
|
1041
|
+
| Function | What it does |
|
|
1042
|
+
| -------------------------------------- | -------------------------------------------------------------------------------------------------- |
|
|
1043
|
+
| `formatCurrency(dollars, opts)` | `$1,234.56`. `decimals` (default 2), `currency` (default AUD), `fallback`. |
|
|
1044
|
+
| `formatCurrencyFromCents(cents, opts)` | The same, for money stored as integer cents. |
|
|
1045
|
+
| `formatCurrencyString(str, opts)` | Groups a money STRING without ever parsing it to a float — for a figure that reaches a tax return. |
|
|
1046
|
+
| `compactCurrency(value, opts)` | `$1.1m` / `$12k`, for a chart axis or a dense tile. |
|
|
1047
|
+
| `isNegativeMoney(value)` | The sign, without a parse, for choosing a tone class. |
|
|
1048
|
+
| `dollarsToCents(dollars)` | Whole cents. |
|
|
1049
|
+
| `formatNumber(value, opts)` | `1,234,567`. |
|
|
1050
|
+
| `formatPercentage(value, opts)` | Value is already in percentage POINTS: `4.5` → `4.5%`. |
|
|
1051
|
+
| `formatRatioAsPercentage(value, opts)` | Value is a 0–1 RATIO: `0.045` → `4.5%`. |
|
|
1052
|
+
| `formatDate(value, opts)` | `19 Dec 2024`, `19 December 2024` (`format: 'long'`) or `19/12/2024` (`format: 'numeric'`). |
|
|
1053
|
+
| `formatDateTime(value, opts)` | The same plus a 24-hour time: `19 Dec 2024, 14:05`. |
|
|
1054
|
+
| `parseApiDate(iso)` | Reads an offset-less timestamp as UTC — the naive-UTC backend trap below. |
|
|
1055
1055
|
|
|
1056
1056
|
Four decisions worth knowing before a migration, because each was settled
|
|
1057
1057
|
against an app that had decided it the other way:
|
|
@@ -1109,8 +1109,8 @@ still pass while `bg-card`, `bg-muted`, `bg-accent`, `bg-popover` and
|
|
|
1109
1109
|
`border-input` compile to no rule at all:
|
|
1110
1110
|
|
|
1111
1111
|
```css
|
|
1112
|
-
@import '@poodle64/ui/styles.css';
|
|
1113
|
-
@source '../node_modules/@poodle64/ui/dist';
|
|
1112
|
+
@import '@poodle64/ui/styles.css'; /* registers the surface */
|
|
1113
|
+
@source '../node_modules/@poodle64/ui/dist'; /* puts it in the scan */
|
|
1114
1114
|
```
|
|
1115
1115
|
|
|
1116
1116
|
They fail differently, so the gate asserts them differently: it compiles the
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* `lint:css` and `check` all succeed, and the app ships with `bg-card`,
|
|
13
13
|
* `bg-muted`, `bg-accent`, `bg-popover` and `border-input` compiling to no rule
|
|
14
14
|
* at all — no build error, no lint hit, no failing test, just classes in the DOM
|
|
15
|
-
* with nothing behind them. That is
|
|
15
|
+
* with nothing behind them. That is radar-hooves/design-system#3, which reached every
|
|
16
16
|
* app in the estate before anyone saw it: dropdowns with no hover, inputs with no
|
|
17
17
|
* border, cards and popovers with no surface.
|
|
18
18
|
*
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* The signed-in user surface for AppShell's `identity` slot.
|
|
4
|
+
*
|
|
5
|
+
* Graduated from the shell lab (`packages/console/src/routes/lab/+page.svelte`,
|
|
6
|
+
* the `identity === 'avatar'` shape) — operator ruling 04/09/2026: avatar
|
|
7
|
+
* only at rest, no name, no chevron, and no prop reintroduces a variant.
|
|
8
|
+
* Opening it answers who is signed in, which workspace they act in, and the
|
|
9
|
+
* ways out — identity first, because a menu that opens on a list of actions
|
|
10
|
+
* makes you infer who you are from the avatar you just clicked.
|
|
11
|
+
*
|
|
12
|
+
* Prop names match the stamped template's auth store
|
|
13
|
+
* (`frontend/src/lib/auth.svelte.ts` — `User`, `Membership`) exactly, so a
|
|
14
|
+
* consumer wires this straight from `auth.user`, `auth.entitlements`,
|
|
15
|
+
* `auth.activeWorkspace` and `auth.activeRole` with no reshaping. A guessed
|
|
16
|
+
* shape needs rewriting the day it meets the real store, which is the whole
|
|
17
|
+
* reason this one didn't. `display_name` and `email` are both nullable
|
|
18
|
+
* there, and `activeWorkspace` is genuinely null until a caller with several
|
|
19
|
+
* memberships chooses one — this component is built against that, not a
|
|
20
|
+
* happy path.
|
|
21
|
+
*
|
|
22
|
+
* Sign-out and settings are the consuming app's concerns, never behaviour
|
|
23
|
+
* baked in here: `onSignOut` and `onAccountSettings` are callbacks. Theme
|
|
24
|
+
* follows AppShell's own `onToggleTheme` pattern — override it, or accept
|
|
25
|
+
* mode-watcher's `toggleMode` by default.
|
|
26
|
+
*/
|
|
27
|
+
import { toggleMode } from 'mode-watcher';
|
|
28
|
+
import * as DropdownMenu from '../dropdown-menu/index.js';
|
|
29
|
+
import * as Avatar from '../avatar/index.js';
|
|
30
|
+
|
|
31
|
+
let {
|
|
32
|
+
user,
|
|
33
|
+
workspace = null,
|
|
34
|
+
role = null,
|
|
35
|
+
entitlements = [],
|
|
36
|
+
onSignOut,
|
|
37
|
+
onAccountSettings,
|
|
38
|
+
onSwitchTheme
|
|
39
|
+
}: {
|
|
40
|
+
user: { username: string; display_name: string | null; email: string | null };
|
|
41
|
+
/** The active workspace's name. Omit while none is chosen yet — the block hides rather than rendering empty. */
|
|
42
|
+
workspace?: string | null;
|
|
43
|
+
/** The caller's standing in `workspace`. Ignored while `workspace` is unset. */
|
|
44
|
+
role?: 'owner' | 'member' | null;
|
|
45
|
+
entitlements?: string[];
|
|
46
|
+
/** Required — the whole reason this slot exists is a stamped app rendering no way to sign out. */
|
|
47
|
+
onSignOut: () => void;
|
|
48
|
+
/** Optional until the app has an account-settings destination to send it to. */
|
|
49
|
+
onAccountSettings?: () => void;
|
|
50
|
+
/** Override the theme action. Defaults to mode-watcher's toggleMode, matching AppShell. */
|
|
51
|
+
onSwitchTheme?: () => void;
|
|
52
|
+
} = $props();
|
|
53
|
+
|
|
54
|
+
const shown = $derived(user.display_name ?? user.username);
|
|
55
|
+
// Shown separately only when it says something the display name doesn't —
|
|
56
|
+
// an audit log and a support question use the username, and it is not
|
|
57
|
+
// always the display name.
|
|
58
|
+
const showUsername = $derived(!!user.display_name && user.display_name !== user.username);
|
|
59
|
+
|
|
60
|
+
function initialsOf(name: string): string {
|
|
61
|
+
const parts = name.trim().split(/\s+/).filter(Boolean);
|
|
62
|
+
if (parts.length === 0) return '';
|
|
63
|
+
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
|
|
64
|
+
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
|
|
65
|
+
}
|
|
66
|
+
const initials = $derived(initialsOf(shown));
|
|
67
|
+
|
|
68
|
+
function switchTheme() {
|
|
69
|
+
if (onSwitchTheme) onSwitchTheme();
|
|
70
|
+
else toggleMode();
|
|
71
|
+
}
|
|
72
|
+
</script>
|
|
73
|
+
|
|
74
|
+
<DropdownMenu.Root>
|
|
75
|
+
<DropdownMenu.Trigger>
|
|
76
|
+
{#snippet child({ props })}
|
|
77
|
+
<button
|
|
78
|
+
{...props}
|
|
79
|
+
aria-label="Account"
|
|
80
|
+
class="hover:bg-surface-2 grid size-[34px] cursor-pointer place-items-center rounded-md"
|
|
81
|
+
>
|
|
82
|
+
<Avatar.Root class="size-6.5 flex-none">
|
|
83
|
+
<Avatar.Fallback class="bg-primary text-primary-foreground text-[11px] font-bold">
|
|
84
|
+
{initials}
|
|
85
|
+
</Avatar.Fallback>
|
|
86
|
+
</Avatar.Root>
|
|
87
|
+
</button>
|
|
88
|
+
{/snippet}
|
|
89
|
+
</DropdownMenu.Trigger>
|
|
90
|
+
<DropdownMenu.Content align="end" class="w-72">
|
|
91
|
+
<div class="flex items-start gap-3 px-2 py-1.5">
|
|
92
|
+
<Avatar.Root class="size-9 flex-none">
|
|
93
|
+
<Avatar.Fallback class="bg-primary text-primary-foreground text-[13px] font-bold">
|
|
94
|
+
{initials}
|
|
95
|
+
</Avatar.Fallback>
|
|
96
|
+
</Avatar.Root>
|
|
97
|
+
<div class="min-w-0">
|
|
98
|
+
<div class="truncate text-[13.5px] font-semibold">{shown}</div>
|
|
99
|
+
{#if showUsername}
|
|
100
|
+
<div class="text-muted-foreground truncate font-mono text-[11px]">{user.username}</div>
|
|
101
|
+
{/if}
|
|
102
|
+
{#if user.email}
|
|
103
|
+
<div class="text-muted-foreground truncate text-[12px]">{user.email}</div>
|
|
104
|
+
{/if}
|
|
105
|
+
</div>
|
|
106
|
+
</div>
|
|
107
|
+
{#if workspace}
|
|
108
|
+
<DropdownMenu.Separator />
|
|
109
|
+
<DropdownMenu.Label class="text-muted-foreground text-[10px] tracking-[0.1em] uppercase">
|
|
110
|
+
Workspace
|
|
111
|
+
</DropdownMenu.Label>
|
|
112
|
+
<div class="flex items-center justify-between gap-2 px-2 pb-1.5">
|
|
113
|
+
<span class="truncate text-[13px]">{workspace}</span>
|
|
114
|
+
{#if role}
|
|
115
|
+
<span
|
|
116
|
+
class="border-border text-muted-foreground rounded-full border px-1.5 py-px text-[10px] capitalize"
|
|
117
|
+
>
|
|
118
|
+
{role}
|
|
119
|
+
</span>
|
|
120
|
+
{/if}
|
|
121
|
+
</div>
|
|
122
|
+
{#if entitlements.length}
|
|
123
|
+
<div class="flex flex-wrap gap-1 px-2 pb-2">
|
|
124
|
+
{#each entitlements as e (e)}
|
|
125
|
+
<span class="bg-surface-2 text-muted-foreground font-mono rounded px-1.5 py-px text-[10px]">
|
|
126
|
+
{e}
|
|
127
|
+
</span>
|
|
128
|
+
{/each}
|
|
129
|
+
</div>
|
|
130
|
+
{/if}
|
|
131
|
+
{/if}
|
|
132
|
+
<DropdownMenu.Separator />
|
|
133
|
+
<DropdownMenu.Item onSelect={switchTheme}>Switch theme</DropdownMenu.Item>
|
|
134
|
+
<DropdownMenu.Item onSelect={onAccountSettings}>Account settings</DropdownMenu.Item>
|
|
135
|
+
<DropdownMenu.Separator />
|
|
136
|
+
<DropdownMenu.Item class="text-status-error" onSelect={onSignOut}>Sign out</DropdownMenu.Item>
|
|
137
|
+
</DropdownMenu.Content>
|
|
138
|
+
</DropdownMenu.Root>
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
type $$ComponentProps = {
|
|
2
|
+
user: {
|
|
3
|
+
username: string;
|
|
4
|
+
display_name: string | null;
|
|
5
|
+
email: string | null;
|
|
6
|
+
};
|
|
7
|
+
/** The active workspace's name. Omit while none is chosen yet — the block hides rather than rendering empty. */
|
|
8
|
+
workspace?: string | null;
|
|
9
|
+
/** The caller's standing in `workspace`. Ignored while `workspace` is unset. */
|
|
10
|
+
role?: 'owner' | 'member' | null;
|
|
11
|
+
entitlements?: string[];
|
|
12
|
+
/** Required — the whole reason this slot exists is a stamped app rendering no way to sign out. */
|
|
13
|
+
onSignOut: () => void;
|
|
14
|
+
/** Optional until the app has an account-settings destination to send it to. */
|
|
15
|
+
onAccountSettings?: () => void;
|
|
16
|
+
/** Override the theme action. Defaults to mode-watcher's toggleMode, matching AppShell. */
|
|
17
|
+
onSwitchTheme?: () => void;
|
|
18
|
+
};
|
|
19
|
+
declare const AppIdentity: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
20
|
+
type AppIdentity = ReturnType<typeof AppIdentity>;
|
|
21
|
+
export default AppIdentity;
|
|
@@ -420,16 +420,26 @@
|
|
|
420
420
|
trailing sizes to its clamp without stretching the right-hand group.
|
|
421
421
|
-->
|
|
422
422
|
{#snippet searchButton(sizing: string)}
|
|
423
|
+
<!--
|
|
424
|
+
Below `sm` this is an icon-only square, matching the menu/theme-toggle
|
|
425
|
+
buttons either side of it: a fixed-width pill sharing the row with a
|
|
426
|
+
`context` switcher or a couple of trailing `actions` badges has as little
|
|
427
|
+
as the width of one glyph to shrink into, and a button painting a single
|
|
428
|
+
truncated letter reads as broken rather than as a control. `sizing`
|
|
429
|
+
supplies the width/flex/padding pair at each tier (base then `sm:`), so
|
|
430
|
+
this snippet's own classes carry shape only and never fight it over the
|
|
431
|
+
same property.
|
|
432
|
+
-->
|
|
423
433
|
<button
|
|
424
434
|
onclick={onSearch}
|
|
425
435
|
class={cn(
|
|
426
|
-
'border-border text-shell-muted-foreground hover:text-shell-foreground flex items-center
|
|
436
|
+
'border-border text-shell-muted-foreground hover:text-shell-foreground flex items-center rounded-md border transition-colors sm:text-sm',
|
|
427
437
|
sizing
|
|
428
438
|
)}
|
|
429
439
|
data-testid="ds-shell-search"
|
|
430
440
|
>
|
|
431
441
|
<Search class="size-4 flex-none" />
|
|
432
|
-
<span class="truncate">{searchLabel}</span>
|
|
442
|
+
<span class="hidden truncate sm:inline">{searchLabel}</span>
|
|
433
443
|
{#if searchShortcut}
|
|
434
444
|
<kbd
|
|
435
445
|
class="border-border bg-background text-shell-muted-foreground text-2xs ml-auto hidden rounded border px-1.5 py-0.5 font-mono sm:block"
|
|
@@ -481,7 +491,7 @@
|
|
|
481
491
|
<button
|
|
482
492
|
bind:this={overlayFirstFocus}
|
|
483
493
|
onclick={closeMobileNav}
|
|
484
|
-
class="border-border text-shell-muted-foreground hover:text-shell-foreground absolute top-
|
|
494
|
+
class="border-border text-shell-muted-foreground hover:text-shell-foreground absolute top-[calc(0.75rem+env(safe-area-inset-top))] right-3 z-10 grid size-8 place-items-center rounded-md border md:hidden"
|
|
485
495
|
aria-label="Close menu"
|
|
486
496
|
>
|
|
487
497
|
<X class="size-4" />
|
|
@@ -535,7 +545,7 @@
|
|
|
535
545
|
|
|
536
546
|
<div class="flex min-h-0 min-w-0 flex-1 flex-col">
|
|
537
547
|
<header
|
|
538
|
-
class="ds-shell-bar bg-shell/80 text-shell-foreground border-border sticky top-0 z-20 flex h-14 flex-none items-center gap-2 border-b px-3 backdrop-blur sm:gap-3 sm:px-5"
|
|
548
|
+
class="ds-shell-bar bg-shell/80 text-shell-foreground border-border sticky top-0 z-20 flex min-h-14 flex-none items-center gap-2 border-b px-3 pt-[env(safe-area-inset-top)] backdrop-blur sm:gap-3 sm:px-5"
|
|
539
549
|
>
|
|
540
550
|
{#if hasNav}
|
|
541
551
|
<!-- `aria-controls` names the region this toggle opens. It is present
|
|
@@ -568,12 +578,16 @@
|
|
|
568
578
|
{#if context}{@render context()}{/if}
|
|
569
579
|
|
|
570
580
|
{#if onSearch && searchPlacement === 'leading'}
|
|
571
|
-
{@render searchButton(
|
|
581
|
+
{@render searchButton(
|
|
582
|
+
'h-9 w-9 flex-none justify-center px-0 sm:h-auto sm:w-[clamp(200px,32vw,560px)] sm:flex-1 sm:justify-start sm:gap-2 sm:px-3 sm:py-1.5'
|
|
583
|
+
)}
|
|
572
584
|
{/if}
|
|
573
585
|
|
|
574
586
|
<div class="ml-auto flex flex-none items-center gap-2 sm:gap-3">
|
|
575
587
|
{#if onSearch && searchPlacement === 'trailing'}
|
|
576
|
-
{@render searchButton(
|
|
588
|
+
{@render searchButton(
|
|
589
|
+
'h-9 w-9 flex-none justify-center px-0 sm:h-auto sm:w-[clamp(200px,32vw,560px)] sm:justify-start sm:gap-2 sm:px-3 sm:py-1.5'
|
|
590
|
+
)}
|
|
577
591
|
{/if}
|
|
578
592
|
|
|
579
593
|
{#if actions}{@render actions()}{/if}
|
|
@@ -694,7 +708,8 @@
|
|
|
694
708
|
'flex min-h-0 min-w-0 flex-1 flex-col',
|
|
695
709
|
'w-full',
|
|
696
710
|
capped && 'ds-shell-measure',
|
|
697
|
-
padded &&
|
|
711
|
+
padded &&
|
|
712
|
+
'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'
|
|
698
713
|
)}
|
|
699
714
|
data-measure={capped ?? undefined}
|
|
700
715
|
>
|
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
import { cn, type WithElementRef } from '../../../utils.js';
|
|
5
5
|
import StatList from '../stat-list/stat-list.svelte';
|
|
6
6
|
import type { StatItem } from '../stat-list/stat-list.svelte';
|
|
7
|
+
import AppDialog from '../app-dialog/app-dialog.svelte';
|
|
8
|
+
import PanelRight from '@lucide/svelte/icons/panel-right';
|
|
7
9
|
|
|
8
10
|
// The persistent right-hand context column, identical on every route. It holds
|
|
9
11
|
// one standing "At a glance" stat card and, on routes with a selectable table,
|
|
@@ -16,10 +18,11 @@
|
|
|
16
18
|
// the stat card is shrink-0 and any detail takes the remaining height with its
|
|
17
19
|
// own inner scroll.
|
|
18
20
|
//
|
|
19
|
-
// Below xl (1280px)
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
21
|
+
// Below xl (1280px) a fixed nav rail plus a ~400px column would crush the
|
|
22
|
+
// primary pane, so the standing `<aside>` stays hidden there — but its content
|
|
23
|
+
// does not vanish with it. A floating trigger opens the identical stats/detail
|
|
24
|
+
// pair in a dialogue instead: the column's shape is a desktop affordance, the
|
|
25
|
+
// information inside it is not.
|
|
23
26
|
let {
|
|
24
27
|
stats,
|
|
25
28
|
statsTitle = 'At a glance',
|
|
@@ -38,8 +41,17 @@
|
|
|
38
41
|
detail?: Snippet;
|
|
39
42
|
ariaLabel?: string;
|
|
40
43
|
} = $props();
|
|
44
|
+
|
|
45
|
+
let sheetOpen = $state(false);
|
|
41
46
|
</script>
|
|
42
47
|
|
|
48
|
+
{#snippet body()}
|
|
49
|
+
<StatList items={stats} title={statsTitle} info={statsInfo} />
|
|
50
|
+
{#if detail}
|
|
51
|
+
{@render detail()}
|
|
52
|
+
{/if}
|
|
53
|
+
{/snippet}
|
|
54
|
+
|
|
43
55
|
<aside
|
|
44
56
|
bind:this={ref}
|
|
45
57
|
aria-label={ariaLabel}
|
|
@@ -49,8 +61,24 @@
|
|
|
49
61
|
)}
|
|
50
62
|
{...restProps}
|
|
51
63
|
>
|
|
52
|
-
|
|
53
|
-
{#if detail}
|
|
54
|
-
{@render detail()}
|
|
55
|
-
{/if}
|
|
64
|
+
{@render body()}
|
|
56
65
|
</aside>
|
|
66
|
+
|
|
67
|
+
<!-- The reachable stand-in below xl. `size-11` (44px) rather than this
|
|
68
|
+
package's usual 36px control row: this is the one control whose entire
|
|
69
|
+
job is making otherwise-unreachable content reachable, so it earns the
|
|
70
|
+
full touch-target minimum rather than the shell's ordinary density. -->
|
|
71
|
+
<button
|
|
72
|
+
type="button"
|
|
73
|
+
onclick={() => (sheetOpen = true)}
|
|
74
|
+
class="border-border bg-card text-muted-foreground hover:text-foreground ds-edge fixed right-4 bottom-[calc(1rem+env(safe-area-inset-bottom))] z-30 grid size-11 flex-none place-items-center rounded-full border xl:hidden"
|
|
75
|
+
aria-label={ariaLabel ? `Open ${ariaLabel}` : `Open ${statsTitle}`}
|
|
76
|
+
>
|
|
77
|
+
<PanelRight class="size-4.5" />
|
|
78
|
+
</button>
|
|
79
|
+
|
|
80
|
+
<AppDialog bind:open={sheetOpen} title={ariaLabel ?? statsTitle} size="sm">
|
|
81
|
+
<div class="flex flex-col gap-5">
|
|
82
|
+
{@render body()}
|
|
83
|
+
</div>
|
|
84
|
+
</AppDialog>
|
package/dist/styles.css
CHANGED
|
@@ -867,6 +867,11 @@
|
|
|
867
867
|
transition: width 200ms ease;
|
|
868
868
|
}
|
|
869
869
|
|
|
870
|
+
/* Full viewport height (`inset-block: 0`), so the brand row at its top and
|
|
871
|
+
the last nav row at its bottom sit exactly where a notch or a home
|
|
872
|
+
indicator gesture bar would cover them. The rail's own sticky state
|
|
873
|
+
never needs this — it starts below the page's own top edge in normal
|
|
874
|
+
flow, not against the physical screen edge. */
|
|
870
875
|
.ds-shell-rail[data-drawer='true'] {
|
|
871
876
|
display: flex;
|
|
872
877
|
position: fixed;
|
|
@@ -874,6 +879,8 @@
|
|
|
874
879
|
left: 0;
|
|
875
880
|
z-index: 50;
|
|
876
881
|
max-width: 82vw;
|
|
882
|
+
padding-top: env(safe-area-inset-top);
|
|
883
|
+
padding-bottom: env(safe-area-inset-bottom);
|
|
877
884
|
box-shadow:
|
|
878
885
|
0 10px 15px -3px oklch(0 0 0 / 0.1),
|
|
879
886
|
0 4px 6px -4px oklch(0 0 0 / 0.1);
|
|
@@ -890,6 +897,8 @@
|
|
|
890
897
|
position: sticky;
|
|
891
898
|
z-index: auto;
|
|
892
899
|
max-width: none;
|
|
900
|
+
padding-top: 0;
|
|
901
|
+
padding-bottom: 0;
|
|
893
902
|
box-shadow: none;
|
|
894
903
|
animation: none;
|
|
895
904
|
}
|
package/package.json
CHANGED
|
@@ -1,103 +1,104 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
2
|
+
"name": "@poodle64/ui",
|
|
3
|
+
"version": "2026.9.3",
|
|
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
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": "github:radar-hooves/design-system",
|
|
8
|
+
"publishConfig": {
|
|
9
|
+
"registry": "https://registry.npmjs.org",
|
|
10
|
+
"access": "public",
|
|
11
|
+
"provenance": false
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"bin",
|
|
15
|
+
"dist",
|
|
16
|
+
"registry"
|
|
17
|
+
],
|
|
18
|
+
"exports": {
|
|
19
|
+
"./package.json": "./package.json",
|
|
20
|
+
"./styles.css": "./dist/styles.css",
|
|
21
|
+
"./utils": {
|
|
22
|
+
"types": "./dist/utils.d.ts",
|
|
23
|
+
"svelte": "./dist/utils.js"
|
|
24
|
+
},
|
|
25
|
+
"./format": {
|
|
26
|
+
"types": "./dist/format.d.ts",
|
|
27
|
+
"svelte": "./dist/format.js"
|
|
28
|
+
},
|
|
29
|
+
"./*": {
|
|
30
|
+
"types": "./dist/components/ui/*/index.d.ts",
|
|
31
|
+
"svelte": "./dist/components/ui/*/index.js"
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
"bin": {
|
|
35
|
+
"ds-check-colour-surface": "./bin/check-colour-surface.mjs"
|
|
36
|
+
},
|
|
37
|
+
"peerDependencies": {
|
|
38
|
+
"bits-ui": "^2.18.1",
|
|
39
|
+
"formsnap": "^2.0.1",
|
|
40
|
+
"mode-watcher": "^1.1.0",
|
|
41
|
+
"svelte": "^5.33.0",
|
|
42
|
+
"svelte-sonner": "^1.1.1",
|
|
43
|
+
"sveltekit-superforms": "^2.30.0"
|
|
44
|
+
},
|
|
45
|
+
"peerDependenciesMeta": {
|
|
46
|
+
"mode-watcher": {
|
|
47
|
+
"optional": true
|
|
48
|
+
},
|
|
49
|
+
"svelte-sonner": {
|
|
50
|
+
"optional": true
|
|
51
|
+
},
|
|
52
|
+
"formsnap": {
|
|
53
|
+
"optional": true
|
|
54
|
+
},
|
|
55
|
+
"sveltekit-superforms": {
|
|
56
|
+
"optional": true
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
"dependencies": {
|
|
60
|
+
"@jsonforms/core": "^3.8.0",
|
|
61
|
+
"@lucide/svelte": "^1.25.0",
|
|
62
|
+
"@tanstack/table-core": "^8.21.3",
|
|
63
|
+
"clsx": "^2.1.1",
|
|
64
|
+
"tailwind-merge": "^3.6.0",
|
|
65
|
+
"tailwind-variants": "^3.2.2",
|
|
66
|
+
"tw-animate-css": "^1.4.0"
|
|
67
|
+
},
|
|
68
|
+
"devDependencies": {
|
|
69
|
+
"@poodle64/design-tokens": "workspace:*",
|
|
70
|
+
"@sveltejs/kit": "^2.63.0",
|
|
71
|
+
"@sveltejs/package": "^2.5.8",
|
|
72
|
+
"@sveltejs/vite-plugin-svelte": "^7.1.2",
|
|
73
|
+
"@tailwindcss/cli": "^4.3.2",
|
|
74
|
+
"@testing-library/jest-dom": "^7.0.0",
|
|
75
|
+
"@testing-library/svelte": "^5.4.2",
|
|
76
|
+
"@types/node": "^22.20.1",
|
|
77
|
+
"bits-ui": "^2.18.1",
|
|
78
|
+
"formsnap": "^2.0.1",
|
|
79
|
+
"jsdom": "^29.1.1",
|
|
80
|
+
"mode-watcher": "^1.1.0",
|
|
81
|
+
"playwright": "^1.62.0",
|
|
82
|
+
"publint": "^0.3.15",
|
|
83
|
+
"svelte": "^5.56.2",
|
|
84
|
+
"svelte-check": "^4.6.0",
|
|
85
|
+
"svelte-sonner": "^1.1.1",
|
|
86
|
+
"sveltekit-superforms": "^2.30.2",
|
|
87
|
+
"tailwindcss": "^4.3.2",
|
|
88
|
+
"typescript": "^6.0.3",
|
|
89
|
+
"vite": "^8.0.16",
|
|
90
|
+
"vitest": "^4.1.10"
|
|
91
|
+
},
|
|
92
|
+
"scripts": {
|
|
93
|
+
"build": "node scripts/generate-registry.mjs && svelte-kit sync && svelte-package && publint",
|
|
94
|
+
"registry": "node scripts/generate-registry.mjs",
|
|
95
|
+
"registry:check": "node scripts/generate-registry.mjs --check",
|
|
96
|
+
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
|
97
|
+
"test": "pnpm run build && vitest run",
|
|
98
|
+
"test:browser": "pnpm run harness:build && node harness/drive.mjs",
|
|
99
|
+
"prepublishOnly": "pnpm run build",
|
|
100
|
+
"harness:build": "vite build --config harness/vite.config.ts && tailwindcss -i harness/harness.css -o harness/dist/app.css --minify",
|
|
101
|
+
"harness:serve": "python3 -m http.server 4180 --directory harness/dist"
|
|
102
|
+
},
|
|
103
|
+
"packageManager": "pnpm@10.28.0"
|
|
103
104
|
}
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"meta": {
|
|
3
3
|
"package": "@poodle64/ui",
|
|
4
|
-
"version": "2026.9.
|
|
4
|
+
"version": "2026.9.3",
|
|
5
5
|
"generatedBy": "scripts/generate-registry.mjs",
|
|
6
6
|
"source": "scripts/situations.json + package source (DO NOT EDIT the outputs by hand)",
|
|
7
|
-
"componentCount":
|
|
7
|
+
"componentCount": 56,
|
|
8
8
|
"situationCount": 15
|
|
9
9
|
},
|
|
10
10
|
"situations": [
|
|
@@ -299,6 +299,13 @@
|
|
|
299
299
|
"import": "@poodle64/ui/context-column",
|
|
300
300
|
"props": "stats, statsTitle?, statsInfo?, detail?, ariaLabel?",
|
|
301
301
|
"insteadOf": "a hand-built right-hand <aside> with a stat card and a detail pane"
|
|
302
|
+
},
|
|
303
|
+
{
|
|
304
|
+
"name": "AppIdentity",
|
|
305
|
+
"dir": "app-identity",
|
|
306
|
+
"import": "@poodle64/ui/app-identity",
|
|
307
|
+
"props": "user, workspace?, role?, entitlements?, onSignOut, onAccountSettings?, onSwitchTheme?",
|
|
308
|
+
"insteadOf": "a hand-rolled account/avatar dropdown wired to raw DropdownMenu.* + Avatar.*"
|
|
302
309
|
}
|
|
303
310
|
]
|
|
304
311
|
},
|
|
@@ -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.
|
|
4
|
+
Generated from `@poodle64/ui@2026.9.3`. 56 components, 15 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
|
|
|
@@ -127,6 +127,7 @@ _The standing chrome — the app shell and its nav, the one page-title treatment
|
|
|
127
127
|
| `AppShell` | `@poodle64/ui/app-shell` | `nav, currentPath, brandTitle?, actions?, context?, children` | a bespoke nav rail + header layout per app |
|
|
128
128
|
| `PageHeader` | `@poodle64/ui/page-header` | `title?, eyebrow?, breadcrumbs?, icon?, subtitle?, info?, meta?, actions?` | a hand-written <h1> and title bar (the drift gate fails this) |
|
|
129
129
|
| `ContextColumn` | `@poodle64/ui/context-column` | `stats, statsTitle?, statsInfo?, detail?, ariaLabel?` | a hand-built right-hand <aside> with a stat card and a detail pane |
|
|
130
|
+
| `AppIdentity` | `@poodle64/ui/app-identity` | `user, workspace?, role?, entitlements?, onSignOut, onAccountSettings?, onSwitchTheme?` | a hand-rolled account/avatar dropdown wired to raw DropdownMenu.* + Avatar.* |
|
|
130
131
|
|
|
131
132
|
## A dialog or overlay
|
|
132
133
|
|