@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 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 | 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. |
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'; /* registers the surface */
1113
- @source '../node_modules/@poodle64/ui/dist'; /* puts it in the scan */
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 poodle64/design-system#3, which reached every
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;
@@ -0,0 +1,2 @@
1
+ export { default as AppIdentity } from './app-identity.svelte';
2
+ export { default } from './app-identity.svelte';
@@ -0,0 +1,2 @@
1
+ export { default as AppIdentity } from './app-identity.svelte';
2
+ export { default } from './app-identity.svelte';
@@ -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 gap-2 rounded-md border px-3 py-1.5 text-sm transition-colors',
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-3 right-3 z-10 grid size-8 place-items-center rounded-md border md:hidden"
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('min-w-0 flex-1 sm:w-[clamp(200px,32vw,560px)] sm:flex-none')}
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('min-w-0 flex-none sm:w-[clamp(200px,32vw,560px)]')}
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 && 'px-4 py-5 sm:px-6 md:px-8 md:py-7 2xl:px-12'
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) the column is hidden so the primary pane keeps full width:
20
- // a fixed nav rail plus a ~400px column would crush the content on a narrow
21
- // screen. The proper narrow-viewport treatment (off-canvas nav, stacking panes)
22
- // is a separate workstream; this is the regression guard until it lands.
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
- <StatList items={stats} title={statsTitle} info={statsInfo} />
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
- "name": "@poodle64/ui",
3
- "version": "2026.9.1",
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:poodle64/design-system",
8
- "publishConfig": {
9
- "registry": "https://registry.npmjs.org",
10
- "access": "public"
11
- },
12
- "files": [
13
- "bin",
14
- "dist",
15
- "registry"
16
- ],
17
- "exports": {
18
- "./package.json": "./package.json",
19
- "./styles.css": "./dist/styles.css",
20
- "./utils": {
21
- "types": "./dist/utils.d.ts",
22
- "svelte": "./dist/utils.js"
23
- },
24
- "./format": {
25
- "types": "./dist/format.d.ts",
26
- "svelte": "./dist/format.js"
27
- },
28
- "./*": {
29
- "types": "./dist/components/ui/*/index.d.ts",
30
- "svelte": "./dist/components/ui/*/index.js"
31
- }
32
- },
33
- "bin": {
34
- "ds-check-colour-surface": "./bin/check-colour-surface.mjs"
35
- },
36
- "peerDependencies": {
37
- "bits-ui": "^2.18.1",
38
- "formsnap": "^2.0.1",
39
- "mode-watcher": "^1.1.0",
40
- "svelte": "^5.33.0",
41
- "svelte-sonner": "^1.1.1",
42
- "sveltekit-superforms": "^2.30.0"
43
- },
44
- "peerDependenciesMeta": {
45
- "mode-watcher": {
46
- "optional": true
47
- },
48
- "svelte-sonner": {
49
- "optional": true
50
- },
51
- "formsnap": {
52
- "optional": true
53
- },
54
- "sveltekit-superforms": {
55
- "optional": true
56
- }
57
- },
58
- "dependencies": {
59
- "@jsonforms/core": "^3.8.0",
60
- "@lucide/svelte": "^1.25.0",
61
- "@tanstack/table-core": "^8.21.3",
62
- "clsx": "^2.1.1",
63
- "tailwind-merge": "^3.6.0",
64
- "tailwind-variants": "^3.2.2",
65
- "tw-animate-css": "^1.4.0"
66
- },
67
- "devDependencies": {
68
- "@poodle64/design-tokens": "workspace:*",
69
- "@sveltejs/kit": "^2.63.0",
70
- "@sveltejs/package": "^2.5.8",
71
- "@sveltejs/vite-plugin-svelte": "^7.1.2",
72
- "@tailwindcss/cli": "^4.3.2",
73
- "@testing-library/jest-dom": "^7.0.0",
74
- "@testing-library/svelte": "^5.4.2",
75
- "@types/node": "^22.20.1",
76
- "bits-ui": "^2.18.1",
77
- "formsnap": "^2.0.1",
78
- "jsdom": "^29.1.1",
79
- "mode-watcher": "^1.1.0",
80
- "playwright": "^1.62.0",
81
- "publint": "^0.3.15",
82
- "svelte": "^5.56.2",
83
- "svelte-check": "^4.6.0",
84
- "svelte-sonner": "^1.1.1",
85
- "sveltekit-superforms": "^2.30.2",
86
- "tailwindcss": "^4.3.2",
87
- "typescript": "^6.0.3",
88
- "vite": "^8.0.16",
89
- "vitest": "^4.1.10"
90
- },
91
- "scripts": {
92
- "build": "node scripts/generate-registry.mjs && svelte-kit sync && svelte-package && publint",
93
- "registry": "node scripts/generate-registry.mjs",
94
- "registry:check": "node scripts/generate-registry.mjs --check",
95
- "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
96
- "test": "pnpm run build && vitest run",
97
- "test:browser": "pnpm run harness:build && node harness/drive.mjs",
98
- "prepublishOnly": "pnpm run build",
99
- "harness:build": "vite build --config harness/vite.config.ts && tailwindcss -i harness/harness.css -o harness/dist/app.css --minify",
100
- "harness:serve": "python3 -m http.server 4180 --directory harness/dist"
101
- },
102
- "packageManager": "pnpm@10.28.0"
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.1",
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": 55,
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.1`. 55 components, 15 situations.
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