@poodle64/ui 2026.9.4 → 2026.9.6

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
@@ -1164,4 +1164,18 @@ non-zero and naming the field on any difference.
1164
1164
 
1165
1165
  1. Change a component; bump `version` in `package.json` (CalVer).
1166
1166
  2. `pnpm build`, which runs `svelte-package` then `publint` (package.json/exports sanity).
1167
- 3. Commit, tag `ui-v<version>`, push the tag; CI publishes to public npm.
1167
+ 3. Commit, tag `ui-v<version>`, push the tag.
1168
+ 4. `.github/workflows/publish.yaml` runs on that push and should publish via
1169
+ npm OIDC trusted publishing: check it with
1170
+ `gh run list --workflow=publish.yaml`. As of 2026.9.4 every run since
1171
+ `ui-v2026.9.2` fails at the `npm publish` step with
1172
+ `E404 Not Found - PUT .../@poodle64%2fui` (an OIDC/trusted-publisher
1173
+ binding issue, since `Build and test package` passes first). Until that
1174
+ is diagnosed and fixed, publish manually from `packages/ui/`:
1175
+ ```bash
1176
+ printf '//registry.npmjs.org/:_authToken=${NODE_AUTH_TOKEN}\n' > .npmrc
1177
+ signet exec --identity huginn-claude --broker https://portcullis.godswood.au \
1178
+ --credential npm-publish-token --env-var NODE_AUTH_TOKEN -- npm publish
1179
+ rm -f .npmrc
1180
+ ```
1181
+ Confirm with `npm view @poodle64/ui version`.
@@ -30,7 +30,14 @@
30
30
  * instead (operator ruling, 07/09/2026, master-project#291's org-of-one
31
31
  * case). Wire it whenever the app has a members route, whether or not it
32
32
  * also renders a switcher.
33
+ *
34
+ * `links` is the escape valve for everything the fixed entries have no room
35
+ * for — an Admin route, a support link — as a LIST, so every app's extra
36
+ * destinations render as the same row. Without it an app puts its own
37
+ * destination in AppShell's `actions` slot as an icon button (cadmus did,
38
+ * twice), and two apps do it two ways in the same bar.
33
39
  */
40
+ import type { Component } from 'svelte';
34
41
  import { toggleMode } from 'mode-watcher';
35
42
  import * as DropdownMenu from '../dropdown-menu/index.js';
36
43
  import * as Avatar from '../avatar/index.js';
@@ -43,7 +50,8 @@
43
50
  onSignOut,
44
51
  onAccountSettings,
45
52
  onManageMembers,
46
- onSwitchTheme
53
+ onSwitchTheme,
54
+ links = []
47
55
  }: {
48
56
  user: { username: string; display_name: string | null; email: string | null };
49
57
  /** The active workspace's name. Omit while none is chosen yet — the block hides rather than rendering empty. */
@@ -70,6 +78,22 @@
70
78
  onManageMembers?: () => void;
71
79
  /** Override the theme action. Defaults to mode-watcher's toggleMode, matching AppShell. */
72
80
  onSwitchTheme?: () => void;
81
+ /**
82
+ * App destinations the fixed entries have no room for, rendered in their
83
+ * own group.
84
+ *
85
+ * A list of items, never a snippet: a snippet lets each app invent its own
86
+ * row shape, and two apps inventing two shapes in the same top bar is the
87
+ * drift this package exists to end. Cadmus is the measured case — its
88
+ * Admin entry had nowhere to go and became an icon button in AppShell's
89
+ * `actions` slot, and a second one (a quiet support link) was about to
90
+ * follow it there.
91
+ *
92
+ * No `href`: the callback shape matches every other action on this
93
+ * component, and a consumer routes with its own `goto`. The icon is
94
+ * optional — a label-only row stays aligned with any iconed row beside it.
95
+ */
96
+ links?: { label: string; icon?: Component<{ class?: string }>; onSelect: () => void }[];
73
97
  } = $props();
74
98
 
75
99
  const shown = $derived(user.display_name ?? user.username);
@@ -110,6 +134,11 @@
110
134
  user.display_name ? initialsOf(user.display_name) : handleInitials(user.username)
111
135
  );
112
136
 
137
+ // Spacers only where the group actually has an icon to align to: a group of
138
+ // label-only rows sits flush with the fixed entries below it, exactly as it
139
+ // would if this prop had never existed.
140
+ const linksHaveIcons = $derived(links.some((l) => l.icon));
141
+
113
142
  function switchTheme() {
114
143
  if (onSwitchTheme) onSwitchTheme();
115
144
  else toggleMode();
@@ -177,6 +206,26 @@
177
206
  <DropdownMenu.Item onSelect={onManageMembers}>Members</DropdownMenu.Item>
178
207
  {/if}
179
208
  {/if}
209
+ {#if links.length}
210
+ <DropdownMenu.Separator />
211
+ <DropdownMenu.Group>
212
+ <!-- Keyed by position, not by label: nothing in the type makes a label
213
+ unique, and two apps' configs colliding on one would be a
214
+ duplicate-key crash rather than a duplicated row. This list is
215
+ app configuration, not data that reorders. -->
216
+ {#each links as link, i (i)}
217
+ {@const Icon = link.icon}
218
+ <DropdownMenu.Item onSelect={link.onSelect}>
219
+ {#if Icon}
220
+ <Icon class="size-4" />
221
+ {:else if linksHaveIcons}
222
+ <span class="size-4 flex-none" aria-hidden="true"></span>
223
+ {/if}
224
+ {link.label}
225
+ </DropdownMenu.Item>
226
+ {/each}
227
+ </DropdownMenu.Group>
228
+ {/if}
180
229
  <DropdownMenu.Separator />
181
230
  <DropdownMenu.Item onSelect={switchTheme}>Switch theme</DropdownMenu.Item>
182
231
  <DropdownMenu.Item onSelect={onAccountSettings}>Account settings</DropdownMenu.Item>
@@ -1,3 +1,42 @@
1
+ /**
2
+ * The signed-in user surface for AppShell's `identity` slot.
3
+ *
4
+ * Graduated from the shell lab (`packages/console/src/routes/lab/+page.svelte`,
5
+ * the `identity === 'avatar'` shape) — operator ruling 04/09/2026: avatar
6
+ * only at rest, no name, no chevron, and no prop reintroduces a variant.
7
+ * Opening it answers who is signed in, which workspace they act in, and the
8
+ * ways out — identity first, because a menu that opens on a list of actions
9
+ * makes you infer who you are from the avatar you just clicked.
10
+ *
11
+ * Prop names match the stamped template's auth store
12
+ * (`frontend/src/lib/auth.svelte.ts` — `User`, `Membership`) exactly, so a
13
+ * consumer wires this straight from `auth.user`, `auth.entitlements`,
14
+ * `auth.activeWorkspace` and `auth.activeRole` with no reshaping. A guessed
15
+ * shape needs rewriting the day it meets the real store, which is the whole
16
+ * reason this one didn't. `display_name` and `email` are both nullable
17
+ * there, and `activeWorkspace` is genuinely null until a caller with several
18
+ * memberships chooses one — this component is built against that, not a
19
+ * happy path.
20
+ *
21
+ * Sign-out and settings are the consuming app's concerns, never behaviour
22
+ * baked in here: `onSignOut` and `onAccountSettings` are callbacks. Theme
23
+ * follows AppShell's own `onToggleTheme` pattern — override it, or accept
24
+ * mode-watcher's `toggleMode` by default.
25
+ *
26
+ * `onManageMembers` exists for the caller a workspace switcher has nothing
27
+ * to show: `WorkspaceMenu` (the factory template) renders nothing in the
28
+ * bar for a single-member workspace, and this is where Members lands
29
+ * instead (operator ruling, 07/09/2026, master-project#291's org-of-one
30
+ * case). Wire it whenever the app has a members route, whether or not it
31
+ * also renders a switcher.
32
+ *
33
+ * `links` is the escape valve for everything the fixed entries have no room
34
+ * for — an Admin route, a support link — as a LIST, so every app's extra
35
+ * destinations render as the same row. Without it an app puts its own
36
+ * destination in AppShell's `actions` slot as an icon button (cadmus did,
37
+ * twice), and two apps do it two ways in the same bar.
38
+ */
39
+ import type { Component } from 'svelte';
1
40
  type $$ComponentProps = {
2
41
  user: {
3
42
  username: string;
@@ -28,7 +67,29 @@ type $$ComponentProps = {
28
67
  onManageMembers?: () => void;
29
68
  /** Override the theme action. Defaults to mode-watcher's toggleMode, matching AppShell. */
30
69
  onSwitchTheme?: () => void;
70
+ /**
71
+ * App destinations the fixed entries have no room for, rendered in their
72
+ * own group.
73
+ *
74
+ * A list of items, never a snippet: a snippet lets each app invent its own
75
+ * row shape, and two apps inventing two shapes in the same top bar is the
76
+ * drift this package exists to end. Cadmus is the measured case — its
77
+ * Admin entry had nowhere to go and became an icon button in AppShell's
78
+ * `actions` slot, and a second one (a quiet support link) was about to
79
+ * follow it there.
80
+ *
81
+ * No `href`: the callback shape matches every other action on this
82
+ * component, and a consumer routes with its own `goto`. The icon is
83
+ * optional — a label-only row stays aligned with any iconed row beside it.
84
+ */
85
+ links?: {
86
+ label: string;
87
+ icon?: Component<{
88
+ class?: string;
89
+ }>;
90
+ onSelect: () => void;
91
+ }[];
31
92
  };
32
- declare const AppIdentity: import("svelte").Component<$$ComponentProps, {}, "">;
93
+ declare const AppIdentity: Component<$$ComponentProps, {}, "">;
33
94
  type AppIdentity = ReturnType<typeof AppIdentity>;
34
95
  export default AppIdentity;
@@ -575,7 +575,19 @@
575
575
  {@render brandLockup(true)}
576
576
  </span>
577
577
 
578
- {#if context}{@render context()}{/if}
578
+ <!-- `min-w-0` is what lets a leading `context` snippet (a workspace
579
+ switcher, a breadcrumb) actually shrink under crowding — a flex
580
+ item's automatic `min-width: auto` otherwise floors it at its own
581
+ min-content size, so with search, actions and identity all also
582
+ competing for the bar the FIRST thing to run out of room was
583
+ whichever trailing control happened to sit last in source order
584
+ (theme, identity) — pushed edge-to-edge past the header's own
585
+ right edge and, with no horizontal scroll on the bar, permanently
586
+ unreachable rather than merely cramped (design-system#111). The
587
+ wordmark and the search button already carry their own shrink
588
+ behaviour (hiding text, going icon-only); `context` is the one
589
+ leading slot that never had to. -->
590
+ {#if context}<div class="min-w-0">{@render context()}</div>{/if}
579
591
 
580
592
  {#if onSearch && searchPlacement === 'leading'}
581
593
  {@render searchButton(
package/dist/styles.css CHANGED
@@ -624,6 +624,15 @@
624
624
  .ds-nav {
625
625
  --ds-nav-ink: var(--ds-nav-chrome-ink, var(--foreground));
626
626
  --ds-nav-ink-muted: var(--ds-nav-chrome-ink-muted, var(--muted-foreground));
627
+ /* Specified rather than left `visible` (the default): `overflow-y: auto`
628
+ otherwise forces the computed value of an unspecified overflow-x to
629
+ `auto` too (CSSOM's "if one axis is visible and the other is not, the
630
+ visible one becomes auto" rule), so a row wider than the nav — a long
631
+ label, a wide badge — got its own sideways scrollbar inside the drawer.
632
+ The drawer scrolls vertically only; a row that doesn't fit truncates
633
+ (`.ds-nav-item`'s label already carries `.truncate`) rather than
634
+ growing a second scroll axis on top of the menu's own (design-system#111). */
635
+ overflow-x: hidden;
627
636
  }
628
637
 
629
638
  .ds-shell-rail,
@@ -847,6 +856,21 @@
847
856
  padding-inline: 0;
848
857
  }
849
858
 
859
+ /* A hard ceiling on the whole shell, in the one unit that names the actual
860
+ drawn surface rather than a percentage of an ancestor. `.ds-shell` is
861
+ already `flex` with `overflow-hidden` in the component markup, which
862
+ clips an ordinary descendant that outgrows it — but a `position: fixed`
863
+ descendant (the open drawer, below) is excused from an ancestor's
864
+ `overflow: hidden` by definition, fixed positioning escapes every
865
+ clipping ancestor that is not itself a containing block for fixed
866
+ elements, and `.ds-shell` is not one. This is the backstop for that
867
+ whole class of leak: whatever grows past the shell's own box, on
868
+ whichever descendant, the SHELL still cannot be asked to draw wider than
869
+ the viewport it is actually in (design-system#111). */
870
+ .ds-shell {
871
+ max-width: 100dvw;
872
+ }
873
+
850
874
  /* The rail is ONE element in two states, not two elements (see the comment in
851
875
  app-shell.svelte): below md it is hidden until `data-drawer` turns it into
852
876
  an overlay; at md and up it is the permanent column and `data-drawer` means
@@ -871,16 +895,35 @@
871
895
  the last nav row at its bottom sit exactly where a notch or a home
872
896
  indicator gesture bar would cover them. The rail's own sticky state
873
897
  never needs this — it starts below the page's own top edge in normal
874
- flow, not against the physical screen edge. */
898
+ flow, not against the physical screen edge.
899
+
900
+ The width is a `min()` against the true viewport (`100dvw`, less the
901
+ left inset a landscape notch carves out of it) rather than the old
902
+ `max-width: 82vw` — `vw` is the LAYOUT viewport, which on a phone stays
903
+ the largest size the page could ever occupy whether or not the browser
904
+ chrome is actually showing, while `dvw` is the viewport the page is
905
+ ACTUALLY drawn into right now. The two rarely disagree on width the way
906
+ `vh`/`dvh` disagree on height, but disagreeing on principle was already
907
+ enough to make this the one place on the drawer that named the wrong
908
+ viewport (design-system#111): every other rule the drawer carries reads
909
+ `dvh`/`env(safe-area-inset-*)`, so `82vw` was the one dimension still
910
+ trusting a unit this component elsewhere treats as untrustworthy on a
911
+ phone. `min()` over `--ds-shell-rail-width` keeps the drawer at its
912
+ ordinary width on anything bigger than a narrow phone, and only the
913
+ viewport term binds below that — so the drawer can never be asked to be
914
+ wider than the screen it is drawn on, at any width, by construction
915
+ rather than by testing every content shape that could have pushed it
916
+ there. */
875
917
  .ds-shell-rail[data-drawer='true'] {
876
918
  display: flex;
877
919
  position: fixed;
878
920
  inset-block: 0;
879
921
  left: 0;
880
922
  z-index: 50;
881
- max-width: 82vw;
923
+ width: min(var(--ds-shell-rail-width), 82dvw, 100dvw - env(safe-area-inset-left, 0px));
882
924
  padding-top: env(safe-area-inset-top);
883
925
  padding-bottom: env(safe-area-inset-bottom);
926
+ padding-left: env(safe-area-inset-left);
884
927
  box-shadow:
885
928
  0 10px 15px -3px oklch(0 0 0 / 0.1),
886
929
  0 4px 6px -4px oklch(0 0 0 / 0.1);
@@ -896,9 +939,10 @@
896
939
  .ds-shell-rail[data-drawer='true'] {
897
940
  position: sticky;
898
941
  z-index: auto;
899
- max-width: none;
942
+ width: var(--ds-shell-rail-width);
900
943
  padding-top: 0;
901
944
  padding-bottom: 0;
945
+ padding-left: 0;
902
946
  box-shadow: none;
903
947
  animation: none;
904
948
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@poodle64/ui",
3
- "version": "2026.9.4",
3
+ "version": "2026.9.6",
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,7 +1,7 @@
1
1
  {
2
2
  "meta": {
3
3
  "package": "@poodle64/ui",
4
- "version": "2026.9.4",
4
+ "version": "2026.9.6",
5
5
  "generatedBy": "scripts/generate-registry.mjs",
6
6
  "source": "scripts/situations.json + package source (DO NOT EDIT the outputs by hand)",
7
7
  "componentCount": 56,
@@ -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`. 56 components, 15 situations.
4
+ Generated from `@poodle64/ui@2026.9.6`. 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