@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 +36 -0
- package/bin/check-colour-surface.mjs +262 -0
- 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/package.json +7 -2
- package/registry/component-map.json +9 -2
- package/registry/component-map.md +2 -1
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;
|
package/package.json
CHANGED
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@poodle64/ui",
|
|
3
|
-
"version": "2026.9.
|
|
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.
|
|
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":
|
|
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.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
|
|