@poodle64/ui 2026.9.0 → 2026.9.2

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
@@ -1089,6 +1089,42 @@ one is bound to Xero tax codes besides. Relative time ("2 hours ago") is out
1089
1089
  too — it rides on `date-fns` in the one app that has it, and a display formatter
1090
1090
  is not worth making that a dependency of every consumer.
1091
1091
 
1092
+ ## The gate: `ds-check-colour-surface`
1093
+
1094
+ The package ships the gate that proves its own surface actually resolves in the
1095
+ consuming app:
1096
+
1097
+ ```bash
1098
+ pnpm exec ds-check-colour-surface # entry: src/app.css
1099
+ pnpm exec ds-check-colour-surface --entry frontend/src/app.css # from a repo root
1100
+ ```
1101
+
1102
+ Tailwind and this package are both resolved from the STYLESHEET's directory, not
1103
+ the working directory, so the second form works from a repo root — which is where
1104
+ a pre-commit hook runs, and how the vendored copies this replaces were invoked.
1105
+
1106
+ An app's `app.css` carries two load-bearing lines, and dropping either breaks
1107
+ nothing any other gate can see — `build`, `lint`, `lint:css` and `check` all
1108
+ still pass while `bg-card`, `bg-muted`, `bg-accent`, `bg-popover` and
1109
+ `border-input` compile to no rule at all:
1110
+
1111
+ ```css
1112
+ @import '@poodle64/ui/styles.css'; /* registers the surface */
1113
+ @source '../node_modules/@poodle64/ui/dist'; /* puts it in the scan */
1114
+ ```
1115
+
1116
+ They fail differently, so the gate asserts them differently: it compiles the
1117
+ semantic utilities and requires each to emit a real declaration (the `@import`),
1118
+ then asks the compiler which sources it resolved and requires this package's own
1119
+ classes to appear and compile (the `@source`). Tailwind comes from the app, not
1120
+ from this package's tree, so what it compiles is what the app ships.
1121
+
1122
+ It was vendored into nine apps as a byte-identical `scripts/check-colour-surface.mjs`
1123
+ and absent from three. If an app still carries that copy, delete it and the
1124
+ `lint:colour` script that calls it once the app is on this version or later —
1125
+ the estate's shared frontend-CI workflow prefers the shipped bin and keeps the
1126
+ vendored path only as a fallback.
1127
+
1092
1128
  ## Verifying a change
1093
1129
 
1094
1130
  ```bash
@@ -0,0 +1,262 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * The shadcn colour surface must actually resolve. Nothing else notices if it does not.
4
+ *
5
+ * The consuming app's `src/app.css` carries two load-bearing lines:
6
+ *
7
+ * @import '@poodle64/ui/styles.css' the shadcn semantic surface AND its
8
+ * Tailwind v4 `@theme inline` registration
9
+ * @source '.../@poodle64/ui/dist' puts the package inside Tailwind's scan
10
+ *
11
+ * Delete either and every gate an app has still passes: `pnpm build`, `lint`,
12
+ * `lint:css` and `check` all succeed, and the app ships with `bg-card`,
13
+ * `bg-muted`, `bg-accent`, `bg-popover` and `border-input` compiling to no rule
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
16
+ * app in the estate before anyone saw it: dropdowns with no hover, inputs with no
17
+ * border, cards and popovers with no surface.
18
+ *
19
+ * The two lines fail differently, so this asserts them differently:
20
+ *
21
+ * registration compile a fixed set of semantic utilities and require each to
22
+ * emit a real declaration. Losing the @import kills all of them.
23
+ * scanning ask the compiler which sources it resolved, read the package's
24
+ * own dist through them, and require that its classes both appear
25
+ * and compile. Losing the @source leaves registration intact — the
26
+ * utilities still compile when named explicitly — and silently
27
+ * stops generating everything the shared components themselves use.
28
+ *
29
+ * It ships from the package because nine apps carried a byte-identical copy of it
30
+ * under `frontend/scripts/`, and three carried none: a gate that guards THIS
31
+ * package's surface, maintained in nine places, is the divergence it exists to
32
+ * stop. The only substantive change from that vendored form is where it looks —
33
+ * the app root comes from the working directory or `--entry`, not from the
34
+ * script's own location, because the script no longer lives in the app.
35
+ *
36
+ * Usage: ds-check-colour-surface [--entry <path to app.css>] (default: src/app.css)
37
+ */
38
+
39
+ import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs';
40
+ import { readFile, readdir } from 'node:fs/promises';
41
+ import { createRequire } from 'node:module';
42
+ import { dirname, join, relative, resolve } from 'node:path';
43
+ import { fileURLToPath, pathToFileURL } from 'node:url';
44
+ import process from 'node:process';
45
+
46
+ /** Semantic names the package registers; each must emit a real declaration. */
47
+ const REGISTERED = ['bg-card', 'bg-muted', 'bg-accent', 'bg-popover', 'border-input'];
48
+ const PACKAGE = '@poodle64/ui';
49
+
50
+ /**
51
+ * Resolve a stylesheet the way the bundler does.
52
+ *
53
+ * `require.resolve` cannot be used: a CSS-only package exports neither a JS main
54
+ * nor `./package.json`. Walk `node_modules` on disk instead, from the REAL path —
55
+ * pnpm links each dependency into a store directory and a package's own siblings
56
+ * live beside it there, not under this app's symlink.
57
+ */
58
+ function resolveStylesheet(id, base) {
59
+ if (id.startsWith('.') || id.startsWith('/')) return resolve(base, id);
60
+ const scoped = id.startsWith('@');
61
+ const pkgName = id
62
+ .split('/')
63
+ .slice(0, scoped ? 2 : 1)
64
+ .join('/');
65
+ const sub = id
66
+ .split('/')
67
+ .slice(scoped ? 2 : 1)
68
+ .join('/');
69
+ for (let dir = realpathSync(base); ; dir = dirname(dir)) {
70
+ const root = resolve(dir, 'node_modules', pkgName);
71
+ if (existsSync(root)) {
72
+ const pkg = JSON.parse(readFileSync(resolve(root, 'package.json'), 'utf8'));
73
+ const pick = (e) =>
74
+ typeof e === 'string' ? e : (e?.style ?? e?.default ?? e?.import ?? e?.require);
75
+ const key = sub ? `./${sub}` : '.';
76
+ let entry = pick(pkg.exports?.[key]) ?? pick(pkg.exports?.[`${key}.css`]);
77
+ if (!entry && sub) {
78
+ const direct = resolve(root, sub.endsWith('.css') ? sub : `${sub}.css`);
79
+ if (existsSync(direct)) return direct;
80
+ }
81
+ entry ??= pkg.style ?? pick(pkg.exports?.['.']) ?? pkg.main;
82
+ if (!entry) throw new Error(`no stylesheet entry for ${id}`);
83
+ return resolve(root, entry);
84
+ }
85
+ if (dirname(dir) === dir) throw new Error(`cannot resolve stylesheet ${id} from ${base}`);
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Tailwind comes from the APP, never from this package's own tree.
91
+ *
92
+ * Under pnpm a bin runs from inside the store, where only what @poodle64/ui
93
+ * itself declares is reachable; the compiler that matters is the one the app
94
+ * builds with, and compiling against a different copy would prove nothing about
95
+ * the app's own output.
96
+ *
97
+ * Resolution starts at the STYLESHEET, not the working directory, so
98
+ * `--entry frontend/src/app.css` works from a repo root — which is where a
99
+ * pre-commit hook runs, and where the nine vendored copies this replaces were
100
+ * invoked from.
101
+ */
102
+ async function loadCompiler(from) {
103
+ const require = createRequire(resolve(from, '_'));
104
+ let manifestPath;
105
+ try {
106
+ manifestPath = require.resolve('tailwindcss/package.json');
107
+ } catch {
108
+ throw new Error(
109
+ `tailwindcss is not resolvable from ${from} — this gate compiles the app's own ` +
110
+ 'stylesheet, so it needs the compiler the app builds with'
111
+ );
112
+ }
113
+
114
+ // Resolve the ESM condition explicitly. `require.resolve('tailwindcss')`
115
+ // picks the `require` condition — Tailwind's CJS bundle — from which
116
+ // `compile` is not an ESM named export, so the import succeeds and hands
117
+ // back undefined. That reads as "compile is not a function" several frames
118
+ // later, which is a long way from the actual cause.
119
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
120
+ const map = manifest.exports?.['.'] ?? {};
121
+ const relative_ = typeof map === 'string' ? map : (map.import ?? map.default ?? map.require);
122
+ if (!relative_) throw new Error('tailwindcss declares no importable entry point');
123
+ const entry = resolve(dirname(manifestPath), relative_);
124
+
125
+ const module = await import(pathToFileURL(entry).href);
126
+ const compile = module.compile ?? module.default?.compile;
127
+ if (typeof compile !== 'function') {
128
+ throw new Error(`tailwindcss at ${entry} exports no compile(): is it v4?`);
129
+ }
130
+ return compile;
131
+ }
132
+
133
+ async function buildCompiler(compile, entryPath) {
134
+ return compile(await readFile(entryPath, 'utf8'), {
135
+ base: dirname(entryPath),
136
+ loadStylesheet: async (id, base) => {
137
+ const path = resolveStylesheet(id, base);
138
+ return { path, base: dirname(path), content: await readFile(path, 'utf8') };
139
+ },
140
+ loadModule: async (id, base) => {
141
+ const req = createRequire(resolve(base, '_'));
142
+ const path = id.startsWith('.') ? resolve(base, id) : req.resolve(id);
143
+ return { path, base: dirname(path), module: (await import(pathToFileURL(path).href)).default };
144
+ }
145
+ });
146
+ }
147
+
148
+ async function filesUnder(dir, acc = []) {
149
+ for (const e of await readdir(dir, { withFileTypes: true })) {
150
+ const p = join(dir, e.name);
151
+ if (e.isDirectory()) await filesUnder(p, acc);
152
+ else if (/\.(svelte|js|ts)$/.test(e.name)) acc.push(p);
153
+ }
154
+ return acc;
155
+ }
156
+
157
+ /** Class-ish tokens, deliberately loose: a superset is fine, we only need some to compile. */
158
+ function candidatesIn(text) {
159
+ return new Set(
160
+ (text.match(/[a-z][a-z0-9]*(?:-[a-z0-9]+)+/g) ?? []).filter(
161
+ (c) => c.length < 40 && !c.includes('--')
162
+ )
163
+ );
164
+ }
165
+
166
+ export async function checkColourSurface({ cwd = process.cwd(), entry } = {}) {
167
+ const entryPath = resolve(cwd, entry ?? join('src', 'app.css'));
168
+ if (!existsSync(entryPath)) {
169
+ throw new Error(
170
+ `no stylesheet at ${relative(cwd, entryPath) || entryPath} — run this from the app ` +
171
+ 'directory, or pass --entry'
172
+ );
173
+ }
174
+
175
+ const failures = [];
176
+ const compiler = await buildCompiler(await loadCompiler(dirname(entryPath)), entryPath);
177
+
178
+ // 1. Registration — the @import.
179
+ const registeredCss = compiler.build(REGISTERED);
180
+ for (const utility of REGISTERED) {
181
+ const rule = registeredCss.match(new RegExp(`\\.${utility}\\s*\\{([^}]*)\\}`));
182
+ if (!rule) {
183
+ failures.push(`${utility}: no rule at all — the shadcn surface is not registered`);
184
+ } else if (!/var\(--/.test(rule[1])) {
185
+ failures.push(`${utility}: emits ${rule[1].trim()} — not resolving to a token`);
186
+ }
187
+ }
188
+
189
+ // 2. Scanning — the @source.
190
+ // Each entry is { base, pattern, negated }; the pattern is the @source argument,
191
+ // relative to the file that declared it. Removing the @source line empties this
192
+ // list entirely, which is the signal.
193
+ const scanned = (compiler.sources ?? [])
194
+ .filter((s) => !s.negated)
195
+ .map((s) => (typeof s === 'string' ? s : resolve(s.base, s.pattern)));
196
+ const packageSource = scanned.find((s) => s.includes(PACKAGE));
197
+ if (!packageSource) {
198
+ failures.push(
199
+ `no @source covers ${PACKAGE}: Tailwind never scans the shared components, so every ` +
200
+ `utility they use and this app does not compiles to nothing`
201
+ );
202
+ } else if (!existsSync(packageSource) || !statSync(packageSource).isDirectory()) {
203
+ failures.push(`the @source for ${PACKAGE} points at ${packageSource}, which is not a directory`);
204
+ } else {
205
+ const candidates = new Set();
206
+ for (const file of await filesUnder(packageSource)) {
207
+ for (const c of candidatesIn(await readFile(file, 'utf8'))) candidates.add(c);
208
+ }
209
+ const produced = compiler.build([...candidates]);
210
+ const emitted = [...candidates].filter((c) => produced.includes(`.${c}`));
211
+ if (emitted.length === 0) {
212
+ failures.push(
213
+ `${PACKAGE}'s own sources produced no utilities: the package is in the scan but ` +
214
+ `nothing it uses compiles`
215
+ );
216
+ }
217
+ }
218
+
219
+ return { entryPath, failures };
220
+ }
221
+
222
+ async function main(argv) {
223
+ const at = argv.indexOf('--entry');
224
+ const entry = at === -1 ? undefined : argv[at + 1];
225
+ if (at !== -1 && !entry) {
226
+ console.error('ds-check-colour-surface: --entry needs a path');
227
+ return 1;
228
+ }
229
+
230
+ let result;
231
+ try {
232
+ result = await checkColourSurface({ entry });
233
+ } catch (error) {
234
+ // A gate that could not run has not passed. Say which, and where it looked.
235
+ console.error(`ds-check-colour-surface: ${error.message}`);
236
+ console.error(` cwd: ${process.cwd()}`);
237
+ return 1;
238
+ }
239
+
240
+ if (result.failures.length > 0) {
241
+ console.error('\nThe shadcn colour surface does not resolve:\n');
242
+ for (const f of result.failures) console.error(` - ${f}`);
243
+ console.error(
244
+ `\n${relative(process.cwd(), result.entryPath)} must keep BOTH ` +
245
+ `\`@import '${PACKAGE}/styles.css'\` and \`@source '../node_modules/${PACKAGE}/dist'\`,\n` +
246
+ `and must not re-declare the shadcn names (--card, --muted, --accent, --popover,\n` +
247
+ `--input) as plain custom properties: that makes the variable exist without\n` +
248
+ `registering it as a theme colour.\n`
249
+ );
250
+ return 1;
251
+ }
252
+
253
+ console.log(
254
+ `colour surface: ${REGISTERED.length} semantic utilities resolve, ${PACKAGE} is scanned`
255
+ );
256
+ return 0;
257
+ }
258
+
259
+ // Importable for the test suite; only a direct CLI invocation exits.
260
+ if (process.argv[1] && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) {
261
+ process.exit(await main(process.argv.slice(2)));
262
+ }
@@ -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';
package/package.json CHANGED
@@ -1,15 +1,17 @@
1
1
  {
2
2
  "name": "@poodle64/ui",
3
- "version": "2026.9.0",
3
+ "version": "2026.9.2",
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",
7
7
  "repository": "github:poodle64/design-system",
8
8
  "publishConfig": {
9
9
  "registry": "https://registry.npmjs.org",
10
- "access": "public"
10
+ "access": "public",
11
+ "provenance": false
11
12
  },
12
13
  "files": [
14
+ "bin",
13
15
  "dist",
14
16
  "registry"
15
17
  ],
@@ -29,6 +31,9 @@
29
31
  "svelte": "./dist/components/ui/*/index.js"
30
32
  }
31
33
  },
34
+ "bin": {
35
+ "ds-check-colour-surface": "./bin/check-colour-surface.mjs"
36
+ },
32
37
  "peerDependencies": {
33
38
  "bits-ui": "^2.18.1",
34
39
  "formsnap": "^2.0.1",
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "meta": {
3
3
  "package": "@poodle64/ui",
4
- "version": "2026.9.0",
4
+ "version": "2026.9.2",
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.0`. 55 components, 15 situations.
4
+ Generated from `@poodle64/ui@2026.9.2`. 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