ply-ui-cli 1.1.137 → 1.1.139
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/dist/commands/add.js +6 -1
- package/dist/commands/doctor.js +94 -12
- package/dist/commands/init.js +160 -44
- package/dist/commands/update.js +4 -1
- package/dist/index.js +4 -4
- package/package.json +1 -1
package/dist/commands/add.js
CHANGED
|
@@ -384,7 +384,12 @@ async function add(components, options = {}) {
|
|
|
384
384
|
for (const name of requested) {
|
|
385
385
|
const targetDir = import_path5.default.join(componentsRoot, name);
|
|
386
386
|
if (!import_fs5.default.existsSync(targetDir)) continue;
|
|
387
|
-
if (options.overwrite
|
|
387
|
+
if (options.overwrite) continue;
|
|
388
|
+
if (options.yes) {
|
|
389
|
+
toInstall.delete(name);
|
|
390
|
+
console.log(` Skipped ${name} (already exists; pass --overwrite to replace it).`);
|
|
391
|
+
continue;
|
|
392
|
+
}
|
|
388
393
|
const res = await (0, import_vendor2.prompts)({
|
|
389
394
|
type: "confirm",
|
|
390
395
|
name: "overwrite",
|
package/dist/commands/doctor.js
CHANGED
|
@@ -30,7 +30,9 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
|
|
|
30
30
|
// src/commands/doctor.ts
|
|
31
31
|
var doctor_exports = {};
|
|
32
32
|
__export(doctor_exports, {
|
|
33
|
-
|
|
33
|
+
angularMajorFromRange: () => angularMajorFromRange,
|
|
34
|
+
doctor: () => doctor,
|
|
35
|
+
nodeMeetsDocumentedBar: () => nodeMeetsDocumentedBar
|
|
34
36
|
});
|
|
35
37
|
module.exports = __toCommonJS(doctor_exports);
|
|
36
38
|
var import_fs2 = __toESM(require("fs"));
|
|
@@ -64,6 +66,12 @@ function configFilePath(cwd = process.cwd()) {
|
|
|
64
66
|
function cssImportCandidates() {
|
|
65
67
|
return [`@import './${CSS_FILE}'`, `@import './${LEGACY_CSS_FILE}'`];
|
|
66
68
|
}
|
|
69
|
+
function sourceDirectiveCovers(source, tailwindDir, targetDir) {
|
|
70
|
+
const raw = source.replace(/[*?[\]{}].*$/, "");
|
|
71
|
+
const resolved = import_path.default.resolve(tailwindDir, raw);
|
|
72
|
+
const resolvedDir = raw.endsWith("/") || raw.endsWith("\\") || import_path.default.extname(raw) === "" ? resolved : import_path.default.dirname(resolved);
|
|
73
|
+
return targetDir === resolvedDir || targetDir.startsWith(resolvedDir + import_path.default.sep);
|
|
74
|
+
}
|
|
67
75
|
|
|
68
76
|
// src/commands/doctor.ts
|
|
69
77
|
var ICON_SPRITES = ["icons.svg", "icons-filled.svg"];
|
|
@@ -103,11 +111,33 @@ function readTextSafe(filePath) {
|
|
|
103
111
|
return null;
|
|
104
112
|
}
|
|
105
113
|
}
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
const
|
|
109
|
-
|
|
110
|
-
|
|
114
|
+
var NODE_BAR = "Node.js 22.22+ or 24.15+";
|
|
115
|
+
function nodeMeetsDocumentedBar(version) {
|
|
116
|
+
const [major, minor] = version.split(".").map((part) => Number(part));
|
|
117
|
+
if (!Number.isFinite(major) || !Number.isFinite(minor)) return false;
|
|
118
|
+
if (major > 24) return true;
|
|
119
|
+
if (major === 24) return minor >= 15;
|
|
120
|
+
if (major === 22) return minor >= 22;
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
function angularMajorFromRange(range) {
|
|
124
|
+
if (!range) return null;
|
|
125
|
+
const match = range.match(/(\d+)\./);
|
|
126
|
+
return match ? Number(match[1]) : null;
|
|
127
|
+
}
|
|
128
|
+
function listedStyleFiles(angularJson) {
|
|
129
|
+
const picked = pickAngularProject(angularJson);
|
|
130
|
+
const styles = picked?.project?.architect?.build?.options?.styles || [];
|
|
131
|
+
const files = [];
|
|
132
|
+
for (const entry of styles) {
|
|
133
|
+
if (typeof entry === "string") files.push(entry);
|
|
134
|
+
else if (entry && typeof entry.input === "string") files.push(entry.input);
|
|
135
|
+
}
|
|
136
|
+
return files;
|
|
137
|
+
}
|
|
138
|
+
function stylesArrayIncludes(cwd, listed, stylesPath) {
|
|
139
|
+
const target = import_path2.default.resolve(stylesPath);
|
|
140
|
+
return listed.some((rel) => import_path2.default.resolve(cwd, rel) === target);
|
|
111
141
|
}
|
|
112
142
|
async function doctor(options = {}) {
|
|
113
143
|
const cwd = options.cwd || process.cwd();
|
|
@@ -195,6 +225,40 @@ async function doctor(options = {}) {
|
|
|
195
225
|
message: `@angular/cdk ${deps["@angular/cdk"]} installed.`
|
|
196
226
|
});
|
|
197
227
|
}
|
|
228
|
+
const angularMajor = angularMajorFromRange(deps["@angular/core"]);
|
|
229
|
+
if (angularMajor === null) {
|
|
230
|
+
checks.push({
|
|
231
|
+
name: "Angular",
|
|
232
|
+
status: "error",
|
|
233
|
+
message: "@angular/core is not installed. Ply expects Angular 22."
|
|
234
|
+
});
|
|
235
|
+
} else if (angularMajor !== 22) {
|
|
236
|
+
checks.push({
|
|
237
|
+
name: "Angular",
|
|
238
|
+
status: "error",
|
|
239
|
+
message: `@angular/core ${deps["@angular/core"]} is Angular ${angularMajor}. Ply expects Angular 22.`
|
|
240
|
+
});
|
|
241
|
+
} else {
|
|
242
|
+
checks.push({
|
|
243
|
+
name: "Angular",
|
|
244
|
+
status: "ok",
|
|
245
|
+
message: `@angular/core ${deps["@angular/core"]} is Angular 22.`
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
const nodeVersion = process.versions.node;
|
|
250
|
+
if (nodeMeetsDocumentedBar(nodeVersion)) {
|
|
251
|
+
checks.push({
|
|
252
|
+
name: "Node.js",
|
|
253
|
+
status: "ok",
|
|
254
|
+
message: `Node.js ${nodeVersion} meets ${NODE_BAR}.`
|
|
255
|
+
});
|
|
256
|
+
} else {
|
|
257
|
+
checks.push({
|
|
258
|
+
name: "Node.js",
|
|
259
|
+
status: "warn",
|
|
260
|
+
message: `Node.js ${nodeVersion} is outside ${NODE_BAR}.`
|
|
261
|
+
});
|
|
198
262
|
}
|
|
199
263
|
const tailwindPath = import_path2.default.resolve(cwd, config?.tailwind?.config || "src/tailwind.css");
|
|
200
264
|
const tailwindContent = readTextSafe(tailwindPath);
|
|
@@ -227,8 +291,8 @@ async function doctor(options = {}) {
|
|
|
227
291
|
if (!sourceMatches.length) {
|
|
228
292
|
checks.push({
|
|
229
293
|
name: "@source paths",
|
|
230
|
-
status: "
|
|
231
|
-
message: `No @source directives found in ${relative(tailwindPath)}. Components
|
|
294
|
+
status: "error",
|
|
295
|
+
message: `No @source directives found in ${relative(tailwindPath)}. Components will not be scanned for classes.`
|
|
232
296
|
});
|
|
233
297
|
} else {
|
|
234
298
|
const componentsAlias2 = config?.aliases?.components || "src/app/components";
|
|
@@ -237,13 +301,13 @@ async function doctor(options = {}) {
|
|
|
237
301
|
const hasComponentSource = sourceMatches.some((m) => {
|
|
238
302
|
const raw = m.match(/@source\s+["']([^"']+)["']/)?.[1];
|
|
239
303
|
if (!raw) return false;
|
|
240
|
-
return
|
|
304
|
+
return sourceDirectiveCovers(raw, tailwindDir, componentsDir);
|
|
241
305
|
});
|
|
242
306
|
if (!hasComponentSource) {
|
|
243
307
|
checks.push({
|
|
244
308
|
name: "@source paths",
|
|
245
|
-
status: "
|
|
246
|
-
message: `@source directives exist but none point to ${componentsAlias2}. Component classes
|
|
309
|
+
status: "error",
|
|
310
|
+
message: `@source directives exist but none point to ${componentsAlias2}. Component classes will be missing.`
|
|
247
311
|
});
|
|
248
312
|
} else {
|
|
249
313
|
checks.push({
|
|
@@ -295,6 +359,22 @@ async function doctor(options = {}) {
|
|
|
295
359
|
message: `${relative(resolvedCss)} exists.`
|
|
296
360
|
});
|
|
297
361
|
}
|
|
362
|
+
if (angularJson && pickAngularProject(angularJson)) {
|
|
363
|
+
const listed = listedStyleFiles(angularJson);
|
|
364
|
+
if (!stylesArrayIncludes(cwd, listed, stylesPath)) {
|
|
365
|
+
checks.push({
|
|
366
|
+
name: "angular.json styles",
|
|
367
|
+
status: "error",
|
|
368
|
+
message: `${relative(stylesPath)} is not in the angular.json styles array, so the global stylesheet is not built.`
|
|
369
|
+
});
|
|
370
|
+
} else {
|
|
371
|
+
checks.push({
|
|
372
|
+
name: "angular.json styles",
|
|
373
|
+
status: "ok",
|
|
374
|
+
message: `${relative(stylesPath)} is listed in angular.json styles.`
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
}
|
|
298
378
|
}
|
|
299
379
|
const assetsDir = angularJson ? resolveAssetsDir(cwd, angularJson) : import_path2.default.resolve(cwd, "src/assets");
|
|
300
380
|
const missingSprites = ICON_SPRITES.filter((s) => !import_fs2.default.existsSync(import_path2.default.join(assetsDir, s)));
|
|
@@ -353,5 +433,7 @@ function printReport(checks) {
|
|
|
353
433
|
}
|
|
354
434
|
// Annotate the CommonJS export names for ESM import in node:
|
|
355
435
|
0 && (module.exports = {
|
|
356
|
-
|
|
436
|
+
angularMajorFromRange,
|
|
437
|
+
doctor,
|
|
438
|
+
nodeMeetsDocumentedBar
|
|
357
439
|
});
|
package/dist/commands/init.js
CHANGED
|
@@ -30,7 +30,9 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
|
|
|
30
30
|
// src/commands/init.ts
|
|
31
31
|
var init_exports = {};
|
|
32
32
|
__export(init_exports, {
|
|
33
|
-
|
|
33
|
+
discoverProjectLayout: () => discoverProjectLayout,
|
|
34
|
+
init: () => init,
|
|
35
|
+
insertComponentSource: () => insertComponentSource
|
|
34
36
|
});
|
|
35
37
|
module.exports = __toCommonJS(init_exports);
|
|
36
38
|
var import_fs3 = __toESM(require("fs"));
|
|
@@ -45,6 +47,7 @@ var import_fs2 = __toESM(require("fs"));
|
|
|
45
47
|
var import_fs = __toESM(require("fs"));
|
|
46
48
|
var import_path = __toESM(require("path"));
|
|
47
49
|
var CONFIG_FILE = "ply-ui.json";
|
|
50
|
+
var LEGACY_CONFIG_FILE = "base-ui.json";
|
|
48
51
|
var CSS_FILE = "ply-ui.css";
|
|
49
52
|
var LEGACY_CSS_FILE = "base-ui.css";
|
|
50
53
|
var LOCKFILE_NAME = "ply-ui-lock.json";
|
|
@@ -62,9 +65,18 @@ function firstExisting(cwd, names) {
|
|
|
62
65
|
}
|
|
63
66
|
return null;
|
|
64
67
|
}
|
|
68
|
+
function configFilePath(cwd = process.cwd()) {
|
|
69
|
+
return firstExisting(cwd, [CONFIG_FILE, LEGACY_CONFIG_FILE]);
|
|
70
|
+
}
|
|
65
71
|
function lockfilePath(cwd = process.cwd()) {
|
|
66
72
|
return firstExisting(cwd, [LOCKFILE_NAME, LEGACY_LOCKFILE_NAME]) || import_path.default.resolve(cwd, LOCKFILE_NAME);
|
|
67
73
|
}
|
|
74
|
+
function sourceDirectiveCovers(source, tailwindDir, targetDir) {
|
|
75
|
+
const raw = source.replace(/[*?[\]{}].*$/, "");
|
|
76
|
+
const resolved = import_path.default.resolve(tailwindDir, raw);
|
|
77
|
+
const resolvedDir = raw.endsWith("/") || raw.endsWith("\\") || import_path.default.extname(raw) === "" ? resolved : import_path.default.dirname(resolved);
|
|
78
|
+
return targetDir === resolvedDir || targetDir.startsWith(resolvedDir + import_path.default.sep);
|
|
79
|
+
}
|
|
68
80
|
|
|
69
81
|
// src/lockfile.ts
|
|
70
82
|
var LOCKFILE_VERSION = 1;
|
|
@@ -87,9 +99,9 @@ function writeLockFile(lock) {
|
|
|
87
99
|
}
|
|
88
100
|
|
|
89
101
|
// src/templates/editor-kit.generated.ts
|
|
90
|
-
var CURSOR_RULE = '---\ndescription: Ply \u2014 standalone, zoneless, Tailwind v4 copy-in components\nalwaysApply: true\n---\n\n# Ply coding rules\n\nYou are generating Angular for a project that uses **Ply** at https://ply-ui.com \u2014 copy-in components, not an npm UI kit. Not MUI Base UI (React).\n\n## Install, don\'t invent\n\n- Install with `npx ply-ui-cli add <name> --yes`. Source lands in `src/app/components/<name
|
|
102
|
+
var CURSOR_RULE = '---\ndescription: Ply \u2014 standalone, zoneless, Tailwind v4 copy-in components\nalwaysApply: true\n---\n\n# Ply coding rules\n\nYou are generating Angular for a project that uses **Ply** at https://ply-ui.com \u2014 copy-in components, not an npm UI kit. Not MUI Base UI (React).\n\n## Install, don\'t invent\n\n- Install with `npx ply-ui-cli add <name> --yes`. Source lands under `aliases.components` in `ply-ui.json` (default `src/app/components/<name>/`). `--yes` skips a folder that already exists; add `--overwrite` to replace it. Humans can run `npx ply-ui-cli add` with no name to pick from the catalog. Never use `add --yes` without a name.\n- Import from that local path. Never `from \'base-ui\'` / `@base-ui/*`.\n- Prefer an existing registry item over a custom control. Check `npx ply-ui-cli list`, https://ply-ui.com/llms.txt, or MCP (`npx -y ply-ui-mcp`) before building a dialog, table, combobox, or form control.\n- If components look unstyled or icons are missing, run `npx ply-ui-cli doctor` (read-only). It checks `ply-ui.json`, Angular CDK, Tailwind v4 `@source` paths, `ply-ui.css`, and icon sprites.\n- Free items need no license. Pro items need `PLY_LICENSE_KEY` \u2014 do not scrape keys or paste Pro source from memory.\n\n## Angular 22 shape\n\n- New components: `standalone: true`, `changeDetection: ChangeDetectionStrategy.OnPush`.\n- Public APIs: `input()`, `output()`, `model()` \u2014 not `@Input()` / `@Output()`.\n- Templates: `@if` / `@for` / `@empty` \u2014 not `*ngIf` / `*ngFor`.\n- Prefer zoneless. Do not add `zone.js`. After async work, write to signals (or signal-backed CVA) so the view updates without Zone.\n- App selectors stay `app-`. Only Ply selectors use the `ply-` prefix.\n\n## Selectors and forms\n\n- Components: `<ply-card>`, `<ply-dialog>` is not a tag \u2014 open modals with `inject(DialogService).open(MyComponent)`.\n- Directives: `[ply-button]`, `[ply-stroked-button]`, `[ply-input]`, `[ply-textarea]`.\n- Native fields: `<input ply-input [(ngModel)]="\u2026">` or Angular 22 `[formField]` + `ply-input`.\n- `data-table` cells: project `<ng-template plyTableCell="columnKey" let-row let-value="value">`. Combobox options: `plyComboboxOption`. Custom select: `plySelectOption`. Do not fork those components to render a badge or a row action.\n- Chrome strings (empty states, Close, paginator): `provideBaseUiI18n({ close: \'Fermer\' })` in `app.config.ts`. Per-instance `emptyMessage` / `emptyText` still override. Not ngx-translate.\n- Icon sprites: `provideBaseUI({ iconPath: \'assets/icons.svg\' })` in `app.config.ts`. Per-instance `[path]` / `[filledPath]` / `[size]` still win. `w-*`/`h-*` classes keep class-based sizing.\n- Tests: use the copied `*.harness.ts` for data-table, custom-select, and dialog. `TestbedHarnessEnvironment.loader(fixture)`; dialogs need `documentRootLoader` (overlay on `document.body`). Do not invent query helpers for those three.\n- CVA hosts (`ply-combobox`, `ply-tags-input`, `ply-toggle`, `ply-custom-select`, `ply-file-upload`, `ply-mention-input`, `ply-password-input`) bind `[(ngModel)]` / `[formField]` on the host, not on an inner native input.\n- Do not nest composite controls inside `ply-input-group` \u2014 only `[ply-input]`, `[ply-textarea]`, and `ply-mention-input` are projected. Use `ply-password-input` for password fields (it already includes a group).\n- Confirm destructive actions with `inject(DialogService).confirm({ title, destructive: true })` \u2014 do not copy a one-off confirm dialog.\n\n## Tailwind v4\n\n- Style with utilities. Pair light and `dark:` variants (`bg-white dark:bg-slate-800`). Dark mode is the `dark` class on `<html>`.\n- Spacing uses the standard scale (`p-4`, `gap-2`, `w-64`). Do not use `[Npx]` for padding, margin, or gap. A fixed control size that is not on the scale (a drawer width) may stay a pixel value.\n- `color="primary"` reads `--ply-primary` from `ply-ui.css`. That file aliases `--color-blue-*` to the same scale. Theme Studio sets `--ply-brand` and `--ply-scale-*`. Live preview: https://ply-ui.com/theme/.\n- Button and input `class` values merge via `cn()`. `class="rounded-full"` and `class="absolute \u2026"` override defaults. Existing `utility!` still works.\n- `<ply-icon>` outlines: `stroke-*`. Filled icons: `[filled]="true"` and `text-*` / `fill-*`.\n\n## Writing direction (RTL)\n\n- Set `<html lang="ar" dir="rtl">`. Switch at runtime with `inject(DirectionRegistry).setDocumentDirection(\'rtl\')`. A section can use `plyDir="rtl"` or `plyDir="auto"` (Arabic, Hebrew, Persian, Urdu, and the other RTL locales). Guide: https://ply-ui.com/configuration/#rtl\n- Install: `npx ply-ui-cli add direction --yes`. Tabs, drawer, icon, shell, and the other components that follow direction already depend on it.\n- Imports: `PlyDirDirective` from `./components/direction/ply-dir.directive`, `DirectionRegistry` from `./components/direction/direction.service`.\n- New layout uses logical utilities: `ms-*` / `me-*`, `ps-*` / `pe-*`, `start-*` / `end-*`, `text-start` / `text-end`, `border-s` / `border-e`, `rounded-s` / `rounded-e`. Leave `left-1/2` + `-translate-x-1/2` centering alone.\n- `left` and `right` stay on those physical edges. `start` and `end` follow `dir`. Drawer default is `end` (physical right in LTR). Toast default is `top-end`. Scroll-to-top and speed dial default to `bottom-end`. The shell `left` slot is the reading-start edge.\n- ArrowRight moves to the next item in LTR and the previous item in RTL. Trees and submenus open on the forward arrow (ArrowRight in LTR, ArrowLeft in RTL).\n- `ply-icon` names that contain `left` or `right` mirror under `dir="rtl"` when `mirror` is `auto` (the default). `mirror="off"` keeps a physical arrow. Charts, knobs, and rich-text align left/center/right stay physical.\n\n## Async, overlays, SSR\n\n- `DialogService.open()` is a no-op during SSR. `DialogService.confirm()` emits `false` on the server. Toast and dialog hosts append via `DOCUMENT`.\n- Drawers need `provideAnimationsAsync()`.\n- Close popovers/drawers yourself on in-panel navigation (`popover.close()`, `drawer.closed.emit()`).\n- Overlay menus (dropdown, context) use `a11y-keyboard` (roving tabindex, Home/End, typeahead). Combobox, custom-select, and command-palette use `aria-activedescendant` \u2014 do not switch those to the menu helper.\n- Do not put `ply-toast-container` in templates; the toast service owns the overlay.\n\n## Agents\n\n- MCP optional: `.cursor/mcp.json` \u2192 `npx -y ply-ui-mcp` with `PLY_CWD` = this app root.\n- Facts: https://ply-ui.com/llms.txt \xB7 catalog: https://ply-ui.com/docs/ai/components.md\n- UI design skill: https://ply-ui.com/docs/ai/skills/ply-ui-design/SKILL.md \u2014 copy to `.cursor/skills/ply-ui-design/` (`init` writes it when missing). Walkthrough: https://ply-ui.com/learn/ply-ui-design-skill/.\n- Usage snippets (VS Code / Cursor): `.vscode/ply-ui.code-snippets` \u2014 expand prefixes like `ply-card` after the CLI has copied the component in.\n';
|
|
91
103
|
var CODE_SNIPPETS = '{\n "Ply table": {\n "prefix": "ply-table",\n "scope": "html",\n "body": [\n "<div class=\\"relative w-full overflow-auto\\">",\n " <table ply-table>",\n " <caption ply-table-caption>${1:A list of recent invoices.}</caption>",\n " <thead ply-table-header>",\n " <tr ply-table-row>",\n " <th ply-table-head>${2:Invoice}</th>",\n " <th ply-table-head>${3:Status}</th>",\n " </tr>",\n " </thead>",\n " <tbody ply-table-body>",\n " <tr ply-table-row>",\n " <td ply-table-cell>$0</td>",\n " <td ply-table-cell></td>",\n " </tr>",\n " </tbody>",\n " </table>",\n "</div>"\n ],\n "description": "Ply free HTML table. Install: npx ply-ui-cli add table --yes"\n },\n "Ply card": {\n "prefix": "ply-card",\n "scope": "html",\n "body": [\n "<ply-card>",\n " <ply-card-header class=\\"flex items-center justify-between gap-[8px]\\">",\n " <h2 class=\\"m-0! text-base font-semibold text-slate-900 dark:text-white\\">${1:Title}</h2>",\n " </ply-card-header>",\n " <ply-card-body class=\\"p-[16px]\\">",\n " $0",\n " </ply-card-body>",\n "</ply-card>"\n ],\n "description": "Ply card usage. Install: npx ply-ui-cli add card --yes"\n },\n "Ply button": {\n "prefix": "ply-button",\n "scope": "html",\n "body": [\n "<button type=\\"button\\" ply-button color=\\"${1:primary}\\">${2:Save}</button>"\n ],\n "description": "Ply filled button directive. Install: npx ply-ui-cli add button --yes"\n },\n "Ply stroked button": {\n "prefix": "ply-stroked-button",\n "scope": "html",\n "body": [\n "<button type=\\"button\\" ply-stroked-button color=\\"${1:default}\\">${2:Cancel}</button>"\n ],\n "description": "Ply stroked button directive. Install: npx ply-ui-cli add button --yes"\n },\n "Ply dialog open": {\n "prefix": "ply-dialog",\n "scope": "typescript",\n "body": [\n "this.dialog.open(${1:MyDialogComponent}).subscribe((result) => {",\n " if (!result) return;",\n " $0",\n "});"\n ],\n "description": "Open a Base UI dialog. Install: npx ply-ui-cli add dialog --yes"\n },\n "Ply dialog confirm": {\n "prefix": "ply-confirm",\n "scope": "typescript",\n "body": [\n "this.dialog.confirm({",\n " title: \'${1:Delete this item?}\',",\n " description: \'${2:This cannot be undone.}\',",\n " destructive: true",\n "}).subscribe((ok) => {",\n " if (!ok) return;",\n " $0",\n "});"\n ],\n "description": "Confirm dialog. Install: npx ply-ui-cli add dialog --yes"\n },\n "Ply password input": {\n "prefix": "ply-password",\n "scope": "html",\n "body": [\n "<ply-password-input label=\\"${1:Password}\\" [(ngModel)]=\\"${2:password}\\" [showStrength]=\\"true\\">",\n " <ply-error>${3:Password is required}</ply-error>",\n "</ply-password-input>"\n ],\n "description": "Password field with show/hide. Install: npx ply-ui-cli add password-input --yes"\n },\n "Ply input group": {\n "prefix": "ply-input",\n "scope": "html",\n "body": [\n "<ply-input-group>",\n " <ply-label>${1:Label}</ply-label>",\n " <input ply-input [(ngModel)]=\\"${2:value}\\" autocomplete=\\"off\\" />",\n "</ply-input-group>"\n ],\n "description": "Native input with ply-input. Install: npx ply-ui-cli add input --yes"\n },\n "Ply textarea": {\n "prefix": "ply-textarea",\n "scope": "html",\n "body": [\n "<ply-input-group>",\n " <ply-label>${1:Notes}</ply-label>",\n " <textarea ply-textarea rows=\\"4\\" [(ngModel)]=\\"${2:notes}\\"></textarea>",\n "</ply-input-group>"\n ],\n "description": "Native textarea with ply-textarea. Install: npx ply-ui-cli add input --yes"\n },\n "Ply combobox": {\n "prefix": "ply-combobox",\n "scope": "html",\n "body": [\n "<ply-combobox",\n " label=\\"${1:Choice}\\"",\n " [options]=\\"${2:options}\\"",\n " [(ngModel)]=\\"${3:value}\\">",\n " <ng-template plyComboboxOption let-option>",\n " $0",\n " </ng-template>",\n "</ply-combobox>"\n ],\n "description": "Searchable select with optional option template. Bind ngModel on the host. Install: npx ply-ui-cli add combobox --yes"\n },\n "Ply select": {\n "prefix": "ply-select",\n "scope": "html",\n "body": [\n "<ply-custom-select",\n " label=\\"${1:Choice}\\"",\n " [options]=\\"${2:options}\\"",\n " [(ngModel)]=\\"${3:value}\\">",\n " <ng-template plySelectOption let-label=\\"label\\">",\n " $0",\n " </ng-template>",\n "</ply-custom-select>"\n ],\n "description": "Custom select CVA host with optional option template. Install: npx ply-ui-cli add custom-select --yes"\n },\n "Ply toggle": {\n "prefix": "ply-toggle",\n "scope": "html",\n "body": [\n "<div class=\\"flex items-center justify-between gap-[12px] rounded-lg border border-slate-200 px-[16px] py-[12px] dark:border-slate-700\\">",\n " <div>",\n " <p class=\\"m-0! text-sm font-medium text-slate-900 dark:text-white\\">${1:Product emails}</p>",\n " <p class=\\"mb-0! mt-[2px] text-xs text-slate-500\\">${2:Occasional tips.}</p>",\n " </div>",\n " <ply-toggle [(ngModel)]=\\"${3:enabled}\\"></ply-toggle>",\n "</div>"\n ],\n "description": "Toggle CVA host. Install: npx ply-ui-cli add toggle --yes"\n },\n "Ply tags input": {\n "prefix": "ply-tags-input",\n "scope": "html",\n "body": [\n "<ply-label>${1:Tags}</ply-label>",\n "<ply-tags-input class=\\"mt-[4px]\\" [(ngModel)]=\\"${2:tags}\\" placeholder=\\"${3:Add a tag\u2026}\\"></ply-tags-input>"\n ],\n "description": "Chip tags CVA host. Install: npx ply-ui-cli add tags-input --yes"\n },\n "Ply checkbox": {\n "prefix": "ply-checkbox",\n "scope": "html",\n "body": [\n "<ply-checkbox [(ngModel)]=\\"${1:checked}\\">${2:Remember me}</ply-checkbox>"\n ],\n "description": "Checkbox. Install: npx ply-ui-cli add checkbox --yes"\n },\n "Ply chat": {\n "prefix": "ply-chat",\n "scope": "html",\n "body": [\n "<ply-chat",\n " [messages]=\\"${1:messages}()\\"",\n " [streaming]=\\"${2:streaming}()\\"",\n " placeholder=\\"${3:Ask\u2026}\\"",\n " (send)=\\"${4:onSend}($event)\\"",\n " (stop)=\\"${5:stop}()\\">",\n "</ply-chat>"\n ],\n "description": "AI chat kit. Install: npx ply-ui-cli add chat --yes"\n },\n "Ply data table": {\n "prefix": "ply-data-table",\n "scope": "html",\n "body": [\n "<ply-data-table",\n " [columns]=\\"${1:columns}\\"",\n " [data]=\\"${2:rows}\\"",\n " [loading]=\\"${3:loading}()\\"",\n " serverSide",\n " sortable",\n " pageable",\n " selectable",\n " bordered",\n " hoverable",\n " [pageSize]=\\"${4:10}\\"",\n " [totalItems]=\\"${5:total}\\"",\n " [(selected)]=\\"${6:selected}\\"",\n " (pageChange)=\\"${7:fetch}($event)\\"",\n " (sortChange)=\\"${8:onSort}($event)\\">",\n " <ng-template plyTableCell=\\"${9:status}\\" let-value=\\"value\\">",\n " $0",\n " </ng-template>",\n "</ply-data-table>"\n ],\n "description": "Pro data table with optional cell templates. Install: npx ply-ui-cli add data-table --yes"\n },\n "Ply tabs": {\n "prefix": "ply-tabs",\n "scope": "html",\n "body": [\n "<ply-tabs [defaultTab]=\\"${1:0}\\">",\n " <ply-tab label=\\"${2:Overview}\\"><ply-tab-body>$0</ply-tab-body></ply-tab>",\n " <ply-tab label=\\"${3:Activity}\\"><ply-tab-body></ply-tab-body></ply-tab>",\n "</ply-tabs>"\n ],\n "description": "Tabs. Install: npx ply-ui-cli add tabs --yes"\n },\n "Ply alert": {\n "prefix": "ply-alert",\n "scope": "html",\n "body": [\n "<ply-alert color=\\"${1:info}\\">${2:Saved.}</ply-alert>"\n ],\n "description": "Alert. Install: npx ply-ui-cli add alert --yes"\n },\n "Ply badge": {\n "prefix": "ply-badge",\n "scope": "html",\n "body": [\n "<ply-badge color=\\"${1:primary-light}\\" size=\\"sm\\">${2:New}</ply-badge>"\n ],\n "description": "Badge. Install: npx ply-ui-cli add badge --yes"\n },\n "Ply skeleton": {\n "prefix": "ply-skeleton",\n "scope": "html",\n "body": [\n "<ply-skeleton variant=\\"${1:rectangular}\\" width=\\"${2:100%}\\" height=\\"${3:16rem}\\"></ply-skeleton>"\n ],\n "description": "Skeleton placeholder. Install: npx ply-ui-cli add skeleton --yes"\n },\n "Ply empty state": {\n "prefix": "ply-empty-state",\n "scope": "html",\n "body": [\n "<ply-empty-state title=\\"${1:Nothing here}\\" description=\\"${2:Try a different filter.}\\"></ply-empty-state>"\n ],\n "description": "Empty state. Install: npx ply-ui-cli add empty-state --yes"\n },\n "Ply toast": {\n "prefix": "ply-toast",\n "scope": "typescript",\n "body": [\n "this.toast.success(\'${1:Saved}\', { duration: ${2:2500} });"\n ],\n "description": "Toast via ToastService. Install: npx ply-ui-cli add toast --yes. Do not add ply-toast-container to templates."\n },\n "Ply icon": {\n "prefix": "ply-icon",\n "scope": "html",\n "body": [\n "<ply-icon name=\\"${1:chevron-right}\\" class=\\"h-4 w-4 stroke-slate-500 dark:stroke-slate-300\\"></ply-icon>"\n ],\n "description": "Outline icon (stroke-*). Filled: [filled]=\\"true\\" + text-*. Install: npx ply-ui-cli add icon --yes"\n },\n "Ply standalone component": {\n "prefix": "ply-standalone",\n "scope": "typescript",\n "body": [\n "import { ChangeDetectionStrategy, Component, input, output } from \'@angular/core\';",\n "",\n "@Component({",\n " selector: \'app-${1:feature}\',",\n " changeDetection: ChangeDetectionStrategy.OnPush,",\n " template: `",\n " <ply-card>",\n " <ply-card-body class=\\"p-[16px]\\">$0</ply-card-body>",\n " </ply-card>",\n " `,",\n "})",\n "export class ${2:Feature}Component {",\n " readonly title = input(\'${3:Title}\');",\n " readonly saved = output<void>();",\n "}"\n ],\n "description": "Standalone OnPush component with signal input/output and a Ply card"\n },\n "Ply i18n": {\n "prefix": "ply-i18n",\n "scope": "typescript",\n "body": [\n "provideBaseUiI18n({",\n " close: \'${1:Fermer}\',",\n " noDataAvailable: \'${2:Aucune donn\xE9e}\',",\n " noResults: \'${3:Aucun r\xE9sultat}\'",\n "})"\n ],\n "description": "Chrome-string dictionary for empty states, paginator, and dialog close. Install: npx ply-ui-cli add i18n --yes"\n },\n "Ply config": {\n "prefix": "ply-config",\n "scope": "typescript",\n "body": [\n "provideBaseUI({",\n " iconPath: \'${1:assets/icons.svg}\',",\n " filledIconPath: \'${2:assets/icons-filled.svg}\',",\n " defaultSize: ${3:20}",\n "})"\n ],\n "description": "Library defaults for icon sprites and default icon size. Install: npx ply-ui-cli add config --yes"\n },\n "Ply harness": {\n "prefix": "ply-harness",\n "scope": "typescript",\n "body": [\n "import { TestbedHarnessEnvironment } from \'@angular/cdk/testing/testbed\';",\n "import { DataTableHarness } from \'./components/data-table/data-table.harness\';",\n "import { DialogHarness } from \'./components/dialog/dialog.harness\';",\n "",\n "const loader = TestbedHarnessEnvironment.loader(fixture);",\n "const table = await loader.getHarness(DataTableHarness);",\n "expect(await table.getRowCount()).toBe(${1:3});",\n "",\n "const dialogs = TestbedHarnessEnvironment.documentRootLoader(fixture);",\n "const dialog = await dialogs.getHarness(DialogHarness);"\n ],\n "description": "CDK ComponentHarness for copied Base UI data-table. Dialog overlays use documentRootLoader. Copied with the component \u2014 not a separate registry item."\n },\n "Ply RTL": {\n "prefix": "ply-rtl",\n "scope": "html",\n "body": [\n "<html lang=\\"${1:ar}\\" dir=\\"rtl\\">",\n " $0",\n "</html>"\n ],\n "description": "Document-level RTL. Ply follows the nearest dir. Install: npx ply-ui-cli add direction --yes. See https://ply-ui.com/configuration/#rtl"\n },\n "Ply dir": {\n "prefix": "ply-dir",\n "scope": "html",\n "body": [\n "<section plyDir=\\"${1|rtl,ltr,auto|}\\">",\n " $0",\n "</section>"\n ],\n "description": "Subtree writing direction. auto follows the document language. Import PlyDirDirective from ./components/direction/ply-dir.directive"\n }\n}\n';
|
|
92
|
-
var DESIGN_SKILL_FILES = { "SKILL.md": '---\nname: ply-ui-design\ndescription: Design and implement Angular 22 + Tailwind v4 UI that matches Ply\'s primitives (ply-button, ply-card, ply-input-group, DialogService, ply-data-table, ply-skeleton, ply-empty-state, ToastService). Covers aesthetic direction, structural foundations, accessibility states, motion, and full operational-state coverage (loading, empty, error, disabled). Use when designing, building, styling, polishing, or reviewing any component, page, layout, dashboard, form, table, dialog, or overlay in ply-theme, and when the user mentions UI design, design system, visual polish, skeletons, empty states, error states, or motion.\n---\n\n# Ply UI Design \u2014 Angular + Tailwind v4\n\nGoverns `projects/base/` (library) and `src/app/` (demo). Precedence: this skill \u2192 `AGENTS.md` / `CLAUDE.md` \u2192 Tailwind defaults. Never fork or restyle a primitive from the inside; extend it through its `class` input (merged with `cn()`).\n\n## Workflow (every UI task)\n\n```\n- [ ] 1. Reuse first: find an existing primitive/block (`ls projects/base/src/lib/components`, `docs/ai/components.md`).\n- [ ] 2. Verify every `ply-icon` name exists in `src/assets/icons.svg`.\n- [ ] 3. Build the resting state from the recipes in \xA70 \u2014 no new colors, radii, or shadows.\n- [ ] 4. Add loading, empty, error, and disabled states (\xA74) in the same frame as the content.\n- [ ] 5. Check hover / active / focus-visible / dark mode / RTL / reduced motion.\n- [ ] 6. Run the Delivery checklist at the end of this file.\n```\n\n## 0. Ground truth \u2014 tokens and primitive recipes\n\n### Tokens (`src/assets/scss/index.scss`; consumers get the same block as `ply-ui.css`)\n\n| Token | Light | Dark | Use |\n|---|---|---|---|\n| `--ply-background` / `--ply-foreground` | `#ffffff` / `#0f172a` | `#0f172a` / `#f8fafc` | page canvas, ring offset |\n| `--ply-primary` (`-hover`, `-active`, `-foreground`) | `#2563eb` / `#1d4ed8` / `#1e40af` / white | same | the one accent |\n| `--ply-primary-soft` | 14% primary mix | 22% | selected rows, active options |\n| `--ply-muted` / `--ply-muted-foreground` | `#e2e8f0` / `#334155` | `#1e293b` / `#e2e8f0` | default buttons, quiet fills |\n| `--ply-destructive` (`-hover`, `-foreground`) | `#dc2626` / `#b91c1c` / white | same | danger buttons only |\n| `--ply-border` | `#cbd5e1` | `#334155` | stroked buttons, `ply-table` |\n| `--ply-ring` | `= --ply-primary` | same | focus rings |\n| `--ply-radius` | `0.5rem` | same | every control |\n\n`--color-blue-50\u2026950` are aliased to the brand scale, so `bg-blue-600` / `text-blue-600` follow the theme. Theme Studio overrides `--ply-brand` and `--ply-scale-*`; never hardcode hex.\n\n### Primitive recipes (the exact classes the library uses \u2014 match them)\n\n| Element | Classes |\n|---|---|\n| Control radius | `rounded-[var(--ply-radius)]` (buttons, inputs, selects) |\n| Surface radius | `rounded-xl` (card, dialog, alert, toast, popover, table wrapper); `rounded-md` (dropdown menu, tooltip, kbd, rectangular badge); `rounded-3xl` empty-state; `rounded-t-2xl` bottom sheet; `rounded-full` chips, avatars, pills, dots only |\n| Card | `bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700`, no shadow \u2014 `ply-card-header` is a fixed `h-14 px-4 font-semibold border-b` bar (pass `class="h-auto py-4"` for stacked titles), `ply-card-body` defaults to `p-4` (use `class="p-6"` for content cards), `ply-card-footer` `flex p-4 border-t` |\n| Overlay panels | dropdown `bg-white dark:bg-slate-800 rounded-md shadow-xl border border-slate-200 dark:border-slate-700 min-w-40` with items `h-12 px-6 text-sm text-slate-600 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors duration-200` \xB7 popover `rounded-xl border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 p-4 shadow-2xl` \xB7 tooltip `rounded-md bg-slate-900 text-white text-sm px-2 py-1 max-w-xs shadow-lg` \xB7 option lists (`ply-custom-select`, `ply-combobox`) `rounded-[var(--ply-radius)] border border-[var(--ply-border)] bg-[var(--ply-background)] shadow-lg max-h-60 overflow-auto`; `ply-multi-select` / `ply-select-tree` use `rounded-xl border-slate-200 dark:border-slate-700 shadow-lg`. `ply-select` is a native `<select>` \u2014 no panel |\n| Page / well | `bg-slate-50 dark:bg-slate-900` |\n| Raised / hover / skeleton fill | `bg-slate-100 dark:bg-slate-700` \xB7 hover rows `hover:bg-slate-50 dark:hover:bg-slate-800` |\n| Hairline | `border-slate-200 dark:border-slate-700` or `border-[var(--ply-border)]` |\n| Button base | `flex items-center gap-2 rounded-[var(--ply-radius)] font-medium tracking-wide transition-all duration-200 disabled:opacity-50 disabled:pointer-events-none` + `FOCUS_RING` |\n| Button / control heights | `sm` h-7 \xB7 `default` h-9 \xB7 `lg` h-10 \xB7 `xl` h-11 \xB7 `xxl` h-14 \u2014 bare `<input>` without `ply-input` must set `h-9`. Buttons in one row share a single `size`: all `default` or all `lg` (or all `sm`). Never put `size="lg"` next to a default-size sibling |\n| Input | `[ply-input]` `h-9 px-4 text-sm text-slate-700 dark:text-slate-200 bg-transparent`; the `ply-input-group` shell owns the chrome: `min-h-9 border border-[var(--ply-border)] rounded-[var(--ply-radius)] bg-[var(--ply-background)] shadow-sm` + `FOCUS_RING_WITHIN`, turning `border-red-500!` when the projected control is touched and invalid |\n| Label / error / hint | `ply-label` `text-sm font-medium text-slate-700 dark:text-slate-300` (auto-linked `for`) \xB7 `ply-error` `text-xs text-red-500 mt-1` (rendered by the group only when invalid; `aria-invalid` / `aria-describedby` set automatically) \xB7 `ply-info-text` `text-xs text-slate-500 dark:text-slate-400 mt-1` |\n| Typography directive | `[plyTypography]` on any element: `display` `text-4xl font-bold tracking-tight sm:text-5xl` \xB7 `title` `text-3xl font-bold tracking-tight` \xB7 `heading` `text-2xl font-semibold tracking-tight` \xB7 `subheading` `text-lg font-medium` \xB7 `body` `text-base text-slate-600 dark:text-slate-300` \xB7 `caption` `text-sm text-slate-500 dark:text-slate-400` \xB7 `overline` `text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400` |\n| Dialog | backdrop `bg-slate-900/50 dark:bg-slate-900/80 backdrop-blur-sm` \xB7 box `bg-white dark:bg-slate-800 rounded-xl shadow` |\n| Toast | `rounded-xl shadow-xl border bg-white dark:bg-slate-800 text-sm font-medium` |\n| Table header | `py-3 px-4 text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400` on `bg-slate-50 dark:bg-slate-800/50` |\n| Table row / cell | row `transition-colors duration-150 hover:bg-slate-50 dark:hover:bg-slate-800/30` \xB7 cell `py-3 px-4 text-sm text-slate-700 dark:text-slate-300` |\n| Eyebrow / metadata | `text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400` |\n| Metric | `text-[30px] font-black leading-none tracking-tight tabular-nums text-slate-900 dark:text-white` |\n| Selected / active option | `PRIMARY_SOFT` \u2192 `bg-[var(--ply-primary-soft)] text-[var(--ply-primary)]` |\n| Focus | import `FOCUS_RING`, `FOCUS_RING_INSET`, `FOCUS_RING_WITHIN` from `utils/tw-merge` \u2014 never hand-roll rings |\n| Layering | `Z_SCALE` from `utils/z-index`: sticky 50 \xB7 overlay 1000 \xB7 popover 1100 \xB7 toast 1200 |\n\n### Reuse map \u2014 do not rebuild these\n\n| Need | Use |\n|---|---|\n| Modal | `inject(DialogService).open(MyComponent, data)` with `ply-dialog` + `ply-dialog-header/body/footer`, `[ply-dialog-close]` |\n| Confirm / destructive | `DialogService.confirm({ title, description, destructive: true })` |\n| Notification | `ToastService.success/error/warning/info/promise()` \u2014 never place `ply-toast-container` in a template |\n| Side panel / mobile sheet | `ply-drawer` (`[ply-drawer]` trigger, needs `provideAnimationsAsync()`) \xB7 `ply-bottom-sheet` |\n| Table | `ply-data-table` (`loading`, `emptyMessage`, `plyTableCell`, `plyTableEmpty`) or `table[ply-table]` primitives |\n| Placeholder while loading | `ply-skeleton` \xB7 `ply-data-table [loading]` \xB7 `ply-loading-overlay` \xB7 `ply-progress-button [loading]` |\n| Nothing to show | `ply-empty-state` + projected CTA \xB7 `@for \u2026 @empty` |\n| Inline status | `ply-alert` with `color` = `danger` / `warning` / `success` / `primary`, actions in `ply-alert-actions` |\n| KPI / number | `ply-stat-card` (`variant="metric"`), `ply-animated-counter`, `ply-progress`, `ply-meter-group` |\n| Form field | `ply-input-group` > `ply-label` + `<input ply-input>` + `ply-error` / `ply-info-text`; composite controls (`ply-custom-select`, `ply-multi-select`, `ply-phone-input`, `ply-currency-input`, \u2026) sit as siblings of `ply-label`, never inside the group |\n\n### Non-negotiable project rules (summary of `AGENTS.md` / `CLAUDE.md`)\n\n- Standalone, `ChangeDetectionStrategy.OnPush`, `input()` / `output()` / `model()`, `@if` / `@for` / `@switch` / `@empty`. Library selectors `ply-*`; demo app selectors `app-*`.\n- Zoneless: any state written outside a template event handler (timers, observables, resize, IntersectionObserver) must be a `signal`. Timers via `injectTimers()`.\n- Always pair light and dark: `bg-white dark:bg-slate-800`, `text-slate-900 dark:text-white`.\n- `ply-icon` outlines are colored with `stroke-*`, never `text-*`; `[filled]="true"` icons use `fill-*`. Verify the name in `src/assets/icons.svg`.\n- Reset heading / paragraph margins with `m-0!` / `mt-0!` (Tailwind v4 suffix, not `!m-0`).\n- Spacing: standard scale only (`p-4`, `gap-2`, `mb-6`). No `[Npx]` values in this repo \u2014 the demo density customizer scales `--spacing`, and arbitrary pixels ignore it.\n- RTL: logical utilities for new layout (`ms-*`, `me-*`, `ps-*`, `pe-*`, `start-*`, `end-*`, `text-start`, `text-end`).\n- Overlays trap focus (CDK `FocusTrapFactory` or `cdkTrapFocus cdkTrapFocusAutoCapture`), close on Escape, and are SSR-safe (`isPlatformBrowser`).\n\n## 1. Aesthetic Direction & Uniqueness (The Look)\n\n- **Anti-Patterns (Forbidden):** No generic rounded-full buttons, random multi-color glowing blobs, or monotonous card grids. Every element must feel intentional.\n - Ply: buttons keep `rounded-[var(--ply-radius)]`; `rounded-full` is reserved for `ply-chip`, avatars, status dots, and delta pills. Break card grids with a `ply-stat-card variant="metric"` row, a full-width `ply-data-table`, or a 2:1 `grid-cols-[2fr_1fr]` split \u2014 not more identical cards.\n- **Surface & Depth:** Use layered tonal contrasts and razor-thin borders (`border-white/[0.08]`) rather than heavy drop shadows or default gray lines.\n - Ply: depth is tonal \u2014 page `bg-slate-50 dark:bg-slate-900` \u2192 surface `bg-white dark:bg-slate-800` \u2192 raised `bg-slate-100 dark:bg-slate-700`. The 1px hairline is `border-slate-200 dark:border-slate-700`; use `border-white/[0.08]`\u2013`/10` only on ink, image, or `bg-slate-900` surfaces. Cards have no shadow; inputs carry `shadow-sm`. The shadow ladder is reserved for floating layers: `shadow-lg` (tooltip, option lists), `shadow-xl` (dropdown menu, toast), `shadow-2xl` (popover, bottom sheet).\n- **Color Discipline:** Strict monochromatic foundation with **one** high-intent, high-saturation accent color used exclusively for key interactive states or active data points.\n - Ply: the neutral is `slate` \u2014 never mix in `gray`, `zinc`, or `neutral`. The accent is `--ply-primary` (`bg-[var(--ply-primary)]`, `text-blue-600 dark:text-blue-400`, `PRIMARY_SOFT`). Do not introduce indigo, violet, sky, or gradients as accents. `green` / `orange` / `red` (`--ply-destructive`) / `purple` are semantic (success / warning / danger / accent) and appear only where they carry meaning \u2014 status badges, alerts, deltas, destructive actions.\n- **Typographic Hierarchy:** High weight contrast, tight tracking on headers (`tracking-tight`), and microscopic uppercase tracking for metadata/tags (`text-[10px] uppercase tracking-widest`).\n - Ply: headings `font-bold tracking-tight text-slate-900 dark:text-white` (page `text-3xl`, card `text-xl`, dialog header `text-lg`) or the `plyTypography` variants (`title`, `heading`, `subheading`); body `text-sm text-slate-700 dark:text-slate-300`; muted `text-slate-500 dark:text-slate-400`. Eyebrows: `plyTypography="overline"` for section labels, `text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400` in data widgets, `<ply-label class="text-[10px]! uppercase font-light! tracking-[2px]!">` in form blocks, `text-xs font-semibold uppercase tracking-wider` in table heads. Pick one eyebrow recipe per screen. Big numbers: `text-[30px] font-black leading-none tracking-tight tabular-nums`.\n\n## 2. Technical & Structural Foundations (The Code)\n\n- **Mobile-First Responsiveness:** Always design compact layouts first, scaling up cleanly using explicit breakpoint modifiers (`sm:`, `md:`, `lg:`).\n - Ply: page container `mx-auto w-full max-w-7xl px-4 sm:px-6 lg:px-8`; stack \u2192 `sm:grid-cols-2 lg:grid-cols-4`; dialogs are already capped at `max-w-[calc(100%-32px)]`; drawers cap at `max-w-[92vw]`. Use `CurrentScreenSizeService` only when layout cannot be expressed in classes.\n- **Layout Architecture:** Prefer CSS Grid for macro-layout structures and Flexbox for fine-grained component alignment. Avoid arbitrary pixel values\u2014stick to a strict spacing rhythm (e.g., Tailwind 4, 8, 12, 16, 24, 32).\n - Ply: `gap-1` inline, `gap-2` control groups, `gap-3` list rows, `gap-4` card grids, `gap-6` sections, `space-y-6` / `mb-8` between page blocks. Card body `p-6`, dense panels `p-4`, table cells `py-3 px-4`. Fixed sizes come from the scale (`w-64`, `h-12`, `size-10`), never `[Npx]`.\n- **Accessibility & States:**\n - Every interactive element (buttons, links, toggles) must feature explicit hover, active, focus, and disabled states.\n - Maintain WCAG AA compliant text-to-background contrast ratios.\n - Ply: hover `hover:bg-slate-50 dark:hover:bg-slate-800`, active `active:bg-slate-100 dark:active:bg-slate-700`, focus `FOCUS_RING` (`focus-visible:` only \u2014 no rings on mouse click), disabled `disabled:opacity-50 disabled:pointer-events-none disabled:cursor-not-allowed`. Icon-only buttons need `aria-label`. `text-slate-400` on white is about 2.6:1 \u2014 allowed only for icons, placeholders, eyebrows, and decorative text; readable muted copy is `text-slate-500 dark:text-slate-400` (4.8:1 / 5.7:1) or darker.\n- **Forms & Feedback:** Forms require inline validation styling, explicit error text, and smooth loading states for asynchronous actions.\n - Ply: `ply-input-group` + `ply-label` + `<input ply-input formControlName="\u2026">` + `<ply-error>` + optional `ply-info-text`. The group reads the projected `NgControl` / `[formField]`: after the field is touched and invalid it shows the red shell, renders `ply-error`, and sets `aria-invalid` + `aria-describedby` \u2014 do not wrap `ply-error` in `@if` or add those attributes by hand; switch messages inside one `ply-error` (`@if (ctrl.hasError(\'required\')) { \u2026 } @else { \u2026 }`). Composite CVA hosts bind `[invalid]` on the group. Submit through `<ply-progress-button type="submit" [loading]="saving()">` (sets `aria-busy`, blocks re-submit). Long operations: `ToastService.promise(op, { loading, success, error })`.\n\n## 3. Micro-Interactions & Transitions\n\n- Keep motion fast, weighted, and natural: `transition-all duration-200 ease-out`.\n - Ply: feedback (hover, press, color) `transition-colors duration-150` \u2013 `duration-200`; enter / expand / height `duration-300` (`openClose`, drawer, bottom sheet); exits are shorter than entries and use `ease-in`. Prefer `transition-colors` / `transition-opacity` / `transition-transform` over `transition-all` when one property changes.\n- Implement subtle state shifts (e.g., `hover:border-slate-400/40` or microscopic `active:scale-[0.99]`).\n - Ply: cards and tiles `hover:border-slate-300 dark:hover:border-slate-600` (or `hover:border-slate-400/40` on dark surfaces) plus optional `active:scale-[0.99]`. `ply-button` already steps its background on hover / active \u2014 do not add scale to it. Reuse `animations.ts` triggers (`dropdown`, `openClose`, `rotate180`, `slideLeft/Right/Top/Bottom`) instead of writing new ones.\n\n## 4. Operational States & Resilience (The Reality)\n\n- **State Coverage:** Every component must gracefully handle Loading (skeleton screens matching exact dimensions), Empty (actionable empty states with copy and CTAs), and Error states.\n- **Data & Text Handling:** Apply explicit line clamping (`line-clamp-2`) or truncation for unpredictable text lengths. Use tabular numerals (`font-tabular-nums`) for all data grids, financial figures, and dashboards.\n- **Motion Orchestration:** Never use jarring transitions. Use swift, purposeful curves (`transition-all duration-200 ease-out`). Modals and overlays must feature subtle backdrop blurs and scale-in entries.\n\nResolve states in this order and render exactly one branch: `loading` \u2192 `error` \u2192 `empty` \u2192 `data`. Every branch lives inside the same container (`ply-card`, same padding, same `min-h-*`) so nothing jumps when the state changes.\n\n### Loading\n\n- `ply-skeleton` is pulse-only (`bg-slate-200 dark:bg-slate-700 animate-pulse`). Variants: `text` (h-3), `circular` (40px), `rectangular`, `card`, `table-row`; `count`, `width` / `height` take CSS strings (`width="100%" height="16rem"`).\n- Match the final layout 1:1 \u2014 same card, same padding, same row count, same column widths. A list of 5 rows loads as `count="5"`; a `16rem` chart loads as a `16rem` rectangle. Never a lone spinner for a region with more than one element.\n- `ply-data-table [loading]="true"` renders its own skeleton rows (`min(pageSize, 5)`); do not wrap it in another skeleton.\n- Lazy chunks: `@defer (on viewport; prefetch on idle) { \u2026 } @placeholder { <ply-skeleton \u2026> } @loading (minimum 300ms) { <ply-skeleton \u2026> }` \u2014 the `minimum` prevents a flash.\n- Refreshing existing data: keep the content and overlay `<ply-loading-overlay [visible]="refreshing()" message="Updating\u2026">` (`aria-busy`, `role="status"`, `bg-white/80 dark:bg-slate-900/80`, no blur \u2014 reserve blur for modals).\n- Async buttons: `<ply-progress-button [loading]="saving()">` \u2014 `loading` is a plain boolean input, so bind it (`[loading]="\u2026"`), not as a bare attribute. Keep the button width stable; the label fades to `opacity-0` under the spinner.\n- `ply-spinner` has no ARIA of its own \u2014 wrap it in `role="status" aria-label="Loading"` or use `ply-loading-overlay`.\n\n### Empty\n\n- Lists: `@for (item of items(); track item.id) { \u2026 } @empty { <ply-empty-state \u2026> }`.\n- `ply-empty-state` takes `iconName`, `title`, `description` and projects the CTA: `<button ply-button color="primary">New project</button>`. Two flavors \u2014 first-run (primary CTA that creates the thing) and filtered / no-results (`iconName="search"`, secondary `ply-stroked-button` "Clear filters"). Never leave an empty state without an action or an explanation.\n- Copy: title names what is missing in \u2264 5 words ("No invoices yet"); description says why or what to do in one sentence; CTA is a verb ("Create invoice", "Clear filters", "Import CSV").\n- Tables: `<ply-data-table emptyMessage="No results for this filter">` or a custom `<ng-template plyTableEmpty>` containing a compact empty state. Chrome strings localize through `provideBaseUiI18n`.\n- Icons that exist for empty states: `inbox`, `search`, `folder`, `folder-open`, `file-text`, `images`, `users`, `calendar`, `cart`, `bell-off`, `filter`.\n\n### Error\n\n- Field: `<ply-error>` inside `ply-input-group` \u2014 the group shows it after touch, paints the shell `border-red-500!`, and wires `aria-invalid` / `aria-describedby`. Write a specific message, never rely on the red border alone, and keep the message under the field (not in a toast).\n- Section / data region: `<ply-alert color="danger" icon="alert-triangle" [close]="true">` with `<ply-alert-actions><button ply-stroked-button color="danger" size="sm" (click)="retry()">Retry</button></ply-alert-actions>`. The host already has `role="alert"`.\n- Whole region failed: `<ply-empty-state iconName="alert-triangle" title="Couldn\'t load orders" description="Check your connection and try again.">` + Retry button. Other error icons: `x-circle`, `alert-circle`, `shield-alert`, `wifi-off`, `file-x`, `folder-x`.\n- Background operations: `ToastService.error(message, { action: { label: \'Retry\', onClick } })`; long jobs: `ToastService.promise()`. Danger / warning toasts announce as `role="alert"` automatically.\n- Destructive confirmation: `DialogService.confirm({ title: \'Delete file?\', description: \'\u2026\', destructive: true })` \u2014 never a one-off confirm dialog.\n- Keep the frame: an error replaces the content inside the same container and `min-h-*`; do not collapse the region or shake / bounce it. Errors are never console-only.\n\n### Data and text\n\n- Tailwind\'s utility is `tabular-nums` (`font-tabular-nums` is not a class). Ply already applies it in `ply-stat-card` (metric), `ply-progress`, `ply-countdown`, `ply-animated-counter`, `ply-meter-group`, and `ply-time-picker` \u2014 apply it to every numeric cell, KPI, price, and axis label you add.\n- Numeric table columns: `align="right"` on the column (renders `text-end`) plus `<ng-template plyTableCell="amount" let-value="value"><span class="tabular-nums whitespace-nowrap">{{ value | currency }}</span></ng-template>`.\n- Single-line text in flex or grid: `min-w-0` on the child, then `truncate`, and `[title]` when the full value matters (IDs, emails, file names).\n- Multi-line descriptions: `line-clamp-2` (cards, list rows) or `line-clamp-3` (article teasers). Titles: `line-clamp-1` / `truncate`.\n- `font-mono` only for IDs, hashes, code, and tokens \u2014 not for money. Dates and currency get `whitespace-nowrap`.\n- `ply-chip` and `ply-tags-input` wrap instead of truncating \u2014 cap visible tags in the template (`+3 more`) when space is fixed.\n\n### Motion orchestration\n\n- Modals: always `DialogService`. The container ships `bg-slate-900/50 dark:bg-slate-900/80 backdrop-blur-sm`, a `scale(0.5\u21921.05\u21921)` entry over 300ms `ease-out`, a mirrored `ease-in` exit, focus trap, Escape, and `aria-modal`. Do not add a second backdrop, animation, or z-index.\n- Sheets and drawers: `ply-bottom-sheet` (`backdrop-blur-sm`, backdrop fades in 200ms `ease-out` / out 150ms `ease-in`, panel slides 300ms) and `ply-drawer` (`backdrop-blur-[8px]`, 300ms slide). Register `provideAnimationsAsync()`.\n- Custom popovers, menus, tooltips: use the `dropdown` trigger from `animations.ts` or CSS `transition-[opacity,transform] duration-200 ease-out` from `opacity-0 scale-95` to `opacity-100 scale-100`; exit `duration-150 ease-in`. Position with `overlayPositions()` / `dropdownConnectedPositions()` from `utils/overlay-position`; layer with `Z_SCALE`.\n- Toasts: the service owns entry, stacking, swipe, and reduced motion \u2014 never animate toasts yourself.\n- Reduced motion is handled globally (`prefers-reduced-motion` block in `index.scss` neutralises CSS animations and transitions). Do not add `motion-reduce:` utilities per element; JS-driven motion checks `window.matchMedia(\'(prefers-reduced-motion: reduce)\')` like `ToastService`.\n- Interactive feedback never exceeds `duration-300`. `duration-500` is only for ambient decoration (the empty-state hover glow).\n\n## Delivery checklist\n\n```\n- [ ] Reused a Ply primitive for every modal, table, toast, empty, loading, and form-field need\n- [ ] Only slate neutrals + --ply-primary accent; semantic colors carry meaning\n- [ ] Control radius rounded-[var(--ply-radius)]; surfaces rounded-xl; rounded-full only on chips/avatars/pills\n- [ ] Standard spacing scale; no [Npx]\n- [ ] Light + dark pair on every color class; icons via stroke-*\n- [ ] Hover, active, focus-visible (FOCUS_RING), and disabled on every interactive element; aria-label on icon-only buttons\n- [ ] Buttons side by side share one size (`default`, `lg`, or `sm`) \u2014 never mix `lg` with default\n- [ ] Loading, empty, error, and data branches render in the same container with matching dimensions\n- [ ] Skeleton row count and sizes mirror the final content; ply-data-table uses [loading]\n- [ ] Empty states have a title, one-sentence description, and a verb CTA\n- [ ] Field errors are ply-error inside ply-input-group (aria wiring is automatic); region errors use ply-alert or ply-empty-state + Retry\n- [ ] tabular-nums + text-end on every numeric column and KPI; truncate/min-w-0 or line-clamp-* on unbounded text\n- [ ] Motion: transition-colors/transition-all 150\u2013200ms feedback, \u2264300ms entries, exits faster; modals via DialogService\n- [ ] Heading/paragraph margins reset with m-0! where needed; logical RTL utilities for new layout\n- [ ] Signals for any state written outside template events (zoneless)\n```\n\n## Additional resources\n\n- Exact inputs, outputs, and class strings for every primitive referenced here: [reference.md](reference.md)\n- Complete copy-paste templates (four-state data card, dashboard KPI grid, dialog form, data table with formatted columns): [examples.md](examples.md)\n- Full component catalog with all APIs: `docs/ai/components.md`\n', "reference.md": "# Ply UI Design \u2014 primitive reference\n\nExact selectors, inputs, outputs, and class strings taken from `projects/base/src/lib/`. Inputs are signal inputs (`input()`); bind with `[name]=\"\u2026\"`. Inputs marked *attr* use `booleanAttribute`, so the bare attribute (`<ply-data-table loading>`) works. Everything is re-exported from `projects/base/src/public-api.ts` (`'Base'` alias in the demo app).\n\n## Shared constants \u2014 `utils/tw-merge.ts`\n\n| Export | Value | Use |\n|---|---|---|\n| `cn(...inputs)` | `twMerge(clsx(inputs))` | merge a component's `class` input over its defaults |\n| `FOCUS_RING` | `outline-none focus-visible:ring-2 focus-visible:ring-[var(--ply-ring)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--ply-background)]` | buttons, links, tiles |\n| `FOCUS_RING_INSET` | `outline-none! focus-visible:ring-2! focus-visible:ring-inset! focus-visible:ring-[var(--ply-ring)]!` | compact controls (calendar cells, chips) |\n| `FOCUS_RING_WITHIN` | `focus-within:ring-2! focus-within:ring-inset! focus-within:ring-[var(--ply-ring)]!` | input-group wrappers with addons |\n| `PRIMARY_SOFT` | `bg-[var(--ply-primary-soft)] text-[var(--ply-primary)]` | selected / highlighted rows and options |\n\n`utils/z-index.ts` \u2014 `Z_SCALE`: sticky `50`, overlay `1000`, popover `1100`, toast `1200`.\n`utils/safe-timer.ts` \u2014 `injectTimers()` returns destroy-scoped `setTimeout` / `setInterval` / `clear` / `clearAll`.\n`utils/overlay-position.ts` \u2014 `overlayPositions(placement, gap, dir)`, `dropdownConnectedPositions()`, `overlayDropUpMetrics()`, `tooltipAbsolutePosition()`.\n\n## Buttons \u2014 `directives/button/`\n\n`[ply-button]` (`BaseButtonDirective`), `[ply-stroked-button]` (`StrokedButtonDirective`), `[ply-icon-button]`, `[ply-icon-stroked-button]`, `[ply-link]`. Inputs: `class`, `color`, `size`, `width`.\n\nBase (filled): `flex items-center gap-2 relative rounded-[var(--ply-radius)] text-center justify-center tracking-wide font-medium transition-all duration-200 cursor-pointer disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 [&_ply-icon]:stroke-current [&_ply-icon]:fill-current` + `FOCUS_RING`. Stroked adds `border` and drops `font-medium`.\n\n| `color` | filled `[ply-button]` | stroked `[ply-stroked-button]` |\n|---|---|---|\n| `primary` | `text-[var(--ply-primary-foreground)]! bg-[var(--ply-primary)] hover:bg-[var(--ply-primary-hover)] active:bg-[var(--ply-primary-active)]` | `text-[var(--ply-primary)]! border-[var(--ply-primary)] hover:bg-blue-50 hover:text-[var(--ply-primary-hover)]! active:bg-blue-100` |\n| `default` | `text-[var(--ply-muted-foreground)]! bg-[var(--ply-muted)] hover:bg-slate-300 dark:hover:bg-slate-700 active:bg-slate-400 dark:active:bg-slate-600` | `text-[var(--ply-muted-foreground)]! border-[var(--ply-border)] hover:bg-slate-50 dark:hover:bg-slate-800 active:bg-slate-100 dark:active:bg-slate-900` |\n| `danger` | `text-[var(--ply-destructive-foreground)]! bg-[var(--ply-destructive)] hover:bg-[var(--ply-destructive-hover)] active:bg-red-800` | `text-[var(--ply-destructive)]! border-[var(--ply-destructive)] hover:bg-red-50 active:bg-red-100` |\n| `success` / `warning` / `accent` | `bg-green-500` / `bg-orange-500` / `bg-purple-500` (+ `-600` hover, `-700` active, `-300` disabled) | `text-*-500! border-*-500 hover:bg-*-50 active:bg-*-100` |\n| `transparent` | `bg-transparent hover:bg-slate-200/50 dark:hover:bg-slate-700/50` | `border-transparent hover:bg-slate-100 dark:hover:bg-slate-800` |\n| `black` / `dark` | `bg-slate-900 dark:bg-black hover:bg-slate-800` | `border-slate-900 dark:border-white hover:bg-slate-900/10 dark:hover:bg-white/10` |\n| `white` | `text-slate-900! bg-white hover:bg-slate-50` | `text-white! border-white hover:bg-white/10` |\n\n| `size` | classes |\n|---|---|\n| `sm` | `h-7 text-xs px-4 [&_ply-icon]:w-3 [&_ply-icon]:h-3` |\n| `default` / `md` | `h-9 text-sm px-6 [&_ply-icon]:w-5 [&_ply-icon]:h-5` |\n| `lg` | `h-10 text-base px-7 [&_ply-icon]:w-6 [&_ply-icon]:h-6` |\n| `xl` | `h-11 text-base px-8 [&_ply-icon]:w-6 [&_ply-icon]:h-6` |\n| `xxl` | `h-14 text-lg px-10 [&_ply-icon]:w-7 [&_ply-icon]:h-7` |\n\nOverrides go through `class` (`class=\"rounded-full w-full\"`); text colors use `!` so they win on `<a>`.\n\n`[ply-icon-button]` \u2014 square, icon-only. Base as filled minus `gap-2 font-medium`; extra colors `secondary` (`bg-slate-500`), `inverted` (`text-white! bg-black/40 hover:bg-black/60 backdrop-blur-sm` \u2014 for buttons over images), `transparent` (`text-[var(--ply-muted-foreground)]! hover:bg-slate-200/50 dark:hover:bg-slate-700/50`). Always give it `aria-label`.\n\n| icon `size` | classes |\n|---|---|\n| `sm` | `h-7 w-7 [&_ply-icon]:w-3 [&_ply-icon]:h-3` |\n| `md` | `h-8 w-8 [&_ply-icon]:w-4 [&_ply-icon]:h-4` |\n| `default` | `h-9 w-9 [&_ply-icon]:w-5 [&_ply-icon]:h-5` |\n| `lg` / `xl` / `xxl` | `h-10 w-10` / `h-11 w-11` (`[&_ply-icon]:w-6`) / `h-14 w-14` (`[&_ply-icon]:w-7`) |\n\n`[ply-link]` \u2014 `inline-flex items-center tracking-wide transition-colors duration-200 [&_ply-icon]:stroke-current`; `color` `default` / `primary` = `text-blue-500 hover:text-blue-700 active:text-blue-900`, `secondary` = `text-slate-500 hover:text-slate-700`, plus `success` / `danger` / `warning` / `accent` on the `-500 \u2192 -700 \u2192 -900` ramp; `size` `sm` `text-xs gap-2`, `default` / `md` `text-sm gap-3`, `lg` `text-base gap-4`, `xl` `text-lg gap-5`. Underline is not included \u2014 add `hover:underline` when a link sits in running text.\n\n## Form fields \u2014 `components/input-group/`, `directives/input-group/`\n\n| Selector | Classes / notes |\n|---|---|\n| `ply-input-group` | host `block w-full`; shell `flex items-center w-full min-h-9 border border-solid border-[var(--ply-border)] rounded-[var(--ply-radius)] relative bg-[var(--ply-background)] shadow-sm focus-within:border-[var(--ply-ring)]!` + `FOCUS_RING_WITHIN`, plus `border-red-500!` while `showErrors()`; projects `ply-label`, `[ply-input]`, `[ply-textarea]`, `ply-mention-input`, `datalist`, `[ply-addon-start]`, `[ply-addon-end]`, `ply-error`, `ply-info-text` |\n| `[ply-input]` | `w-full h-9 px-4 text-sm text-slate-700 dark:text-slate-200 bg-transparent rounded-[var(--ply-radius)] transition-all duration-200 outline-none focus:ring-0 disabled:cursor-not-allowed disabled:text-slate-400 read-only:bg-slate-50 dark:read-only:bg-slate-700` |\n| `ply-label` | `block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1`; `for` is linked to the projected control automatically |\n| `ply-error` | `block text-xs text-red-500 mt-1`; rendered by the group only while `showErrors()` |\n| `ply-info-text` | `block text-xs text-slate-500 dark:text-slate-400 mt-1`; always rendered |\n\n`showErrors()` is `invalid` input **or** (`[formField]` state `invalid && touched`) **or** (`NgControl.touched && errors`). When true the group also sets `aria-invalid=\"true\"` and `aria-describedby=\"<errorId>\"` on the projected input / textarea and removes them when it clears. Consequences: never wrap `ply-error` in `@if`, never set `aria-invalid` yourself inside a group, and use one `ply-error` whose content switches on `hasError(...)`.\n\nComposite CVA hosts (`ply-custom-select`, `ply-multi-select`, `ply-combobox`, `ply-tags-input`, `ply-phone-input`, `ply-currency-input`, `ply-password-input`, `ply-file-upload`, `ply-toggle`, `ply-select`) bind `[(ngModel)]` / `formControlName` / `[formField]` on the host and are placed as siblings of `ply-label`, never inside `ply-input-group`. `ply-select` is a native `<select>` wrapper (`size` `sm`\u2013`xl`) and has no floating panel.\n\n## Card \u2014 `components/card/`\n\n| Part | Classes |\n|---|---|\n| `ply-card` | `block bg-white dark:bg-slate-800 overflow-hidden rounded-xl border border-slate-200 dark:border-slate-700 not-prose`; input `horizontal` (*attr*). No shadow |\n| `ply-card-header` | `flex h-14 font-semibold justify-between items-center border-b border-slate-300 dark:border-slate-700 dark:text-slate-300 dark:bg-slate-800 px-4 not-prose` \u2014 fixed height; pass `class=\"h-auto py-4\"` for a title + subtitle stack, `class=\"h-auto flex-col items-start gap-1 py-4\"` for stacked content |\n| `ply-card-body` | `block p-4 relative overflow-y-auto overflow-x-hidden dark:!text-slate-300` \u2014 content cards pass `class=\"p-6\"`, tables pass `class=\"p-0\"` |\n| `ply-card-footer` | `flex border-t border-slate-300 dark:border-slate-700 p-4 dark:text-slate-300` \u2014 action rows pass `class=\"justify-end gap-2\"` |\n\nAll parts accept `class` merged with `cn()`. Note the header/footer dividers are `slate-300`, one step darker than the card's own `slate-200` border.\n\n## Skeleton \u2014 `ply-skeleton`\n\n| Input | Type | Default |\n|---|---|---|\n| `variant` | `'text' \\| 'circular' \\| 'rectangular' \\| 'card' \\| 'table-row'` | `'text'` |\n| `count` | `number` | `1` |\n| `width` / `height` | CSS string (`\"100%\"`, `\"16rem\"`, `\"40px\"`) | unset; circular defaults to 40px |\n| `animated` | boolean *attr* | `true` |\n\nFill `bg-slate-200 dark:bg-slate-700` + `animate-pulse` (pulse only, no shimmer). Variant shapes: `text` \u2192 `h-3 rounded w-full`; `circular` \u2192 `rounded-full`; `rectangular` \u2192 `rounded-lg`; `card` \u2192 full card preset (`rounded-xl border border-slate-200 dark:border-slate-700 p-4`, avatar + 2 lines + 3 body lines); `table-row` \u2192 `flex items-center gap-4` (avatar, flexible bar, `w-6` bar). Items stack with `space-y-2` (`space-y-3` for cards). Host `block` + `class`.\n\n## Empty state \u2014 `ply-empty-state`\n\nInputs `iconName`, `title`, `description` (all optional strings). CTA via default `<ng-content>`. No size input; wrap in a narrower container to shrink.\n\nRoot `relative overflow-hidden group rounded-3xl p-2 transition-all duration-500 hover:shadow-2xl hover:shadow-slate-500/10 border border-dashed border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900/50 backdrop-blur-xl`. Icon box `w-16 h-16 bg-slate-100 dark:bg-slate-800 rounded-2xl shadow-sm border border-slate-200 dark:border-slate-700 text-slate-400 dark:text-slate-500` with `w-8 h-8 stroke-current` glyph. Title `<h3 class=\"text-2xl font-bold text-slate-900 dark:text-white\">`; description `<p class=\"max-w-80 mx-auto text-sm text-slate-500 dark:text-slate-400 leading-relaxed\">`.\n\n## Loading overlay \u2014 `ply-loading-overlay`\n\n| Input | Type | Default |\n|---|---|---|\n| `visible` | boolean *attr* | `false` |\n| `fullscreen` | boolean *attr* | `false` \u2014 host becomes `contents`, children are not projected |\n| `lockScroll` | boolean *attr* | `false` (auto when fullscreen) |\n| `message` | string | `''` |\n| `size` | `SpinnerSize` | `'lg'` |\n| `color` | `SpinnerColor` | `'primary'` |\n\nHost `relative block` + `aria-busy=\"true\"` while visible. Overlay `absolute inset-0 z-50 flex items-center justify-center bg-white/80 dark:bg-slate-900/80` (fullscreen: `fixed inset-0 z-[10000]`), `role=\"status\" aria-live=\"polite\"`, message `text-sm font-medium text-slate-700 dark:text-slate-200`. No backdrop blur by design.\n\n## Spinner \u2014 `ply-spinner`\n\n`size`: `sm` `w-4 h-4 border` \xB7 `md`/`default` `w-6 h-6 border-2` \xB7 `lg` `w-10 h-10 border-4` \xB7 `xl` `w-12 h-12 border-[5px]`. `color`: `primary` `border-blue-500 border-t-blue-100 border-e-blue-100 border-b-blue-100`, `success`, `danger`, `warning`, `accent`, `inverted` (`border-white border-t-white/20 \u2026`). Base `block rounded-full border-solid animate-spin`. No ARIA \u2014 add `role=\"status\"` + `aria-label` on a wrapper.\n\n## Progress button \u2014 `ply-progress-button`\n\n| Input | Type | Default |\n|---|---|---|\n| `loading` | boolean (plain \u2014 bind `[loading]`) | `false` |\n| `color` / `size` | `ButtonColor` / `ButtonSize` | `'primary'` / `'default'` |\n| `type` | `'button' \\| 'submit' \\| 'reset'` | `'button'` |\n| `disabled` | boolean | `false` |\n| `width` | string | `''` |\n| `spinnerColor` | `SpinnerColor` | derived from `color` |\n\nOutput `clicked` (suppressed while loading or disabled; `preventDefault` blocks submit). While loading: `aria-busy=\"true\"`, `aria-disabled=\"true\"`, `pointer-events-none`, label `opacity-0` under a centered inline spinner (16 / 20 / 22 / 24 / 32 px by size). Native `disabled` is not set during loading, so the button keeps full opacity.\n\n## Alert \u2014 `ply-alert`\n\n| Input | Type | Default |\n|---|---|---|\n| `color` | `'' \\| 'primary' \\| 'success' \\| 'danger' \\| 'accent' \\| 'warning'` | `''` |\n| `variant` | `'soft' \\| 'solid' \\| 'outline'` | `'soft'` |\n| `icon` | icon name | none |\n| `close` | boolean *attr* | `false` |\n| `duration` | ms; `> 0` auto-dismisses | `0` |\n\nOutput `closed`. Host `role=\"alert\"`. Slots: default body, `ply-alert-actions` (`min-w-full flex justify-end gap-2`). Shell `flex flex-col rounded-xl text-sm p-4 gap-3 border transition-all duration-200`. Danger soft: `bg-red-50 text-red-800 border-red-100 dark:bg-red-900/50 dark:text-red-200 dark:border-red-800/70`, icon `w-6 h-6 min-w-6 stroke-red-600 dark:stroke-red-400`; solid `bg-red-600 text-white border-red-700`; outline `bg-transparent text-red-600 border-red-300 dark:text-red-400 dark:border-red-800`. Close button `w-7 h-7 rounded-md -me-2 -mt-2 hover:bg-red-100 dark:hover:bg-red-900/40` with `aria-label=\"Close alert\"`.\n\n## Toast \u2014 `ToastService` (`components/toast/toast.service.ts`)\n\n```ts\nshow(message, config?): number success(message, config?) // icon 'check'\nerror(message, config?) // color 'danger', icon 'alert-triangle', role=\"alert\"\nwarning(message, config?) // color 'warning', icon 'alert-circle', role=\"alert\"\ninfo(message, config?) // color 'primary', icon 'info-circle'\npromise<T>(promiseOrFn, { loading, success, error }, config?): Promise<T> // loading icon 'loader', sticky until settled\ndismiss(id) clearAll()\n```\n\n`ToastConfig`: `color?: ToastColor`, `duration?: number` (default `4000`; `0` = sticky), `icon?`, `position?: ToastPosition` (default `'top-end'`), `action?: { label, onClick, dismiss? }`. Card `pointer-events-auto flex items-center gap-3 px-4 py-3 rounded-xl shadow-xl border max-w-sm w-full`; danger `bg-white text-slate-800 border-red-200 dark:bg-slate-800 dark:text-slate-200 dark:border-red-800`. Stack: 3 visible, `14px` peek, `0.05` scale step, spring `400ms cubic-bezier(0.22, 1.12, 0.36, 1)`, exit `280ms`. Reduced motion (`matchMedia`) switches to a static list with no tweens. The service owns `ply-toast-container` \u2014 never place it in a template. Host `aria-live=\"polite\"`.\n\n## Dialog \u2014 `DialogService` (`components/dialog/dialog.service.ts`)\n\n```ts\nopen<TData, TResult>(type, data?, className?, options?: { hideOnBackdropClick?: boolean; containerType? }): Observable<TResult | undefined>\nconfirm({ title, description?, confirmLabel?, cancelLabel?, destructive? }): Observable<boolean>\n```\n\nInside the opened component: `inject(DialogContext<TData, TResult>)` \u2192 `context.data`, `context.close(result?)`. Parts: `ply-dialog [width]=\"480\"` (number \u2192 px, or any CSS width) `[height]`, `ply-dialog-header` (`flex justify-between items-center p-4 text-lg font-bold border-b border-slate-200 dark:border-slate-800`), `ply-dialog-body` (`p-4 text-sm overflow-y-auto max-h-[calc(100vh-160px)]`), `ply-dialog-footer` (`flex justify-end gap-4 p-4 border-t`), `[ply-dialog-close]` on any button.\n\nContainer: backdrop `absolute inset-0 bg-slate-900/50 dark:bg-slate-900/80 backdrop-blur-sm` (fade 230ms); box `relative bg-white dark:bg-slate-800 rounded-xl shadow overflow-hidden w-fit max-w-[calc(100%-32px)] max-h-[calc(100%-32px)] flex flex-col`, `role=\"dialog\" aria-modal=\"true\"`, enter `scale(0.5) \u2192 1.05 (200ms ease-out) \u2192 1 (100ms)`, leave mirrored with `ease-in`. CDK focus trap, focus restore, Escape, `aria-labelledby` from the first `h1\u2013h3` / `ply-dialog-header`. SSR: `open()` returns `of(undefined)`, `confirm()` `of(false)`. `confirm()` renders `ply-alert-dialog` at width 400 with `role=\"alertdialog\"`.\n\nQuirks: `className` is stored but not bound (no effect). `hideOnBackdropClick: false` also disables Escape (no `context` on the container) \u2014 provide an explicit close button.\n\n## Drawer and bottom sheet\n\n`ply-drawer` \u2014 inputs `size: 'sm' | 'md' | 'lg' | 'xl'` (`w-[200px]` / `w-[400px]` / `w-[40vw]` / `w-[56vw]`; top/bottom `h-40` / `h-[280px]` / `h-[400px]` / `h-[560px]`), `position: DrawerPosition` (default `'end'`), `close` (shows X), `drawerLabel`; output `closed`. Trigger: `[ply-drawer]` directive on a button. Panel `bg-white dark:bg-slate-900 p-4 fixed \u2026 max-w-[92vw] shadow-lg`, `role=\"dialog\" aria-modal=\"true\"`, `cdkTrapFocus cdkTrapFocusAutoCapture`. Backdrop on the overlay: `bg-slate-300/50 dark:bg-slate-800/80 backdrop-blur-[8px]`. Slide `.3s` via `animations.ts`. Requires `provideAnimationsAsync()`.\n\n`ply-bottom-sheet` \u2014 `open` (model), `height: 'auto' | 'half' | 'full' | string` (`max-h-[85vh]` / `h-[50vh]` / `h-[94vh]`), `showHandle` (default true), `showClose`, `sheetLabel`, `dismissible` (default true); output `closed`. Backdrop `fixed inset-0 z-1000 bg-slate-900/50 dark:bg-slate-900/80 backdrop-blur-sm` (`[@fade]` 200ms ease-out / 150ms ease-in). Panel `fixed bottom-0 start-0 end-0 z-1001 mx-auto w-full sm:max-w-140 bg-white dark:bg-slate-900 rounded-t-2xl shadow-2xl`; handle `w-10 h-1 rounded-full bg-slate-300 dark:bg-slate-600`; swipe-down dismiss.\n\n## Data table \u2014 `ply-data-table<T>`\n\n| Input | Type | Default |\n|---|---|---|\n| `columns` | `TableColumn[]` (`key`, `label`, `sortable?`, `width?`, `minWidth?`, `resizable?`, `filterable?`, `align?: 'left' \\| 'center' \\| 'right'`) | `[]` |\n| `data` | `T[]` | `[]` |\n| `loading` | boolean *attr* \u2014 renders `min(pageSize, 5)` pulse rows | `false` |\n| `emptyMessage` | string (default i18n \"No data available\") | unset |\n| `sortable`, `filterable`, `pageable`, `striped`, `bordered`, `hoverable`, `selectable`, `resizable`, `serverSide`, `virtualize` | boolean *attr* | `false` (`hoverable` `true`) |\n| `pageSize` / `currentPage` (model) / `totalItems` | number | `10` / `1` / `0` |\n| `rowKey` | string | `'id'` |\n| `selected` (model) | `(string \\| number)[]` | `[]` |\n| `columnFilters` (model) | `Record<string, string>` | `{}` |\n| `rowHeight` / `viewportHeight` | px (virtualize) | `44` / `400` |\n\nOutputs: `rowClick`, `sortChange`, `filterChange`, `pageChange`, `selectionChange`. Templates: `<ng-template plyTableCell=\"key\" let-row let-value=\"value\">`, `<ng-template plyTableEmpty>`. No `error` input \u2014 render `ply-alert` above the table or swap the table for an error state.\n\nClasses: wrapper `overflow-x-auto rounded-xl`; header `border-b border-slate-200 dark:border-slate-700 bg-slate-50 dark:bg-slate-800/50`, head cell `py-3 px-4 text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400`; body cell `py-3 px-4 text-sm text-slate-700 dark:text-slate-300` + `text-start` / `text-center` / `text-end`; row `transition-colors duration-150 cursor-pointer hover:bg-slate-50 dark:hover:bg-slate-800/30`, striped `bg-slate-50/50 dark:bg-slate-800/20`, selected `bg-blue-50 dark:bg-blue-950/30`; empty row `py-8 px-4 text-center text-sm text-slate-500 dark:text-slate-400`; skeleton bars `h-3 bg-slate-200 dark:bg-slate-700 rounded animate-pulse`. Header is not sticky; cells do not truncate or use `tabular-nums` unless your `plyTableCell` template adds them.\n\nPrimitive table: `table[ply-table]` `w-full caption-bottom text-sm`; `thead[ply-table-header]`; `tr[ply-table-row]` `border-b border-[var(--ply-border)] transition-colors hover:bg-[var(--ply-muted)]/50`; `th[ply-table-head]` `h-10 px-4 text-start align-middle text-xs font-medium text-[var(--ply-muted-foreground)]`; `td[ply-table-cell]` `p-4 align-middle`.\n\n## Numbers\n\n- `ply-stat-card` \u2014 `variant: 'classic' | 'metric'`, `value`, `label`, `trend?`, `trendUp?`, `icon?`, `color: StatCardColor`, `caption?`, `delta?: number`, `series?: readonly number[]`. Metric label `text-[11px] font-bold uppercase tracking-[0.18em] text-slate-400`; value `truncate text-[30px] font-black leading-none tracking-tight text-slate-900 tabular-nums dark:text-white`; delta pill `rounded-full border px-2 py-0.5 text-[11px] font-semibold tabular-nums`. Classic tile `rounded-xl border border-slate-200 bg-white p-6 dark:border-slate-700 dark:bg-slate-800`, value `text-2xl font-bold` (no `tabular-nums` \u2014 add via `class` if needed).\n- `ply-animated-counter` \u2014 `value`, `from`, `duration` (1500), `decimals`, `prefix`, `suffix`, `separator`; renders `<span class=\"tabular-nums\">`; jumps to the final value under SSR.\n- `ply-countdown` \u2014 `targetDate` (required); digits `text-3xl font-bold tabular-nums`, labels `text-xs font-medium text-slate-400 uppercase tracking-wider`.\n- `ply-progress` \u2014 `role=\"progressbar\"` with `aria-valuenow`; track `rounded-full bg-slate-200 dark:bg-slate-700`, fill `transition-all duration-300`, value bubble `text-xs font-semibold tabular-nums`.\n- `ply-meter-group` \u2014 `role=\"meter\"`; legend values `tabular-nums text-slate-500 dark:text-slate-400`.\n\n## Avatar, badge, chip\n\n- `ply-avatar` \u2014 `avatarUrl`, `initials`, `shape: 'circle' | 'square'` (default square), `size: 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'full'` (`w-6` \u2026 `w-14`), `status: 'active' | 'inactive'`. Fallback order: image \u2192 initials (`uppercase font-medium text-slate-700 dark:text-slate-200`) \u2192 `user` icon. `role=\"img\"` with `aria-label`.\n- `ply-badge` \u2014 base `h-6 flex gap-1 border border-transparent justify-center items-center px-3 text-xs! whitespace-nowrap text-white bg-slate-400`; `shape=\"rectangular\"` \u2192 `rounded-md`, every other shape \u2192 `rounded-full`. Solid colors use 600/700 shades so white text passes AA: `primary` `bg-blue-600!`, `danger` `bg-red-600!`, `success` `bg-green-700!`, `accent` `bg-purple-600!`, `warning` `bg-orange-700!`. Soft colors: `default` `bg-slate-100! border-slate-300! text-slate-700!`, `transparent` `bg-transparent! border-slate-300! text-slate-700!`, `slate-light` `bg-slate-200! border-slate-500! text-slate-800!`, `*-light` (`primary`, `danger`, `success`, `accent`, `warning`) `bg-*-100! border-*-400! text-*-800!`. Sizes: `sm` `h-5! text-xs! px-2!`, `md` / `default` `h-6! text-sm!`, `lg` `h-7! text-base! px-4!`, `xl` `h-8! text-xl! px-4!`. Status badges in tables use `size=\"sm\" shape=\"rounded\"` and a `*-light` color; count badges use solid.\n- `ply-chip` \u2014 `color: '' | 'primary' | 'success' | 'danger' | 'warning' | 'accent'`, `size: 'sm' | 'md' | 'default' | 'lg'`; `sm` is `text-xs px-2 py-0.5 rounded-full`. Chips and `ply-tags-input` wrap; they never truncate.\n\n## Selection controls \u2014 `ply-checkbox`, `ply-toggle`, `ply-radio-group` / `ply-radio-button`\n\n| Control | Box / track | Checked | Notes |\n|---|---|---|---|\n| `ply-checkbox` | `w-4 h-4 appearance-none bg-slate-50 dark:bg-slate-800 border border-slate-300 dark:border-slate-600 hover:border-slate-400 rounded me-2 mt-1 peer` | `checked:bg-blue-500 checked:border-blue-500 checked:hover:border-blue-600` (per `color`: blue / red / green / purple / orange `-500`) | `indeterminate` input mirrors checked colors; `[ariaLabel]` for label-less boxes; label is projected content |\n| `ply-toggle` | container `h-5 w-9` (`sm`) / `h-6 w-11` (`md`) / `h-7 w-[52px]` (`lg`); track `bg-slate-200 dark:bg-slate-700 border border-slate-300 dark:border-slate-700 transition-all duration-300`, shape `smooth` `rounded-lg` / `rounded` `rounded-full` / `square` `rounded-md` | `peer-checked:bg-blue-500 peer-checked:border-blue-500` (per `color`) | thumb `bg-white start-0.5 top-0.5 transition-all duration-300`, `h-4` / `h-5` / `h-6`, slides `translate-x-4` / `-5` / `-6` with RTL mirror; `[ariaLabel]` for standalone switches |\n| `ply-radio-button` | `w-4 h-4 appearance-none bg-slate-50 dark:bg-slate-600 border border-slate-300 dark:border-slate-700 rounded-full me-2 peer` + its own `focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2` | `checked:bg-blue-500! checked:border-blue-500!` (per `color`; `warning` uses `orange-400`) | inputs `value`, `label`, `name`, `color`, `disabled`; place inside `ply-radio-group` which owns the value |\n\nAll three are CVAs (`ngModel`, `formControlName`, `[formField]`) with `disabled` input + forms-disabled merging. Selection controls are the one place the library uses raw `blue-500` instead of `--ply-primary`; match that when hand-styling a custom control next to them.\n\n## Overlays \u2014 dropdown, popover, tooltip\n\n| Primitive | Usage | Panel classes |\n|---|---|---|\n| `ply-dropdown-menu` + `[ply-dropdown-menu-trigger]=\"menu\"` | `<ply-dropdown-menu #menu size=\"200px\"><ply-dropdown-menu-item>\u2026</ply-dropdown-menu-item></ply-dropdown-menu>`; `placement` `start` / `end` / `left` / `right` / `top-start` / `top-end` on the trigger; `(closed)` output | `bg-white dark:bg-slate-800 rounded-md shadow-xl border border-slate-200 dark:border-slate-700 overflow-hidden min-w-40 outline-none`; enters with the `dropdown` animation (`.3s .1s` from `translateY(15px)`) |\n| `ply-dropdown-menu-item` | `[(checked)]` turns it into `menuitemcheckbox` and keeps the menu open; `stayOpen` attr; `class` merges | `w-full px-6 h-12 flex items-center text-sm transition-colors duration-200 cursor-pointer hover:bg-slate-100 dark:hover:bg-slate-700 text-slate-600 dark:text-slate-300 whitespace-nowrap not-prose outline-none focus-visible:bg-slate-100 dark:focus-visible:bg-slate-700`; keyboard: arrows, Home / End, typeahead, Escape, submenu via `aria-haspopup=\"menu\"` |\n| `ply-popover` | `<ply-popover placement=\"bottom-start\" minWidth=\"280px\"><button popover-trigger ply-button>Open</button><div>panel</div></ply-popover>`; or an external `[ply-popover-trigger]=\"pop\"`; `PopoverPlacement` = `top` / `bottom` / `left` / `right` / `*-start` / `*-end` | CDK overlay with transparent backdrop, `8px` gap, `reposition` scroll strategy; panel `rounded-xl border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 p-4 shadow-2xl`; focuses first focusable element on open, restores focus on close, Escape closes |\n| `[ply-tooltip]=\"text\"` | `tooltipPlacement` (`top` default / `bottom` / `left` / `right` \u2014 never bare `placement`, it collides with dropdown / drawer), `type=\"dark\"` (default) or `\"light\"`, `delay`, `tooltipClass`, `canShow` | `text-sm rounded-md text-center px-2 py-1 max-w-xs pointer-events-none transition-all duration-300 shadow-lg block relative` + `bg-slate-900 text-white` (dark) or `bg-white text-slate-700 border border-slate-200` (light), 4px arrow; slides in from `translate-y-2` / `-translate-x-2`; sets `aria-describedby` on the host while visible; hides on leave, blur, Escape, scroll. Text only \u2014 no interactive content |\n\nLayering: overlays are CDK overlays (viewport-attached), so they escape `overflow: hidden` parents. Custom floating panels use `Z_SCALE` from `utils/z-index.ts` and `overlayPositions()` / `dropdownConnectedPositions()` / `tooltipAbsolutePosition()` from `utils/overlay-position.ts`.\n\n## Tabs \u2014 `ply-tabs`\n\n`<ply-tabs type=\"underline\" [defaultTab]=\"0\" (tabChanged)=\"\u2026\">` with `<ply-tab label=\"\u2026\">` (or `<ply-tab-label>` for icon + text) and `<ply-tab-body>`. Inputs `type` (`underline` / `pills` / `folder` / unset), `position` (`left` / `center` / `right` / `full-width`), `ariaLabel`, `labelledBy`, `scrollRestricted`. Roving-tabindex tablist with arrow / Home / End keys and scroll arrows on overflow.\n\n| Piece | Classes |\n|---|---|\n| tab (all types) | `px-8 h-10 flex items-center text-sm bg-transparent whitespace-nowrap dark:text-slate-400 border-0` + `FOCUS_RING` |\n| `underline` list / active | list `border-b border-slate-300 dark:border-slate-700`; active `text-[var(--ply-primary)]! shadow-[0_1px_0_var(--ply-primary)]`; inactive hover `hover:shadow-tab` |\n| `pills` | `bg-slate-100 dark:bg-slate-600 dark:text-slate-200 rounded-md! me-2 transition-color duration-300`; active `text-[var(--ply-primary-foreground)]! bg-[var(--ply-primary)]!` |\n| `folder` list / active | list `bg-slate-100 dark:bg-slate-900 rounded-t-lg pt-1 px-1`; active `bg-white dark:bg-slate-800 text-[var(--ply-primary)] rounded-t-md` |\n\nUse `underline` for page sections, `pills` for view switches inside a card, `folder` for editor-like surfaces. Tab panels are `role=\"tabpanel\" tabindex=\"0\"`.\n\n## Divider, kbd, code, typography\n\n- `ply-divider` \u2014 host `block`; renders `w-full h-px bg-slate-300 dark:bg-slate-600` (or `w-px h-full` with `vertical`). One step darker than card borders; for a same-tone rule inside a card use `border-t border-slate-200 dark:border-slate-700` instead.\n- `ply-kbd` \u2014 `inline-flex h-6 min-w-6 items-center justify-center rounded-md border border-slate-200 bg-slate-50 px-1.5 font-mono text-xs font-medium text-slate-700 shadow-[0_1px_0_0_rgb(226_232_240)] dark:border-slate-600 dark:bg-slate-800 dark:text-slate-200`. Use for shortcuts in menus, empty states, and command palettes.\n- `ply-code` \u2014 `language` (`HTML`, `TypeScript`, `JavaScript`, `Bash`), `[(showCode)]`; host `block not-prose`; ships its own copy button (`ply-icon-button` + tooltip + toast). Inline code in prose: `<code class=\"rounded bg-slate-100 px-1.5 py-0.5 font-mono text-[0.85em] text-slate-800 dark:bg-slate-800 dark:text-slate-200\">`.\n- `[plyTypography]` / `[plyHeadingText]` (`directives/typography/ply-heading.directive.ts`) \u2014 base `text-slate-900 dark:text-white` + variant: `display` `text-4xl font-bold tracking-tight sm:text-5xl`, `title` `text-3xl font-bold tracking-tight`, `heading` (default, also `plyHeadingText`) `text-2xl font-semibold tracking-tight`, `subheading` `text-lg font-medium`, `body` `text-base font-normal text-slate-600 dark:text-slate-300`, `caption` `text-sm text-slate-500 dark:text-slate-400`, `overline` `text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400`. `class` merges through `cn()`, so `class=\"text-xl\"` overrides the size.\n\n## Type unions \u2014 `projects/base/src/lib/types/index.ts`\n\n| Type | Values |\n|---|---|\n| `ButtonColor` / `StrokedButtonColor` | `primary` `success` `danger` `warning` `accent` `white` `black` `default` `secondary` `dark` `slate` `transparent` |\n| `ButtonSize` / `StrokedButtonSize` / `IconButtonSize` / `GroupButtonSize` | `sm` `default` `md` `lg` `xl` `xxl` |\n| `IconButtonColor` | as `ButtonColor` minus `dark` `slate`, plus `inverted` |\n| `LinkColor` / `LinkSize` | `primary` `success` `danger` `warning` `accent` `default` `secondary` / `sm` `md` `default` `lg` `xl` |\n| `BadgeColor` | `primary` `danger` `success` `accent` `warning` `default` `transparent` `slate-light` `primary-light` `danger-light` `success-light` `accent-light` `warning-light` |\n| `BadgeSize` / `BadgeShape` | `sm` `md` `default` `lg` `xl` / `rectangular` `circle` `rounded` `pill` |\n| `AlertColor` / `AlertVariant` | `''` `primary` `success` `danger` `accent` `warning` / `soft` `solid` `outline` |\n| `ToastColor` / `ToastPosition` | `primary` `success` `danger` `warning` `accent` / `top-*` `bottom-*` \xD7 `left` `right` `center` `start` `end` |\n| `CheckboxColor` / `RadioColor` / `ToggleColor` / `SliderColor` / `ProgressColor` / `ChartColor` | `primary` `danger` `success` `accent` `warning` |\n| `ToggleSize` / `ToggleShape` | `sm` `md` `default` `lg` / `rounded` `square` `smooth` |\n| `AvatarSize` / `AvatarShape` / `AvatarStatus` | `xs` `sm` `md` `lg` `xl` `full` / `circle` `square` / `active` `inactive` |\n| `ChipColor` / `ChipSize` | `''` `primary` `success` `danger` `warning` `accent` / `sm` `md` `default` `lg` |\n| `SpinnerColor` / `SpinnerSize` | `primary` `success` `danger` `warning` `accent` `inverted` / `sm` `md` `default` `lg` `xl` |\n| `SkeletonVariant` | `text` `circular` `rectangular` `card` `table-row` |\n| `StatCardColor` / `StatCardVariant` / `TimelineColor` | `primary` `success` `danger` `warning` `accent` `default` / `classic` `metric` / as `StatCardColor` |\n| `DropdownPlacement` / `PopoverPlacement` / `TooltipPlacement` | `start` `end` `left` `right` `top-start` `top-end` / `top` `bottom` `left` `right` `top-start` `top-end` `bottom-start` `bottom-end` / `top` `bottom` `left` `right` |\n| `DrawerPosition` / `DrawerSize` / `BottomSheetHeight` | `left` `right` `top` `bottom` `start` `end` / `sm` `md` `lg` `xl` or CSS length / `auto` `half` `full` or CSS length |\n| `TableColumn` | `{ key, label, sortable?, width?, minWidth?, resizable?, filterable?, align?: 'left' \\| 'center' \\| 'right' }`; `TableSortDirection` `asc` `desc` `''` |\n| `ComboboxOption` / `ToastAction` / `MeterGroupItem` / `ChartDataPoint` | `{ value, label, disabled?, description? }` / `{ label, onClick, dismiss? }` / `{ label, value, color?, icon? }` / `{ label, value, color? }` |\n\n`start` / `end` variants follow `dir` (RTL-aware); `left` / `right` are physical. Prefer `start` / `end` in shared components.\n\n## Statistical profile of the library (for judgement calls)\n\n- Neutrals are slate only (~5.5k `slate-*` utilities, zero `zinc` / `gray` / `neutral`). Brand blue is `--ply-primary` on interactive surfaces, raw `blue-500` on selection controls and links, `blue-600` on solid badges.\n- Spacing uses the standard scale (~3k standard utilities vs ~26 arbitrary `[Npx]` values, all inside primitives). Do not introduce `p-[16px]`-style values; the demo density customizer scales `--spacing`.\n- Radius: controls `rounded-[var(--ply-radius)]` (0.5rem default), surfaces `rounded-xl`, menus / tooltips / kbd `rounded-md`, checkbox `rounded`, radio / chips / avatars `rounded-full`.\n- Motion: `duration-200` dominates (`transition-all` on controls, `transition-colors` on rows and links); `duration-300` for toggles, tooltips, tab pills, progress; `duration-150` only for row hovers and exit transitions. No `motion-reduce:` utilities \u2014 the global `prefers-reduced-motion` rule in `src/assets/scss/index.scss` handles it.\n- Text: `text-sm` is the default UI size; `text-xs` for meta, errors, hints, badges; `text-base` only for `body` prose and `lg` controls. `tabular-nums` already ships in stat-card, progress, countdown, animated-counter, meter-group, time-picker. `tracking-widest` is unused \u2014 the widest tracking in the library is `tracking-wider` (`overline`, table heads) and `tracking-[2px]` on form-block labels.\n\n## Motion inventory\n\n| Where | Values |\n|---|---|\n| `ply-button`, `[ply-input]`, `ply-alert` | `transition-all duration-200` |\n| table rows, list rows | `transition-colors duration-150` |\n| `animations.ts` triggers | `dropdown` (enter `.3s .1s` from `translateY(15px)`), `openClose` (height `.3s`), `rotate180`, `tabAnimation`, `slideLeft/Right/Top/Bottom` (`.3s`) |\n| Dialog | box 200ms + 100ms `ease-out` in, 100ms + 200ms `ease-in` out; backdrop 230ms |\n| Bottom sheet | backdrop 200ms `ease-out` / 150ms `ease-in`; panel 300ms |\n| Toast | spring `400ms cubic-bezier(0.22, 1.12, 0.36, 1)`, exit 280ms |\n| `ply-progress` fill | `duration-300`; tooltip bubble `scale-95 \u2192 scale-100 duration-150 ease-out` |\n| Global | `@media (prefers-reduced-motion: reduce)` in `src/assets/scss/index.scss` sets all animation and transition durations to `0.01ms` |\n\n## Icon names (`src/assets/icons.svg`, 389 symbols)\n\nState pairings the library already uses: empty `inbox` / `search` / `folder`; error `alert-triangle`; warning `alert-circle`; success `check`; info `info-circle`; loading `loader`; close `close`; avatar fallback `user`; trend `trend-up` / `trend-down`, `arrow-up` / `arrow-down`.\n\naccessibility, activity, airplay, alarm-clock, alert-circle, alert-octagon, alert-triangle, align-center, align-justify, align-left, align-right, app-window, archive, area-chart, arrow-down, arrow-down-circle, arrow-down-left, arrow-down-right, arrow-in, arrow-in-circle, arrow-in-square, arrow-left, arrow-left-circle, arrow-out, arrow-out-circle, arrow-out-square, arrow-right, arrow-right-circle, arrow-up, arrow-up-circle, arrow-up-left, arrow-up-right, at-sign, attach, award, aws, backward, backward-step, badge-check, bar-chart, bar-chart-2, bar-chart-3, bar-chart-4, barcode, battery, battery-charging, bell, bell-off, blocks, blog, bluetooth, bold, bolt, book, book-open, bookmark, bot, box, boxes, brain, briefcase, bug, building, cable, calculator, calendar, calendar-check, calendar-plus, calendar-x, camera, camera-off, car, cart, cast, check, check-circle, check-square, checkbox, chevron-down, chevron-left, chevron-right, chevron-up, chevrons-down, chevrons-left, chevrons-right, chevrons-up, circle, circle-dashed, clipboard, clipboard-check, clipboard-list, clock, close, cloud, code, command, compas, component, contact, cookie, copy, corner-down-left, corner-down-right, corner-left-down, corner-left-up, corner-right-down, corner-right-up, corner-up-left, corner-up-right, cpu, credit-card, crop, cut, dark, database, diamond, disc, doc, dollar-sign, download, download-cloud, edit, edit-box, edit-line, elements, elements-ui, email, eraser, expand, export, external-link, eye, eye-not, facebook, figma, file, file-check, file-code, file-minus, file-minus-2, file-plus, file-plus-2, file-search, file-text, file-x, file-x-2, file-zip, film, filter, fingerprint, flag, flip-horizontal, flip-vertical, folder, folder-minus, folder-minus-2, folder-open, folder-plus, folder-plus-2, folder-search, folder-x, folder-x-2, forbidden, forward, forward-step, gamepad, gauge, gift, git-branch, git-commit, git-merge, git-pull-request, github, glob, grid, grid-ui, group, handshake, hard-drive, hash, heading, headphones, heart, heart-off, help-circle, hexagon, highlighter, history, home, home-2, hourglass, id-card, images, img, import, inbox, indent, info, info-circle, instagram, italic, key, keyboard, languages, laptop, layers, layout, layout-2, layout-3, layout-4, layout-5, layout-6, layout-columns, layout-grid, layout-rows, layout-sidebar, layout-topbar, library, life-buoy, light, line-chart, line-chart-2, link, linkedin, list, list-checks, list-ordered, list-todo, loader, location, lock, mail-open, map, map-marker-2, map-marker-3, maximize, megaphone, menu, message, mic, mic-off, minimize, minus, minus-circle, minus-square, monitor, more, more-horisontal, mouse, move, music, network, notebook, octagon, outdent, package, paintbrush, palette, panel-bottom, panel-left, panel-right, panel-top, paste, pause, pause-circle, pentagon, percent, phone, phone-call, phone-off, pie-chart, pie-chart-2, pie-chart-3, pie-chart-4, pie-chart-5, pin, plane, play, play-circle, plug, plus, plus-circle, plus-square, power, printer, puzzle, qr-code, quote, radar, radio, receipt, refresh, repeat, replace, reply, rocket, rotate-left, rotate-right, rss, save, scale, scan, scan-face, scan-line, scissors, search, search-minus, search-plus, send, server, settings, share, shield, shield-alert, shield-check, shopping-bag, shuffle, signal, slack, sliders, smile, sparkles, speaker, split, square, square-dashed, star, sticky-note, stop, stop-circle, store, strikethrough, table, table-2, tablet, tag, target, templates, templates-ui, terminal, thumb-down, thumb-up, ticket, timer, toggle-left, toggle-right, translate, trash, trend-down, trend-up, triangle, trophy, truck, tv, type, underline, ungroup, unlink, unlock, unplug, upload, upload-cloud, usb, user, user-check, user-circle, user-cog, user-minus, user-plus, user-x, users, video, video-off, volume, volume-1, volume-x, wallet, wand, warehouse, watch, wifi, wifi-off, workflow, wrap-text, x, x-circle, x-square, x-twitter, zap\n", "examples.md": '# Ply UI Design \u2014 complete examples\n\nAll templates use verified Ply APIs. In the demo app import classes from `\'Base\'`; in the library use relative paths; consumers import from the copied `src/app/components/<name>/` files. Class names: `CardComponent`, `CardHeaderComponent`, `CardBodyComponent`, `CardFooterComponent`, `AvatarComponent`, `SkeletonComponent`, `EmptyStateComponent`, `AlertComponent`, `AlertActionsComponent`, `StatCardComponent`, `ProgressButtonComponent`, `LoadingOverlayComponent`, `DataTableComponent`, `TableCellDirective`, `TableEmptyDirective`, `BadgeComponent`, `InputGroupComponent`, `LabelComponent`, `ErrorComponent`, `InfoTextComponent`, `BaseInputDirective`, `BaseButtonDirective`, `StrokedButtonDirective`, `IconButtonDirective`, `IconComponent`, `DialogComponent`, `DialogHeaderComponent`, `DialogBodyComponent`, `DialogFooterComponent`, `DialogCloseDirective`, `DialogContext`, `DialogService`, `ToastService`.\n\n## 1. Four-state data region (loading \u2192 error \u2192 empty \u2192 data)\n\nOne container, one `min-h`, one branch rendered at a time. The skeleton mirrors the final row layout (avatar + two lines + amount) and row count.\n\n```ts\n@Component({\n selector: \'app-recent-orders\',\n changeDetection: ChangeDetectionStrategy.OnPush,\n imports: [\n CardComponent, CardHeaderComponent, CardBodyComponent, AvatarComponent,\n SkeletonComponent, EmptyStateComponent, AlertComponent, AlertActionsComponent,\n BaseButtonDirective, StrokedButtonDirective, IconComponent, CurrencyPipe, DatePipe,\n ],\n templateUrl: \'./recent-orders.component.html\',\n})\nexport class RecentOrdersComponent {\n private readonly api = inject(OrdersApi);\n\n readonly orders = signal<Order[]>([]);\n readonly loading = signal(true);\n readonly error = signal<string | null>(null);\n readonly filterActive = signal(false);\n\n constructor() {\n this.load();\n }\n\n load(): void {\n this.loading.set(true);\n this.error.set(null);\n this.api.recent().subscribe({\n next: (rows) => { this.orders.set(rows); this.loading.set(false); },\n error: () => { this.error.set("Couldn\'t load recent orders."); this.loading.set(false); },\n });\n }\n}\n```\n\n```html\n<ply-card>\n <ply-card-header class="h-auto py-4">\n <div>\n <p class="m-0! text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400">Sales</p>\n <h3 class="m-0! text-xl font-bold tracking-tight text-slate-900 dark:text-white">Recent orders</h3>\n </div>\n <button ply-stroked-button size="sm" (click)="load()" [disabled]="loading()">\n <ply-icon name="refresh" class="h-4 w-4"></ply-icon>\n Refresh\n </button>\n </ply-card-header>\n\n <ply-card-body class="min-h-80 p-6">\n @if (loading()) {\n <ul class="m-0! list-none space-y-4 p-0" aria-busy="true" aria-label="Loading recent orders">\n @for (i of [1, 2, 3, 4, 5]; track i) {\n <li class="flex items-center gap-3">\n <ply-skeleton variant="circular" width="40px" height="40px"></ply-skeleton>\n <div class="min-w-0 flex-1 space-y-2">\n <ply-skeleton width="60%"></ply-skeleton>\n <ply-skeleton width="35%"></ply-skeleton>\n </div>\n <ply-skeleton width="4.5rem" height="1rem"></ply-skeleton>\n </li>\n }\n </ul>\n } @else if (error()) {\n <ply-alert color="danger" icon="alert-triangle">\n {{ error() }}\n <ply-alert-actions>\n <button ply-stroked-button color="danger" size="sm" (click)="load()">Retry</button>\n </ply-alert-actions>\n </ply-alert>\n } @else {\n <ul class="m-0! list-none divide-y divide-slate-200 p-0 dark:divide-slate-700">\n @for (order of orders(); track order.id) {\n <li class="flex items-center gap-3 py-3 first:pt-0 last:pb-0">\n <ply-avatar size="md" shape="circle" [initials]="order.customerInitials"></ply-avatar>\n <div class="min-w-0 flex-1">\n <p class="m-0! truncate text-sm font-medium text-slate-900 dark:text-white" [title]="order.customer">\n {{ order.customer }}\n </p>\n <p class="m-0! line-clamp-1 text-xs text-slate-500 dark:text-slate-400">\n #{{ order.id }} \xB7 {{ order.placedAt | date: \'mediumDate\' }}\n </p>\n </div>\n <span class="shrink-0 text-sm font-semibold tabular-nums text-slate-900 dark:text-white">\n {{ order.total | currency: order.currency }}\n </span>\n </li>\n } @empty {\n @if (filterActive()) {\n <ply-empty-state iconName="search" title="No matching orders"\n description="Nothing matches the current filter. Clear it to see all orders.">\n <button ply-stroked-button (click)="filterActive.set(false)">Clear filter</button>\n </ply-empty-state>\n } @else {\n <ply-empty-state iconName="inbox" title="No orders yet"\n description="Orders appear here as soon as a customer checks out.">\n <button ply-button color="primary">Create test order</button>\n </ply-empty-state>\n }\n }\n </ul>\n }\n </ply-card-body>\n</ply-card>\n```\n\n## 2. Dashboard KPI grid with a matching skeleton\n\nThe skeleton grid uses the same `grid` classes and card chrome as the metrics, so the page does not reflow when data lands.\n\n```html\n<section aria-labelledby="kpi-heading">\n <h2 id="kpi-heading" class="sr-only">Key metrics</h2>\n\n @if (loading()) {\n <div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4" aria-busy="true">\n @for (i of [1, 2, 3, 4]; track i) {\n <div class="rounded-xl border border-slate-200 bg-white p-6 dark:border-slate-700 dark:bg-slate-800">\n <ply-skeleton width="45%" height="0.6rem" class="mb-4"></ply-skeleton>\n <ply-skeleton width="70%" height="1.875rem" class="mb-4"></ply-skeleton>\n <ply-skeleton variant="rectangular" width="100%" height="3rem"></ply-skeleton>\n </div>\n }\n </div>\n } @else {\n <div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">\n <ply-stat-card variant="metric" label="Revenue" [value]="kpi().revenue | currency: \'USD\' : \'symbol\' : \'1.0-0\'"\n [delta]="kpi().revenueDelta" [series]="kpi().revenueSeries" caption="vs. last 30 days"></ply-stat-card>\n <ply-stat-card variant="metric" label="Orders" [value]="kpi().orders | number"\n [delta]="kpi().ordersDelta" [series]="kpi().ordersSeries" color="success"></ply-stat-card>\n <ply-stat-card variant="metric" label="Refund rate" [value]="kpi().refundRate | percent: \'1.1-1\'"\n [delta]="kpi().refundDelta" color="warning"></ply-stat-card>\n <ply-stat-card variant="metric" label="Avg. order" [value]="kpi().avgOrder | currency: \'USD\'"\n [delta]="kpi().avgOrderDelta"></ply-stat-card>\n </div>\n }\n</section>\n```\n\nAnything numeric you write yourself follows the same recipe as the stat card value:\n\n```html\n<p class="m-0! text-[30px] font-black leading-none tracking-tight tabular-nums text-slate-900 dark:text-white">\n {{ total() | currency }}\n</p>\n```\n\n## 3. Dialog form with inline validation, async submit, and destructive confirm\n\nOpen with `inject(DialogService).open<ProjectDraft | undefined, ProjectDraft>(ProjectDialogComponent, existing)`. The container supplies backdrop blur, scale-in, focus trap, Escape, and labelling \u2014 the dialog component adds nothing for those.\n\n```ts\n@Component({\n selector: \'app-project-dialog\',\n changeDetection: ChangeDetectionStrategy.OnPush,\n imports: [\n ReactiveFormsModule,\n DialogComponent, DialogHeaderComponent, DialogBodyComponent, DialogFooterComponent, DialogCloseDirective,\n InputGroupComponent, LabelComponent, BaseInputDirective, ErrorComponent, InfoTextComponent,\n ProgressButtonComponent, StrokedButtonDirective, IconButtonDirective, IconComponent,\n ],\n templateUrl: \'./project-dialog.component.html\',\n})\nexport class ProjectDialogComponent {\n private readonly context = inject<DialogContext<ProjectDraft | undefined, ProjectDraft>>(DialogContext);\n private readonly dialog = inject(DialogService);\n private readonly toast = inject(ToastService);\n private readonly api = inject(ProjectsApi);\n private readonly fb = inject(NonNullableFormBuilder);\n\n readonly isEdit = !!this.context.data;\n readonly saving = signal(false);\n readonly form = this.fb.group({\n name: [this.context.data?.name ?? \'\', [Validators.required, Validators.maxLength(60)]],\n owner: [this.context.data?.owner ?? \'\', [Validators.required, Validators.email]],\n });\n\n save(): void {\n if (this.form.invalid) { this.form.markAllAsTouched(); return; }\n this.saving.set(true);\n this.api.save(this.form.getRawValue()).subscribe({\n next: (saved) => { this.toast.success(\'Project saved\'); this.context.close(saved); },\n error: () => { this.saving.set(false); this.toast.error("Couldn\'t save project", { action: { label: \'Retry\', onClick: () => this.save() } }); },\n });\n }\n\n remove(): void {\n this.dialog.confirm({\n title: \'Delete this project?\',\n description: \'Members lose access immediately. This cannot be undone.\',\n confirmLabel: \'Delete\',\n destructive: true,\n }).subscribe((ok) => {\n if (!ok) return;\n this.api.delete(this.context.data!.id).subscribe(() => {\n this.toast.success(\'Project deleted\');\n this.context.close();\n });\n });\n }\n}\n```\n\n```html\n<ply-dialog width="480">\n <ply-dialog-header>\n {{ isEdit ? \'Edit project\' : \'New project\' }}\n <button type="button" ply-icon-button color="transparent" ply-dialog-close aria-label="Close">\n <ply-icon name="close"></ply-icon>\n </button>\n </ply-dialog-header>\n\n <form [formGroup]="form" (ngSubmit)="save()" novalidate>\n <ply-dialog-body>\n <!-- ply-input-group links the label, shows ply-error after touch + invalid, paints the red shell,\n and sets aria-invalid / aria-describedby. No @if around ply-error, no manual ids. -->\n <div class="flex flex-col gap-4 py-1">\n <ply-input-group>\n <ply-label>Name</ply-label>\n <input ply-input formControlName="name" placeholder="Acme checkout" autocomplete="off" />\n <ply-error>\n @if (form.controls.name.hasError(\'required\')) { Enter a project name. }\n @else { Keep the name under 60 characters. }\n </ply-error>\n <ply-info-text>Shown in the sidebar and on invoices.</ply-info-text>\n </ply-input-group>\n\n <ply-input-group>\n <ply-label>Owner email</ply-label>\n <input ply-input type="email" formControlName="owner" placeholder="you@company.com" />\n <ply-error>Enter a valid email address.</ply-error>\n </ply-input-group>\n </div>\n </ply-dialog-body>\n\n <ply-dialog-footer class="justify-between">\n <div>\n @if (isEdit) {\n <button type="button" ply-stroked-button color="danger" (click)="remove()" [disabled]="saving()">Delete</button>\n }\n </div>\n <div class="flex gap-2">\n <button type="button" ply-stroked-button ply-dialog-close [disabled]="saving()">Cancel</button>\n <ply-progress-button type="submit" color="primary" [loading]="saving()">\n {{ isEdit ? \'Save changes\' : \'Create project\' }}\n </ply-progress-button>\n </div>\n </ply-dialog-footer>\n </form>\n</ply-dialog>\n```\n\n## 4. Data table with formatted numeric columns and every state\n\n`ply-data-table` renders skeleton rows for `loading` and a message row for empty data; only the error branch is yours.\n\n```ts\nreadonly columns: TableColumn[] = [\n { key: \'id\', label: \'Invoice\', sortable: true, width: \'120px\' },\n { key: \'customer\', label: \'Customer\', sortable: true },\n { key: \'issued\', label: \'Issued\', sortable: true, width: \'140px\' },\n { key: \'amount\', label: \'Amount\', sortable: true, align: \'right\', width: \'140px\' },\n { key: \'status\', label: \'Status\', width: \'120px\' },\n];\n```\n\n```html\n<div class="mb-4 flex flex-wrap items-center justify-between gap-3">\n <div>\n <p class="m-0! text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400">Billing</p>\n <h2 class="m-0! text-xl font-bold tracking-tight text-slate-900 dark:text-white">Invoices</h2>\n </div>\n <button ply-button color="primary" size="sm" (click)="openNewInvoice()">\n <ply-icon name="plus" class="h-4 w-4"></ply-icon>\n New invoice\n </button>\n</div>\n\n@if (error()) {\n <ply-alert color="danger" icon="alert-triangle" class="mb-4">\n {{ error() }}\n <ply-alert-actions>\n <button ply-stroked-button color="danger" size="sm" (click)="fetch(lastPage())">Retry</button>\n </ply-alert-actions>\n </ply-alert>\n}\n\n<ply-data-table\n [columns]="columns"\n [data]="rows()"\n [loading]="loading()"\n sortable pageable bordered hoverable\n serverSide [pageSize]="25" [totalItems]="total()"\n emptyMessage="No invoices match these filters"\n (pageChange)="fetch($event)"\n (sortChange)="onSort($event)"\n (rowClick)="openInvoice($event)">\n\n <ng-template plyTableCell="id" let-value="value">\n <span class="font-mono text-xs text-slate-500 dark:text-slate-400">{{ value }}</span>\n </ng-template>\n\n <ng-template plyTableCell="customer" let-row>\n <div class="flex min-w-0 items-center gap-3">\n <ply-avatar size="sm" shape="circle" [initials]="row.initials"></ply-avatar>\n <div class="min-w-0">\n <p class="m-0! truncate text-sm font-medium text-slate-900 dark:text-white" [title]="row.customer">{{ row.customer }}</p>\n <p class="m-0! truncate text-xs text-slate-500 dark:text-slate-400" [title]="row.email">{{ row.email }}</p>\n </div>\n </div>\n </ng-template>\n\n <ng-template plyTableCell="issued" let-value="value">\n <span class="whitespace-nowrap tabular-nums">{{ value | date: \'mediumDate\' }}</span>\n </ng-template>\n\n <ng-template plyTableCell="amount" let-row let-value="value">\n <span class="whitespace-nowrap font-semibold tabular-nums text-slate-900 dark:text-white">\n {{ value | currency: row.currency }}\n </span>\n </ng-template>\n\n <ng-template plyTableCell="status" let-value="value">\n <ply-badge [color]="statusColor(value)" size="sm" shape="rounded">{{ value }}</ply-badge>\n </ng-template>\n\n <ng-template plyTableEmpty>\n <div class="py-6">\n <ply-empty-state iconName="filter" title="No invoices match"\n description="Widen the date range or clear the status filter.">\n <button ply-stroked-button size="sm" (click)="clearFilters()">Clear filters</button>\n </ply-empty-state>\n </div>\n </ng-template>\n</ply-data-table>\n```\n\n## 5. Refreshing in place and lazy-loading heavy widgets\n\n```html\n<!-- Keep stale data visible while refreshing -->\n<ply-loading-overlay [visible]="refreshing()" message="Updating\u2026" class="rounded-xl">\n <app-revenue-chart [series]="series()"></app-revenue-chart>\n</ply-loading-overlay>\n\n<!-- Defer a heavy widget with a same-size placeholder -->\n<ply-card>\n <ply-card-body class="p-6">\n @defer (on viewport; prefetch on idle) {\n <app-revenue-chart [series]="series()" class="block h-64"></app-revenue-chart>\n } @placeholder {\n <ply-skeleton variant="rectangular" width="100%" height="16rem"></ply-skeleton>\n } @loading (minimum 300ms) {\n <ply-skeleton variant="rectangular" width="100%" height="16rem"></ply-skeleton>\n }\n </ply-card-body>\n</ply-card>\n```\n\n## 6. Custom menu panel (only when `ply-dropdown-menu` / `ply-popover` do not fit)\n\nPrefer `<ply-dropdown-menu>` with `[ply-dropdown-menu-trigger]` and `ply-dropdown-menu-item`, or `<ply-popover>` with `[ply-popover-trigger]`. If you must hand-roll a panel, copy the dropdown\'s surface and item recipe exactly, position with `dropdownConnectedPositions()`, and layer with `Z_SCALE.popover`.\n\n```html\n<div\n role="menu"\n class="min-w-40 origin-top overflow-hidden rounded-md border border-slate-200 bg-white shadow-xl outline-none transition-[opacity,transform] dark:border-slate-700 dark:bg-slate-800"\n [class]="open() ? \'scale-100 opacity-100 duration-200 ease-out\' : \'pointer-events-none scale-95 opacity-0 duration-150 ease-in\'"\n [style.z-index]="Z_SCALE.popover">\n <button role="menuitem" class="flex h-12 w-full items-center gap-3 px-6 text-sm text-slate-600 outline-none transition-colors duration-200 hover:bg-slate-100 focus-visible:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-700 dark:focus-visible:bg-slate-700">\n <ply-icon name="edit" class="h-4 w-4 stroke-current"></ply-icon>\n Rename\n </button>\n <button role="menuitem" class="flex h-12 w-full items-center gap-3 px-6 text-sm text-red-600 outline-none transition-colors duration-200 hover:bg-red-50 focus-visible:bg-red-50 dark:text-red-400 dark:hover:bg-red-900/30 dark:focus-visible:bg-red-900/30">\n <ply-icon name="trash" class="h-4 w-4 stroke-current"></ply-icon>\n Delete\n </button>\n</div>\n```\n' };
|
|
104
|
+
var DESIGN_SKILL_FILES = { "SKILL.md": '---\nname: ply-ui-design\ndescription: Design and implement Angular 22 + Tailwind v4 UI that matches Ply\'s primitives (ply-button, ply-card, ply-input-group, DialogService, ply-data-table, ply-skeleton, ply-empty-state, ToastService). Covers aesthetic direction, structural foundations, accessibility states, motion, and full operational-state coverage (loading, empty, error, disabled). Use when the project has ply-ui.json and you are designing, building, styling, polishing, or reviewing a component, page, layout, dashboard, form, table, dialog, or overlay, and when the user mentions UI design, design system, visual polish, skeletons, empty states, error states, or motion.\n---\n\n# Ply UI Design \u2014 Angular + Tailwind v4\n\nRead `ply-ui.json` first. Components live at `aliases.components` (default `src/app/components/<name>/`). Import from that folder, never from `base-ui` or `projects/base`. The Tailwind entry is `tailwind.config`; the global stylesheet is `tailwind.css`. Icon sprites are the files `init` downloaded, usually `public/assets/icons.svg` and `public/assets/icons-filled.svg`, or `src/assets/` when that folder is what Angular serves. Never fork or restyle a primitive from the inside; extend it through its `class` input (merged with `cn()`).\n\n## Workflow (every UI task)\n\n```\n- [ ] 1. Reuse first: `npx ply-ui-cli list`, or look in the `aliases.components` folder from `ply-ui.json`. Names and APIs: https://ply-ui.com/docs/ai/components.md\n- [ ] 2. Verify every `ply-icon` name exists in the outline sprite (`icons.svg`, 392 symbols). Filled artwork is only `star` and `heart` in `icons-filled.svg`.\n- [ ] 3. Build the resting state from the recipes in \xA70 \u2014 no new colors, radii, or shadows.\n- [ ] 4. Add loading, empty, error, and disabled states (\xA74) in the same frame as the content.\n- [ ] 5. Check hover / active / focus-visible / dark mode / RTL / reduced motion.\n- [ ] 6. Run the Delivery checklist at the end of this file.\n```\n\n## 0. Ground truth \u2014 tokens and primitive recipes\n\n### Tokens (`ply-ui.css`, next to the global stylesheet from `ply-ui.json`)\n\n| Token | Light | Dark | Use |\n|---|---|---|---|\n| `--ply-background` / `--ply-foreground` | `#ffffff` / `#0f172a` | `#0f172a` / `#f8fafc` | page canvas, ring offset |\n| `--ply-primary` (`-hover`, `-active`, `-foreground`) | `#2563eb` / `#1d4ed8` / `#1e40af` / white | same | the one accent |\n| `--ply-primary-soft` | 14% primary mix | 22% | selected rows, active options |\n| `--ply-muted` / `--ply-muted-foreground` | `#e2e8f0` / `#334155` | `#1e293b` / `#e2e8f0` | default buttons, quiet fills |\n| `--ply-destructive` (`-hover`, `-foreground`) | `#dc2626` / `#b91c1c` / white | same | danger buttons only |\n| `--ply-border` | `#cbd5e1` | `#334155` | stroked buttons, `ply-table` |\n| `--ply-ring` | `= --ply-primary` | same | focus rings |\n| `--ply-radius` | `0.5rem` | same | every control |\n\n`--color-blue-50\u2026950` are aliased to the brand scale, so `bg-blue-600` / `text-blue-600` follow the theme. Theme Studio overrides `--ply-brand` and `--ply-scale-*`; never hardcode hex.\n\n### Primitive recipes (the exact classes the library uses \u2014 match them)\n\n| Element | Classes |\n|---|---|\n| Control radius | `rounded-[var(--ply-radius)]` (buttons, inputs, selects) |\n| Surface radius | `rounded-xl` (card, dialog, alert, toast, popover, table wrapper); `rounded-md` (dropdown menu, tooltip, kbd, rectangular badge); `rounded-3xl` empty-state; `rounded-t-2xl` bottom sheet; `rounded-full` chips, avatars, pills, dots only |\n| Card | `bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700`, no shadow \u2014 `ply-card-header` is a fixed `h-14 px-4 font-semibold border-b` bar (pass `class="h-auto py-4"` for stacked titles), `ply-card-body` defaults to `p-4` (use `class="p-6"` for content cards), `ply-card-footer` `flex p-4 border-t` |\n| Overlay panels | dropdown `bg-white dark:bg-slate-800 rounded-md shadow-xl border border-slate-200 dark:border-slate-700 min-w-40` with items `h-12 px-6 text-sm text-slate-600 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors duration-200` \xB7 popover `rounded-xl border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 p-4 shadow-2xl` \xB7 tooltip `rounded-md bg-slate-900 text-white text-sm px-2 py-1 max-w-xs shadow-lg` \xB7 option lists (`ply-custom-select`, `ply-combobox`) `rounded-[var(--ply-radius)] border border-[var(--ply-border)] bg-[var(--ply-background)] shadow-lg max-h-60 overflow-auto`; `ply-multi-select` / `ply-select-tree` use `rounded-xl border-slate-200 dark:border-slate-700 shadow-lg`. `ply-select` is a native `<select>` \u2014 no panel |\n| Page / well | `bg-slate-50 dark:bg-slate-900` |\n| Raised / hover / skeleton fill | `bg-slate-100 dark:bg-slate-700` \xB7 hover rows `hover:bg-slate-50 dark:hover:bg-slate-800` |\n| Hairline | `border-slate-200 dark:border-slate-700` or `border-[var(--ply-border)]` |\n| Button base | `flex items-center gap-2 rounded-[var(--ply-radius)] font-medium tracking-wide transition-all duration-200 disabled:opacity-50 disabled:pointer-events-none` + `FOCUS_RING` |\n| Button / control heights | `sm` h-7 \xB7 `default` h-9 \xB7 `lg` h-10 \xB7 `xl` h-11 \xB7 `xxl` h-14 \u2014 bare `<input>` without `ply-input` must set `h-9`. Buttons in one row share a single `size`: all `default` or all `lg` (or all `sm`). Never put `size="lg"` next to a default-size sibling |\n| Input | `[ply-input]` `h-9 px-4 text-sm text-slate-700 dark:text-slate-200 bg-transparent`; the `ply-input-group` shell owns the chrome: `min-h-9 border border-[var(--ply-border)] rounded-[var(--ply-radius)] bg-[var(--ply-background)] shadow-sm` + `FOCUS_RING_WITHIN`, turning `border-red-500!` when the projected control is touched and invalid |\n| Label / error / hint | `ply-label` `text-sm font-medium text-slate-700 dark:text-slate-300` (auto-linked `for`) \xB7 `ply-error` `text-xs text-red-500 mt-1` (rendered by the group only when invalid; `aria-invalid` / `aria-describedby` set automatically) \xB7 `ply-info-text` `text-xs text-slate-500 dark:text-slate-400 mt-1` |\n| Typography directive | `[plyTypography]` on any element: `display` `text-4xl font-bold tracking-tight sm:text-5xl` \xB7 `title` `text-3xl font-bold tracking-tight` \xB7 `heading` `text-2xl font-semibold tracking-tight` \xB7 `subheading` `text-lg font-medium` \xB7 `body` `text-base text-slate-600 dark:text-slate-300` \xB7 `caption` `text-sm text-slate-500 dark:text-slate-400` \xB7 `overline` `text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400` |\n| Dialog | backdrop `bg-slate-900/50 dark:bg-slate-900/80 backdrop-blur-sm` \xB7 box `bg-white dark:bg-slate-800 rounded-xl shadow` |\n| Toast | `rounded-xl shadow-xl border bg-white dark:bg-slate-800 text-sm font-medium` |\n| Table header | `py-3 px-4 text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400` on `bg-slate-50 dark:bg-slate-800/50` |\n| Table row / cell | row `transition-colors duration-150 hover:bg-slate-50 dark:hover:bg-slate-800/30` \xB7 cell `py-3 px-4 text-sm text-slate-700 dark:text-slate-300` |\n| Eyebrow / metadata | `text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400` |\n| Metric | `text-[30px] font-black leading-none tracking-tight tabular-nums text-slate-900 dark:text-white` |\n| Selected / active option | `PRIMARY_SOFT` \u2192 `bg-[var(--ply-primary-soft)] text-[var(--ply-primary)]` |\n| Focus | import `FOCUS_RING`, `FOCUS_RING_INSET`, `FOCUS_RING_WITHIN` from `utils/tw-merge` \u2014 never hand-roll rings |\n| Layering | `Z_SCALE` from `utils/z-index`: sticky 50 \xB7 overlay 1000 \xB7 popover 1100 \xB7 toast 1200 |\n\n### Reuse map \u2014 do not rebuild these\n\n| Need | Use |\n|---|---|\n| Modal | `inject(DialogService).open(MyComponent, data?, className?, options?)` with `ply-dialog` + `ply-dialog-header/body/footer`, `[ply-dialog-close]`. The second argument is dialog data. `options` is only `{ hideOnBackdropClick?, containerType? }` |\n| Confirm / destructive | `DialogService.confirm({ title, description, destructive: true })` |\n| Notification | `ToastService.success/error/warning/info/promise()` \u2014 default position `top-end` (follows writing direction). Never place `ply-toast-container` in a template |\n| Side panel / mobile sheet | `ply-drawer` (`[ply-drawer]` trigger, needs `provideAnimationsAsync()`) \xB7 `ply-bottom-sheet` |\n| Table | `ply-data-table` (`loading`, `emptyMessage`, `plyTableCell`, `plyTableEmpty`) or `table[ply-table]` primitives |\n| Placeholder while loading | `ply-skeleton` \xB7 `ply-data-table [loading]` \xB7 `ply-loading-overlay` \xB7 `ply-progress-button [loading]` |\n| Nothing to show | `ply-empty-state` + projected CTA \xB7 `@for \u2026 @empty` |\n| Inline status | `ply-alert` with `color` = `danger` / `warning` / `success` / `primary`, actions in `ply-alert-actions` |\n| KPI / number | `ply-stat-card` (`variant="metric"`), `ply-animated-counter`, `ply-progress`, `ply-meter-group` |\n| Form field | `ply-input-group` > `ply-label` + `<input ply-input>` + `ply-error` / `ply-info-text`; composite controls (`ply-custom-select`, `ply-multi-select`, `ply-phone-input`, `ply-currency-input`, \u2026) sit as siblings of `ply-label`, never inside the group |\n| Gallery slides | `slider`. Numeric ranges are `range-slider` and `dual-range-slider` |\n\n### Non-negotiable project rules (summary of `AGENTS.md` / `CLAUDE.md`)\n\n- Standalone, `ChangeDetectionStrategy.OnPush`, `input()` / `output()` / `model()`, `@if` / `@for` / `@switch` / `@empty`. Library selectors `ply-*`; demo app selectors `app-*`.\n- Zoneless: any state written outside a template event handler (timers, observables, resize, IntersectionObserver) must be a `signal`. Timers via `injectTimers()`.\n- Always pair light and dark: `bg-white dark:bg-slate-800`, `text-slate-900 dark:text-white`.\n- `ply-icon` outlines use `stroke-*`. Filled icons (`[filled]="true"`, only `star` and `heart`) use `text-*` or `fill-*`. Verify the name in the outline sprite.\n- Reset heading / paragraph margins with `m-0!` / `mt-0!` (Tailwind v4 suffix, not `!m-0`).\n- Spacing: standard scale only (`p-4`, `gap-2`, `mb-6`). No `[Npx]` for padding, margin, or gap. A fixed control size that is not on the scale may stay a pixel value.\n- RTL: logical utilities for new layout (`ms-*`, `me-*`, `ps-*`, `pe-*`, `start-*`, `end-*`, `text-start`, `text-end`).\n- Overlays trap focus (CDK `FocusTrapFactory` or `cdkTrapFocus cdkTrapFocusAutoCapture`), close on Escape, and are SSR-safe (`isPlatformBrowser`).\n\n## 1. Aesthetic Direction & Uniqueness (The Look)\n\n- **Anti-Patterns (Forbidden):** No generic rounded-full buttons, random multi-color glowing blobs, or monotonous card grids. Every element must feel intentional.\n - Ply: buttons keep `rounded-[var(--ply-radius)]`; `rounded-full` is reserved for `ply-chip`, avatars, status dots, and delta pills. Break card grids with a `ply-stat-card variant="metric"` row, a full-width `ply-data-table`, or a 2:1 `grid-cols-[2fr_1fr]` split \u2014 not more identical cards.\n- **Surface & Depth:** Use layered tonal contrasts and razor-thin borders (`border-white/[0.08]`) rather than heavy drop shadows or default gray lines.\n - Ply: depth is tonal \u2014 page `bg-slate-50 dark:bg-slate-900` \u2192 surface `bg-white dark:bg-slate-800` \u2192 raised `bg-slate-100 dark:bg-slate-700`. The 1px hairline is `border-slate-200 dark:border-slate-700`; use `border-white/[0.08]`\u2013`/10` only on ink, image, or `bg-slate-900` surfaces. Cards have no shadow; inputs carry `shadow-sm`. The shadow ladder is reserved for floating layers: `shadow-lg` (tooltip, option lists), `shadow-xl` (dropdown menu, toast), `shadow-2xl` (popover, bottom sheet).\n- **Color Discipline:** Strict monochromatic foundation with **one** high-intent, high-saturation accent color used exclusively for key interactive states or active data points.\n - Ply: the neutral is `slate` \u2014 never mix in `gray`, `zinc`, or `neutral`. The accent is `--ply-primary` (`bg-[var(--ply-primary)]`, `text-blue-600 dark:text-blue-400`, `PRIMARY_SOFT`). Do not introduce indigo, violet, sky, or gradients as accents. `green` / `orange` / `red` (`--ply-destructive`) / `purple` are semantic (success / warning / danger / accent) and appear only where they carry meaning \u2014 status badges, alerts, deltas, destructive actions.\n- **Typographic Hierarchy:** High weight contrast, tight tracking on headers (`tracking-tight`), and microscopic uppercase tracking for metadata/tags (`text-[10px] uppercase tracking-widest`).\n - Ply: headings `font-bold tracking-tight text-slate-900 dark:text-white` (page `text-3xl`, card `text-xl`, dialog header `text-lg`) or the `plyTypography` variants (`title`, `heading`, `subheading`); body `text-sm text-slate-700 dark:text-slate-300`; muted `text-slate-500 dark:text-slate-400`. Eyebrows: `plyTypography="overline"` for section labels, `text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400` in data widgets, `<ply-label class="text-[10px]! uppercase font-light! tracking-[2px]!">` in form blocks, `text-xs font-semibold uppercase tracking-wider` in table heads. Pick one eyebrow recipe per screen. Big numbers: `text-[30px] font-black leading-none tracking-tight tabular-nums`.\n\n## 2. Technical & Structural Foundations (The Code)\n\n- **Mobile-First Responsiveness:** Always design compact layouts first, scaling up cleanly using explicit breakpoint modifiers (`sm:`, `md:`, `lg:`).\n - Ply: page container `mx-auto w-full max-w-7xl px-4 sm:px-6 lg:px-8`; stack \u2192 `sm:grid-cols-2 lg:grid-cols-4`; dialogs are already capped at `max-w-[calc(100%-32px)]`; drawers cap at `max-w-[92vw]`. Use `CurrentScreenSizeService` only when layout cannot be expressed in classes.\n- **Layout Architecture:** Prefer CSS Grid for macro-layout structures and Flexbox for fine-grained component alignment. Avoid arbitrary pixel values\u2014stick to a strict spacing rhythm (e.g., Tailwind 4, 8, 12, 16, 24, 32).\n - Ply: `gap-1` inline, `gap-2` control groups, `gap-3` list rows, `gap-4` card grids, `gap-6` sections, `space-y-6` / `mb-8` between page blocks. Card body `p-6`, dense panels `p-4`, table cells `py-3 px-4`. Fixed sizes come from the scale (`w-64`, `h-12`, `size-10`), never `[Npx]`.\n- **Accessibility & States:**\n - Every interactive element (buttons, links, toggles) must feature explicit hover, active, focus, and disabled states.\n - Maintain WCAG AA compliant text-to-background contrast ratios.\n - Ply: hover `hover:bg-slate-50 dark:hover:bg-slate-800`, active `active:bg-slate-100 dark:active:bg-slate-700`, focus `FOCUS_RING` (`focus-visible:` only \u2014 no rings on mouse click), disabled `disabled:opacity-50 disabled:pointer-events-none disabled:cursor-not-allowed`. Icon-only buttons need `aria-label`. `text-slate-400` on white is about 2.6:1 \u2014 allowed only for icons, placeholders, eyebrows, and decorative text; readable muted copy is `text-slate-500 dark:text-slate-400` (4.8:1 / 5.7:1) or darker.\n- **Forms & Feedback:** Forms require inline validation styling, explicit error text, and smooth loading states for asynchronous actions.\n - Ply: `ply-input-group` + `ply-label` + `<input ply-input formControlName="\u2026">` + `<ply-error>` + optional `ply-info-text`. The group reads the projected `NgControl` / `[formField]`: after the field is touched and invalid it shows the red shell, renders `ply-error`, and sets `aria-invalid` + `aria-describedby` \u2014 do not wrap `ply-error` in `@if` or add those attributes by hand; switch messages inside one `ply-error` (`@if (ctrl.hasError(\'required\')) { \u2026 } @else { \u2026 }`). Composite CVA hosts bind `[invalid]` on the group. Submit through `<ply-progress-button type="submit" [loading]="saving()">` (sets `aria-busy`, blocks re-submit). Long operations: `ToastService.promise(op, { loading, success, error })`.\n\n## 3. Micro-Interactions & Transitions\n\n- Keep motion fast, weighted, and natural: `transition-all duration-200 ease-out`.\n - Ply: feedback (hover, press, color) `transition-colors duration-150` \u2013 `duration-200`; enter / expand / height `duration-300` (`openClose`, drawer, bottom sheet); exits are shorter than entries and use `ease-in`. Prefer `transition-colors` / `transition-opacity` / `transition-transform` over `transition-all` when one property changes.\n- Implement subtle state shifts (e.g., `hover:border-slate-400/40` or microscopic `active:scale-[0.99]`).\n - Ply: cards and tiles `hover:border-slate-300 dark:hover:border-slate-600` (or `hover:border-slate-400/40` on dark surfaces) plus optional `active:scale-[0.99]`. `ply-button` already steps its background on hover / active \u2014 do not add scale to it. Reuse `animations.ts` triggers (`dropdown`, `openClose`, `rotate180`, `slideLeft/Right/Top/Bottom`) instead of writing new ones.\n\n## 4. Operational States & Resilience (The Reality)\n\n- **State Coverage:** Every component must gracefully handle Loading (skeleton screens matching exact dimensions), Empty (actionable empty states with copy and CTAs), and Error states.\n- **Data & Text Handling:** Apply explicit line clamping (`line-clamp-2`) or truncation for unpredictable text lengths. Use tabular numerals (`font-tabular-nums`) for all data grids, financial figures, and dashboards.\n- **Motion Orchestration:** Never use jarring transitions. Use swift, purposeful curves (`transition-all duration-200 ease-out`). Modals and overlays must feature subtle backdrop blurs and scale-in entries.\n\nResolve states in this order and render exactly one branch: `loading` \u2192 `error` \u2192 `empty` \u2192 `data`. Every branch lives inside the same container (`ply-card`, same padding, same `min-h-*`) so nothing jumps when the state changes.\n\n### Loading\n\n- `ply-skeleton` is pulse-only (`bg-slate-200 dark:bg-slate-700 animate-pulse`). Variants: `text` (h-3), `circular` (40px), `rectangular`, `card`, `table-row`; `count`, `width` / `height` take CSS strings (`width="100%" height="16rem"`).\n- Match the final layout 1:1 \u2014 same card, same padding, same row count, same column widths. A list of 5 rows loads as `count="5"`; a `16rem` chart loads as a `16rem` rectangle. Never a lone spinner for a region with more than one element.\n- `ply-data-table [loading]="true"` renders its own skeleton rows (`min(pageSize, 5)`); do not wrap it in another skeleton.\n- Lazy chunks: `@defer (on viewport; prefetch on idle) { \u2026 } @placeholder { <ply-skeleton \u2026> } @loading (minimum 300ms) { <ply-skeleton \u2026> }` \u2014 the `minimum` prevents a flash.\n- Refreshing existing data: keep the content and overlay `<ply-loading-overlay [visible]="refreshing()" message="Updating\u2026">` (`aria-busy`, `role="status"`, `bg-white/80 dark:bg-slate-900/80`, no blur \u2014 reserve blur for modals).\n- Async buttons: `<ply-progress-button [loading]="saving()">` \u2014 `loading` is a plain boolean input, so bind it (`[loading]="\u2026"`), not as a bare attribute. Keep the button width stable; the label fades to `opacity-0` under the spinner.\n- `ply-spinner` has no ARIA of its own \u2014 wrap it in `role="status" aria-label="Loading"` or use `ply-loading-overlay`.\n\n### Empty\n\n- Lists: `@for (item of items(); track item.id) { \u2026 } @empty { <ply-empty-state \u2026> }`.\n- `ply-empty-state` takes `iconName`, `title`, `description` and projects the CTA: `<button ply-button color="primary">New project</button>`. Two flavors \u2014 first-run (primary CTA that creates the thing) and filtered / no-results (`iconName="search"`, secondary `ply-stroked-button` "Clear filters"). Never leave an empty state without an action or an explanation.\n- Copy: title names what is missing in \u2264 5 words ("No invoices yet"); description says why or what to do in one sentence; CTA is a verb ("Create invoice", "Clear filters", "Import CSV").\n- Tables: `<ply-data-table emptyMessage="No results for this filter">` or a custom `<ng-template plyTableEmpty>` containing a compact empty state. Chrome strings localize through `provideBaseUiI18n`.\n- Icons that exist for empty states: `inbox`, `search`, `folder`, `folder-open`, `file-text`, `images`, `users`, `calendar`, `cart`, `bell-off`, `filter`.\n\n### Error\n\n- Field: `<ply-error>` inside `ply-input-group` \u2014 the group shows it after touch, paints the shell `border-red-500!`, and wires `aria-invalid` / `aria-describedby`. Write a specific message, never rely on the red border alone, and keep the message under the field (not in a toast).\n- Section / data region: `<ply-alert color="danger" icon="alert-triangle" [close]="true">` with `<ply-alert-actions><button ply-stroked-button color="danger" size="sm" (click)="retry()">Retry</button></ply-alert-actions>`. The host already has `role="alert"`.\n- Whole region failed: `<ply-empty-state iconName="alert-triangle" title="Couldn\'t load orders" description="Check your connection and try again.">` + Retry button. Other error icons: `x-circle`, `alert-circle`, `shield-alert`, `wifi-off`, `file-x`, `folder-x`.\n- Background operations: `ToastService.error(message, { action: { label: \'Retry\', onClick } })`; long jobs: `ToastService.promise()`. Danger / warning toasts announce as `role="alert"` automatically.\n- Destructive confirmation: `DialogService.confirm({ title: \'Delete file?\', description: \'\u2026\', destructive: true })` \u2014 never a one-off confirm dialog.\n- Keep the frame: an error replaces the content inside the same container and `min-h-*`; do not collapse the region or shake / bounce it. Errors are never console-only.\n\n### Data and text\n\n- Tailwind\'s utility is `tabular-nums` (`font-tabular-nums` is not a class). Ply already applies it in `ply-stat-card` (metric), `ply-progress`, `ply-countdown`, `ply-animated-counter`, `ply-meter-group`, and `ply-time-picker` \u2014 apply it to every numeric cell, KPI, price, and axis label you add.\n- Numeric table columns: `align="right"` on the column (renders `text-end`) plus `<ng-template plyTableCell="amount" let-value="value"><span class="tabular-nums whitespace-nowrap">{{ value | currency }}</span></ng-template>`.\n- Single-line text in flex or grid: `min-w-0` on the child, then `truncate`, and `[title]` when the full value matters (IDs, emails, file names).\n- Multi-line descriptions: `line-clamp-2` (cards, list rows) or `line-clamp-3` (article teasers). Titles: `line-clamp-1` / `truncate`.\n- `font-mono` only for IDs, hashes, code, and tokens \u2014 not for money. Dates and currency get `whitespace-nowrap`.\n- `ply-chip` and `ply-tags-input` wrap instead of truncating \u2014 cap visible tags in the template (`+3 more`) when space is fixed.\n\n### Motion orchestration\n\n- Modals: always `DialogService`. The container ships `bg-slate-900/50 dark:bg-slate-900/80 backdrop-blur-sm`, a `scale(0.5\u21921.05\u21921)` entry over 300ms `ease-out`, a mirrored `ease-in` exit, focus trap, Escape, and `aria-modal`. Do not add a second backdrop, animation, or z-index.\n- Sheets and drawers: `ply-bottom-sheet` (`backdrop-blur-sm`, backdrop fades in 200ms `ease-out` / out 150ms `ease-in`, panel slides 300ms) and `ply-drawer` (`backdrop-blur-[8px]`, 300ms slide). Register `provideAnimationsAsync()`.\n- Custom popovers, menus, tooltips: use the `dropdown` trigger from `animations.ts` or CSS `transition-[opacity,transform] duration-200 ease-out` from `opacity-0 scale-95` to `opacity-100 scale-100`; exit `duration-150 ease-in`. Position with `overlayPositions()` / `dropdownConnectedPositions()` from `utils/overlay-position`; layer with `Z_SCALE`.\n- Toasts: the service owns entry, stacking, swipe, and reduced motion \u2014 never animate toasts yourself.\n- Reduced motion is handled globally (`prefers-reduced-motion` block in `index.scss` neutralises CSS animations and transitions). Do not add `motion-reduce:` utilities per element; JS-driven motion checks `window.matchMedia(\'(prefers-reduced-motion: reduce)\')` like `ToastService`.\n- Interactive feedback never exceeds `duration-300`. `duration-500` is only for ambient decoration (the empty-state hover glow).\n\n## Delivery checklist\n\n```\n- [ ] Reused a Ply primitive for every modal, table, toast, empty, loading, and form-field need\n- [ ] Only slate neutrals + --ply-primary accent; semantic colors carry meaning\n- [ ] Control radius rounded-[var(--ply-radius)]; surfaces rounded-xl; rounded-full only on chips/avatars/pills\n- [ ] Standard spacing scale; no [Npx]\n- [ ] Light + dark pair on every color class; icons via stroke-*\n- [ ] Hover, active, focus-visible (FOCUS_RING), and disabled on every interactive element; aria-label on icon-only buttons\n- [ ] Buttons side by side share one size (`default`, `lg`, or `sm`) \u2014 never mix `lg` with default\n- [ ] Loading, empty, error, and data branches render in the same container with matching dimensions\n- [ ] Skeleton row count and sizes mirror the final content; ply-data-table uses [loading]\n- [ ] Empty states have a title, one-sentence description, and a verb CTA\n- [ ] Field errors are ply-error inside ply-input-group (aria wiring is automatic); region errors use ply-alert or ply-empty-state + Retry\n- [ ] tabular-nums + text-end on every numeric column and KPI; truncate/min-w-0 or line-clamp-* on unbounded text\n- [ ] Motion: transition-colors/transition-all 150\u2013200ms feedback, \u2264300ms entries, exits faster; modals via DialogService\n- [ ] Heading/paragraph margins reset with m-0! where needed; logical RTL utilities for new layout\n- [ ] Signals for any state written outside template events (zoneless)\n```\n\n## Additional resources\n\n- Exact inputs, outputs, and class strings for every primitive referenced here: [reference.md](reference.md)\n- Complete copy-paste templates (four-state data card, dashboard KPI grid, dialog form, data table with formatted columns): [examples.md](examples.md)\n- Full component catalog with all APIs: `docs/ai/components.md`\n', "reference.md": "# Ply UI Design \u2014 primitive reference\n\nSelectors, inputs, outputs, and class strings for components installed under `aliases.components` in `ply-ui.json`. Import each component from that folder. Inputs are signal inputs (`input()`); bind with `[name]=\"\u2026\"`. Inputs marked *attr* use `booleanAttribute`, so the bare attribute (`<ply-data-table loading>`) works.\n\n## Shared constants \u2014 `utils/tw-merge.ts`\n\n| Export | Value | Use |\n|---|---|---|\n| `cn(...inputs)` | `twMerge(clsx(inputs))` | merge a component's `class` input over its defaults |\n| `FOCUS_RING` | `outline-none focus-visible:ring-2 focus-visible:ring-[var(--ply-ring)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--ply-background)]` | buttons, links, tiles |\n| `FOCUS_RING_INSET` | `outline-none! focus-visible:ring-2! focus-visible:ring-inset! focus-visible:ring-[var(--ply-ring)]!` | compact controls (calendar cells, chips) |\n| `FOCUS_RING_WITHIN` | `focus-within:ring-2! focus-within:ring-inset! focus-within:ring-[var(--ply-ring)]!` | input-group wrappers with addons |\n| `PRIMARY_SOFT` | `bg-[var(--ply-primary-soft)] text-[var(--ply-primary)]` | selected / highlighted rows and options |\n\n`utils/z-index.ts` \u2014 `Z_SCALE`: sticky `50`, overlay `1000`, popover `1100`, toast `1200`.\n`utils/safe-timer.ts` \u2014 `injectTimers()` returns destroy-scoped `setTimeout` / `setInterval` / `clear` / `clearAll`.\n`utils/overlay-position.ts` \u2014 `overlayPositions(placement, gap, dir)`, `dropdownConnectedPositions()`, `overlayDropUpMetrics()`, `tooltipAbsolutePosition()`.\n\n## Buttons \u2014 `directives/button/`\n\n`[ply-button]` (`BaseButtonDirective`), `[ply-stroked-button]` (`StrokedButtonDirective`), `[ply-icon-button]`, `[ply-icon-stroked-button]`, `[ply-link]`. Inputs: `class`, `color`, `size`, `width`.\n\nBase (filled): `flex items-center gap-2 relative rounded-[var(--ply-radius)] text-center justify-center tracking-wide font-medium transition-all duration-200 cursor-pointer disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 [&_ply-icon]:stroke-current [&_ply-icon]:fill-current` + `FOCUS_RING`. Stroked adds `border` and drops `font-medium`.\n\n| `color` | filled `[ply-button]` | stroked `[ply-stroked-button]` |\n|---|---|---|\n| `primary` | `text-[var(--ply-primary-foreground)]! bg-[var(--ply-primary)] hover:bg-[var(--ply-primary-hover)] active:bg-[var(--ply-primary-active)]` | `text-[var(--ply-primary)]! border-[var(--ply-primary)] hover:bg-blue-50 hover:text-[var(--ply-primary-hover)]! active:bg-blue-100` |\n| `default` | `text-[var(--ply-muted-foreground)]! bg-[var(--ply-muted)] hover:bg-slate-300 dark:hover:bg-slate-700 active:bg-slate-400 dark:active:bg-slate-600` | `text-[var(--ply-muted-foreground)]! border-[var(--ply-border)] hover:bg-slate-50 dark:hover:bg-slate-800 active:bg-slate-100 dark:active:bg-slate-900` |\n| `danger` | `text-[var(--ply-destructive-foreground)]! bg-[var(--ply-destructive)] hover:bg-[var(--ply-destructive-hover)] active:bg-red-800` | `text-[var(--ply-destructive)]! border-[var(--ply-destructive)] hover:bg-red-50 active:bg-red-100` |\n| `success` / `warning` / `accent` | `bg-green-500` / `bg-orange-500` / `bg-purple-500` (+ `-600` hover, `-700` active, `-300` disabled) | `text-*-500! border-*-500 hover:bg-*-50 active:bg-*-100` |\n| `transparent` | `bg-transparent hover:bg-slate-200/50 dark:hover:bg-slate-700/50` | `border-transparent hover:bg-slate-100 dark:hover:bg-slate-800` |\n| `black` / `dark` | `bg-slate-900 dark:bg-black hover:bg-slate-800` | `border-slate-900 dark:border-white hover:bg-slate-900/10 dark:hover:bg-white/10` |\n| `white` | `text-slate-900! bg-white hover:bg-slate-50` | `text-white! border-white hover:bg-white/10` |\n\n| `size` | classes |\n|---|---|\n| `sm` | `h-7 text-xs px-4 [&_ply-icon]:w-3 [&_ply-icon]:h-3` |\n| `default` / `md` | `h-9 text-sm px-6 [&_ply-icon]:w-5 [&_ply-icon]:h-5` |\n| `lg` | `h-10 text-base px-7 [&_ply-icon]:w-6 [&_ply-icon]:h-6` |\n| `xl` | `h-11 text-base px-8 [&_ply-icon]:w-6 [&_ply-icon]:h-6` |\n| `xxl` | `h-14 text-lg px-10 [&_ply-icon]:w-7 [&_ply-icon]:h-7` |\n\nOverrides go through `class` (`class=\"rounded-full w-full\"`); text colors use `!` so they win on `<a>`.\n\n`[ply-icon-button]` \u2014 square, icon-only. Base as filled minus `gap-2 font-medium`; extra colors `secondary` (`bg-slate-500`), `inverted` (`text-white! bg-black/40 hover:bg-black/60 backdrop-blur-sm` \u2014 for buttons over images), `transparent` (`text-[var(--ply-muted-foreground)]! hover:bg-slate-200/50 dark:hover:bg-slate-700/50`). Always give it `aria-label`.\n\n| icon `size` | classes |\n|---|---|\n| `sm` | `h-7 w-7 [&_ply-icon]:w-3 [&_ply-icon]:h-3` |\n| `md` | `h-8 w-8 [&_ply-icon]:w-4 [&_ply-icon]:h-4` |\n| `default` | `h-9 w-9 [&_ply-icon]:w-5 [&_ply-icon]:h-5` |\n| `lg` / `xl` / `xxl` | `h-10 w-10` / `h-11 w-11` (`[&_ply-icon]:w-6`) / `h-14 w-14` (`[&_ply-icon]:w-7`) |\n\n`[ply-link]` \u2014 `inline-flex items-center tracking-wide transition-colors duration-200 [&_ply-icon]:stroke-current`; `color` `default` / `primary` = `text-blue-500 hover:text-blue-700 active:text-blue-900`, `secondary` = `text-slate-500 hover:text-slate-700`, plus `success` / `danger` / `warning` / `accent` on the `-500 \u2192 -700 \u2192 -900` ramp; `size` `sm` `text-xs gap-2`, `default` / `md` `text-sm gap-3`, `lg` `text-base gap-4`, `xl` `text-lg gap-5`. Underline is not included \u2014 add `hover:underline` when a link sits in running text.\n\n## Form fields \u2014 `components/input-group/`, `directives/input-group/`\n\n| Selector | Classes / notes |\n|---|---|\n| `ply-input-group` | host `block w-full`; shell `flex items-center w-full min-h-9 border border-solid border-[var(--ply-border)] rounded-[var(--ply-radius)] relative bg-[var(--ply-background)] shadow-sm focus-within:border-[var(--ply-ring)]!` + `FOCUS_RING_WITHIN`, plus `border-red-500!` while `showErrors()`; projects `ply-label`, `[ply-input]`, `[ply-textarea]`, `ply-mention-input`, `datalist`, `[ply-addon-start]`, `[ply-addon-end]`, `ply-error`, `ply-info-text` |\n| `[ply-input]` | `w-full h-9 px-4 text-sm text-slate-700 dark:text-slate-200 bg-transparent rounded-[var(--ply-radius)] transition-all duration-200 outline-none focus:ring-0 disabled:cursor-not-allowed disabled:text-slate-400 read-only:bg-slate-50 dark:read-only:bg-slate-700` |\n| `ply-label` | `block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1`; `for` is linked to the projected control automatically |\n| `ply-error` | `block text-xs text-red-500 mt-1`; rendered by the group only while `showErrors()` |\n| `ply-info-text` | `block text-xs text-slate-500 dark:text-slate-400 mt-1`; always rendered |\n\n`showErrors()` is `invalid` input **or** (`[formField]` state `invalid && touched`) **or** (`NgControl.touched && errors`). When true the group also sets `aria-invalid=\"true\"` and `aria-describedby=\"<errorId>\"` on the projected input / textarea and removes them when it clears. Consequences: never wrap `ply-error` in `@if`, never set `aria-invalid` yourself inside a group, and use one `ply-error` whose content switches on `hasError(...)`.\n\nComposite CVA hosts (`ply-custom-select`, `ply-multi-select`, `ply-combobox`, `ply-tags-input`, `ply-phone-input`, `ply-currency-input`, `ply-password-input`, `ply-file-upload`, `ply-toggle`, `ply-select`) bind `[(ngModel)]` / `formControlName` / `[formField]` on the host and are placed as siblings of `ply-label`, never inside `ply-input-group`. `ply-select` is a native `<select>` wrapper (`size` `sm`\u2013`xl`) and has no floating panel.\n\n## Card \u2014 `components/card/`\n\n| Part | Classes |\n|---|---|\n| `ply-card` | `block bg-white dark:bg-slate-800 overflow-hidden rounded-xl border border-slate-200 dark:border-slate-700 not-prose`; input `horizontal` (*attr*). No shadow |\n| `ply-card-header` | `flex h-14 font-semibold justify-between items-center border-b border-slate-300 dark:border-slate-700 dark:text-slate-300 dark:bg-slate-800 px-4 not-prose` \u2014 fixed height; pass `class=\"h-auto py-4\"` for a title + subtitle stack, `class=\"h-auto flex-col items-start gap-1 py-4\"` for stacked content |\n| `ply-card-body` | `block p-4 relative overflow-y-auto overflow-x-hidden dark:!text-slate-300` \u2014 content cards pass `class=\"p-6\"`, tables pass `class=\"p-0\"` |\n| `ply-card-footer` | `flex border-t border-slate-300 dark:border-slate-700 p-4 dark:text-slate-300` \u2014 action rows pass `class=\"justify-end gap-2\"` |\n\nAll parts accept `class` merged with `cn()`. Note the header/footer dividers are `slate-300`, one step darker than the card's own `slate-200` border.\n\n## Skeleton \u2014 `ply-skeleton`\n\n| Input | Type | Default |\n|---|---|---|\n| `variant` | `'text' \\| 'circular' \\| 'rectangular' \\| 'card' \\| 'table-row'` | `'text'` |\n| `count` | `number` | `1` |\n| `width` / `height` | CSS string (`\"100%\"`, `\"16rem\"`, `\"40px\"`) | unset; circular defaults to 40px |\n| `animated` | boolean *attr* | `true` |\n\nFill `bg-slate-200 dark:bg-slate-700` + `animate-pulse` (pulse only, no shimmer). Variant shapes: `text` \u2192 `h-3 rounded w-full`; `circular` \u2192 `rounded-full`; `rectangular` \u2192 `rounded-lg`; `card` \u2192 full card preset (`rounded-xl border border-slate-200 dark:border-slate-700 p-4`, avatar + 2 lines + 3 body lines); `table-row` \u2192 `flex items-center gap-4` (avatar, flexible bar, `w-6` bar). Items stack with `space-y-2` (`space-y-3` for cards). Host `block` + `class`.\n\n## Empty state \u2014 `ply-empty-state`\n\nInputs `iconName`, `title`, `description` (all optional strings). CTA via default `<ng-content>`. No size input; wrap in a narrower container to shrink.\n\nRoot `relative overflow-hidden group rounded-3xl p-2 transition-all duration-500 hover:shadow-2xl hover:shadow-slate-500/10 border border-dashed border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900/50 backdrop-blur-xl`. Icon box `w-16 h-16 bg-slate-100 dark:bg-slate-800 rounded-2xl shadow-sm border border-slate-200 dark:border-slate-700 text-slate-400 dark:text-slate-500` with `w-8 h-8 stroke-current` glyph. Title `<h3 class=\"text-2xl font-bold text-slate-900 dark:text-white\">`; description `<p class=\"max-w-80 mx-auto text-sm text-slate-500 dark:text-slate-400 leading-relaxed\">`.\n\n## Loading overlay \u2014 `ply-loading-overlay`\n\n| Input | Type | Default |\n|---|---|---|\n| `visible` | boolean *attr* | `false` |\n| `fullscreen` | boolean *attr* | `false` \u2014 host becomes `contents`, children are not projected |\n| `lockScroll` | boolean *attr* | `false` (auto when fullscreen) |\n| `message` | string | `''` |\n| `size` | `SpinnerSize` | `'lg'` |\n| `color` | `SpinnerColor` | `'primary'` |\n\nHost `relative block` + `aria-busy=\"true\"` while visible. Overlay `absolute inset-0 z-50 flex items-center justify-center bg-white/80 dark:bg-slate-900/80` (fullscreen: `fixed inset-0 z-[10000]`), `role=\"status\" aria-live=\"polite\"`, message `text-sm font-medium text-slate-700 dark:text-slate-200`. No backdrop blur by design.\n\n## Spinner \u2014 `ply-spinner`\n\n`size`: `sm` `w-4 h-4 border` \xB7 `md`/`default` `w-6 h-6 border-2` \xB7 `lg` `w-10 h-10 border-4` \xB7 `xl` `w-12 h-12 border-[5px]`. `color`: `primary` `border-blue-500 border-t-blue-100 border-e-blue-100 border-b-blue-100`, `success`, `danger`, `warning`, `accent`, `inverted` (`border-white border-t-white/20 \u2026`). Base `block rounded-full border-solid animate-spin`. No ARIA \u2014 add `role=\"status\"` + `aria-label` on a wrapper.\n\n## Progress button \u2014 `ply-progress-button`\n\n| Input | Type | Default |\n|---|---|---|\n| `loading` | boolean (plain \u2014 bind `[loading]`) | `false` |\n| `color` / `size` | `ButtonColor` / `ButtonSize` | `'primary'` / `'default'` |\n| `type` | `'button' \\| 'submit' \\| 'reset'` | `'button'` |\n| `disabled` | boolean | `false` |\n| `width` | string | `''` |\n| `spinnerColor` | `SpinnerColor` | derived from `color` |\n\nOutput `clicked` (suppressed while loading or disabled; `preventDefault` blocks submit). While loading: `aria-busy=\"true\"`, `aria-disabled=\"true\"`, `pointer-events-none`, label `opacity-0` under a centered inline spinner (16 / 20 / 22 / 24 / 32 px by size). Native `disabled` is not set during loading, so the button keeps full opacity.\n\n## Alert \u2014 `ply-alert`\n\n| Input | Type | Default |\n|---|---|---|\n| `color` | `'' \\| 'primary' \\| 'success' \\| 'danger' \\| 'accent' \\| 'warning'` | `''` |\n| `variant` | `'soft' \\| 'solid' \\| 'outline'` | `'soft'` |\n| `icon` | icon name | none |\n| `close` | boolean *attr* | `false` |\n| `duration` | ms; `> 0` auto-dismisses | `0` |\n\nOutput `closed`. Host `role=\"alert\"`. Slots: default body, `ply-alert-actions` (`min-w-full flex justify-end gap-2`). Shell `flex flex-col rounded-xl text-sm p-4 gap-3 border transition-all duration-200`. Danger soft: `bg-red-50 text-red-800 border-red-100 dark:bg-red-900/50 dark:text-red-200 dark:border-red-800/70`, icon `w-6 h-6 min-w-6 stroke-red-600 dark:stroke-red-400`; solid `bg-red-600 text-white border-red-700`; outline `bg-transparent text-red-600 border-red-300 dark:text-red-400 dark:border-red-800`. Close button `w-7 h-7 rounded-md -me-2 -mt-2 hover:bg-red-100 dark:hover:bg-red-900/40` with `aria-label=\"Close alert\"`.\n\n## Toast \u2014 `ToastService` (`components/toast/toast.service.ts`)\n\n```ts\nshow(message, config?): number success(message, config?) // icon 'check'\nerror(message, config?) // color 'danger', icon 'alert-triangle', role=\"alert\"\nwarning(message, config?) // color 'warning', icon 'alert-circle', role=\"alert\"\ninfo(message, config?) // color 'primary', icon 'info-circle'\npromise<T>(promiseOrFn, { loading, success, error }, config?): Promise<T> // loading icon 'loader', sticky until settled\ndismiss(id) clearAll()\n```\n\n`ToastConfig`: `color?: ToastColor`, `duration?: number` (default `4000`; `0` = sticky), `icon?`, `position?: ToastPosition` (default `'top-end'`), `action?: { label, onClick, dismiss? }`. Card `pointer-events-auto flex items-center gap-3 px-4 py-3 rounded-xl shadow-xl border max-w-sm w-full`; danger `bg-white text-slate-800 border-red-200 dark:bg-slate-800 dark:text-slate-200 dark:border-red-800`. Stack: 3 visible, `14px` peek, `0.05` scale step, spring `400ms cubic-bezier(0.22, 1.12, 0.36, 1)`, exit `280ms`. Reduced motion (`matchMedia`) switches to a static list with no tweens. The service owns `ply-toast-container` \u2014 never place it in a template. Host `aria-live=\"polite\"`.\n\n## Dialog \u2014 `DialogService` (`components/dialog/dialog.service.ts`)\n\n```ts\nopen<TData, TResult>(type, data?, className?, options?: { hideOnBackdropClick?: boolean; containerType? }): Observable<TResult | undefined>\nconfirm({ title, description?, confirmLabel?, cancelLabel?, destructive? }): Observable<boolean>\n```\n\nInside the opened component: `inject(DialogContext<TData, TResult>)` \u2192 `context.data`, `context.close(result?)`. Parts: `ply-dialog [width]=\"480\"` (number \u2192 px, or any CSS width) `[height]`, `ply-dialog-header` (`flex justify-between items-center p-4 text-lg font-bold border-b border-slate-200 dark:border-slate-800`), `ply-dialog-body` (`p-4 text-sm overflow-y-auto max-h-[calc(100vh-160px)]`), `ply-dialog-footer` (`flex justify-end gap-4 p-4 border-t`), `[ply-dialog-close]` on any button.\n\nContainer: backdrop `absolute inset-0 bg-slate-900/50 dark:bg-slate-900/80 backdrop-blur-sm` (fade 230ms); box `relative bg-white dark:bg-slate-800 rounded-xl shadow overflow-hidden w-fit max-w-[calc(100%-32px)] max-h-[calc(100%-32px)] flex flex-col`, `role=\"dialog\" aria-modal=\"true\"`, enter `scale(0.5) \u2192 1.05 (200ms ease-out) \u2192 1 (100ms)`, leave mirrored with `ease-in`. CDK focus trap, focus restore, Escape, `aria-labelledby` from the first `h1\u2013h3` / `ply-dialog-header`. SSR: `open()` returns `of(undefined)`, `confirm()` `of(false)`. `confirm()` renders `ply-alert-dialog` at width 400 with `role=\"alertdialog\"`.\n\nQuirks: `className` is stored but not bound (no effect). `hideOnBackdropClick: false` also disables Escape (no `context` on the container) \u2014 provide an explicit close button.\n\n## Drawer and bottom sheet\n\n`ply-drawer` \u2014 inputs `size: 'sm' | 'md' | 'lg' | 'xl'` (`w-[200px]` / `w-[400px]` / `w-[40vw]` / `w-[56vw]`; top/bottom `h-40` / `h-[280px]` / `h-[400px]` / `h-[560px]`), `position: DrawerPosition` (default `'end'`), `close` (shows X), `drawerLabel`; output `closed`. Trigger: `[ply-drawer]` directive on a button. Panel `bg-white dark:bg-slate-900 p-4 fixed \u2026 max-w-[92vw] shadow-lg`, `role=\"dialog\" aria-modal=\"true\"`, `cdkTrapFocus cdkTrapFocusAutoCapture`. Backdrop on the overlay: `bg-slate-300/50 dark:bg-slate-800/80 backdrop-blur-[8px]`. Slide `.3s` via `animations.ts`. Requires `provideAnimationsAsync()`.\n\n`ply-bottom-sheet` \u2014 `open` (model), `height: 'auto' | 'half' | 'full' | string` (`max-h-[85vh]` / `h-[50vh]` / `h-[94vh]`), `showHandle` (default true), `showClose`, `sheetLabel`, `dismissible` (default true); output `closed`. Backdrop `fixed inset-0 z-1000 bg-slate-900/50 dark:bg-slate-900/80 backdrop-blur-sm` (`[@fade]` 200ms ease-out / 150ms ease-in). Panel `fixed bottom-0 start-0 end-0 z-1001 mx-auto w-full sm:max-w-140 bg-white dark:bg-slate-900 rounded-t-2xl shadow-2xl`; handle `w-10 h-1 rounded-full bg-slate-300 dark:bg-slate-600`; swipe-down dismiss.\n\n## Data table \u2014 `ply-data-table<T>`\n\n| Input | Type | Default |\n|---|---|---|\n| `columns` | `TableColumn[]` (`key`, `label`, `sortable?`, `width?`, `minWidth?`, `resizable?`, `filterable?`, `align?: 'left' \\| 'center' \\| 'right'`) | `[]` |\n| `data` | `T[]` | `[]` |\n| `loading` | boolean *attr* \u2014 renders `min(pageSize, 5)` pulse rows | `false` |\n| `emptyMessage` | string (default i18n \"No data available\") | unset |\n| `sortable`, `filterable`, `pageable`, `striped`, `bordered`, `hoverable`, `selectable`, `resizable`, `serverSide`, `virtualize` | boolean *attr* | `false` (`hoverable` `true`) |\n| `pageSize` / `currentPage` (model) / `totalItems` | number | `10` / `1` / `0` |\n| `rowKey` | string | `'id'` |\n| `selected` (model) | `(string \\| number)[]` | `[]` |\n| `columnFilters` (model) | `Record<string, string>` | `{}` |\n| `rowHeight` / `viewportHeight` | px (virtualize) | `44` / `400` |\n\nOutputs: `rowClick`, `sortChange`, `filterChange`, `pageChange`, `selectionChange`. Templates: `<ng-template plyTableCell=\"key\" let-row let-value=\"value\">`, `<ng-template plyTableEmpty>`. No `error` input \u2014 render `ply-alert` above the table or swap the table for an error state.\n\nClasses: wrapper `overflow-x-auto rounded-xl`; header `border-b border-slate-200 dark:border-slate-700 bg-slate-50 dark:bg-slate-800/50`, head cell `py-3 px-4 text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400`; body cell `py-3 px-4 text-sm text-slate-700 dark:text-slate-300` + `text-start` / `text-center` / `text-end`; row `transition-colors duration-150 cursor-pointer hover:bg-slate-50 dark:hover:bg-slate-800/30`, striped `bg-slate-50/50 dark:bg-slate-800/20`, selected `bg-blue-50 dark:bg-blue-950/30`; empty row `py-8 px-4 text-center text-sm text-slate-500 dark:text-slate-400`; skeleton bars `h-3 bg-slate-200 dark:bg-slate-700 rounded animate-pulse`. Header is not sticky; cells do not truncate or use `tabular-nums` unless your `plyTableCell` template adds them.\n\nPrimitive table: `table[ply-table]` `w-full caption-bottom text-sm`; `thead[ply-table-header]`; `tr[ply-table-row]` `border-b border-[var(--ply-border)] transition-colors hover:bg-[var(--ply-muted)]/50`; `th[ply-table-head]` `h-10 px-4 text-start align-middle text-xs font-medium text-[var(--ply-muted-foreground)]`; `td[ply-table-cell]` `p-4 align-middle`.\n\n## Numbers\n\n- `ply-stat-card` \u2014 `variant: 'classic' | 'metric'`, `value`, `label`, `trend?`, `trendUp?`, `icon?`, `color: StatCardColor`, `caption?`, `delta?: number`, `series?: readonly number[]`. Metric label `text-[11px] font-bold uppercase tracking-[0.18em] text-slate-400`; value `truncate text-[30px] font-black leading-none tracking-tight text-slate-900 tabular-nums dark:text-white`; delta pill `rounded-full border px-2 py-0.5 text-[11px] font-semibold tabular-nums`. Classic tile `rounded-xl border border-slate-200 bg-white p-6 dark:border-slate-700 dark:bg-slate-800`, value `text-2xl font-bold` (no `tabular-nums` \u2014 add via `class` if needed).\n- `ply-animated-counter` \u2014 `value`, `from`, `duration` (1500), `decimals`, `prefix`, `suffix`, `separator`; renders `<span class=\"tabular-nums\">`; jumps to the final value under SSR.\n- `ply-countdown` \u2014 `targetDate` (required); digits `text-3xl font-bold tabular-nums`, labels `text-xs font-medium text-slate-400 uppercase tracking-wider`.\n- `ply-progress` \u2014 `role=\"progressbar\"` with `aria-valuenow`; track `rounded-full bg-slate-200 dark:bg-slate-700`, fill `transition-all duration-300`, value bubble `text-xs font-semibold tabular-nums`.\n- `ply-meter-group` \u2014 `role=\"meter\"`; legend values `tabular-nums text-slate-500 dark:text-slate-400`.\n\n## Avatar, badge, chip\n\n- `ply-avatar` \u2014 `avatarUrl`, `initials`, `shape: 'circle' | 'square'` (default square), `size: 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'full'` (`w-6` \u2026 `w-14`), `status: 'active' | 'inactive'`. Fallback order: image \u2192 initials (`uppercase font-medium text-slate-700 dark:text-slate-200`) \u2192 `user` icon. `role=\"img\"` with `aria-label`.\n- `ply-badge` \u2014 base `h-6 flex gap-1 border border-transparent justify-center items-center px-3 text-xs! whitespace-nowrap text-white bg-slate-400`; `shape=\"rectangular\"` \u2192 `rounded-md`, every other shape \u2192 `rounded-full`. Solid colors use 600/700 shades so white text passes AA: `primary` `bg-blue-600!`, `danger` `bg-red-600!`, `success` `bg-green-700!`, `accent` `bg-purple-600!`, `warning` `bg-orange-700!`. Soft colors: `default` `bg-slate-100! border-slate-300! text-slate-700!`, `transparent` `bg-transparent! border-slate-300! text-slate-700!`, `slate-light` `bg-slate-200! border-slate-500! text-slate-800!`, `*-light` (`primary`, `danger`, `success`, `accent`, `warning`) `bg-*-100! border-*-400! text-*-800!`. Sizes: `sm` `h-5! text-xs! px-2!`, `md` / `default` `h-6! text-sm!`, `lg` `h-7! text-base! px-4!`, `xl` `h-8! text-xl! px-4!`. Status badges in tables use `size=\"sm\" shape=\"rounded\"` and a `*-light` color; count badges use solid.\n- `ply-chip` \u2014 `color: '' | 'primary' | 'success' | 'danger' | 'warning' | 'accent'`, `size: 'sm' | 'md' | 'default' | 'lg'`; `sm` is `text-xs px-2 py-0.5 rounded-full`. Chips and `ply-tags-input` wrap; they never truncate.\n\n## Selection controls \u2014 `ply-checkbox`, `ply-toggle`, `ply-radio-group` / `ply-radio-button`\n\n| Control | Box / track | Checked | Notes |\n|---|---|---|---|\n| `ply-checkbox` | `w-4 h-4 appearance-none bg-slate-50 dark:bg-slate-800 border border-slate-300 dark:border-slate-600 hover:border-slate-400 rounded me-2 mt-1 peer` | `checked:bg-blue-500 checked:border-blue-500 checked:hover:border-blue-600` (per `color`: blue / red / green / purple / orange `-500`) | `indeterminate` input mirrors checked colors; `[ariaLabel]` for label-less boxes; label is projected content |\n| `ply-toggle` | container `h-5 w-9` (`sm`) / `h-6 w-11` (`md`) / `h-7 w-[52px]` (`lg`); track `bg-slate-200 dark:bg-slate-700 border border-slate-300 dark:border-slate-700 transition-all duration-300`, shape `smooth` `rounded-lg` / `rounded` `rounded-full` / `square` `rounded-md` | `peer-checked:bg-blue-500 peer-checked:border-blue-500` (per `color`) | thumb `bg-white start-0.5 top-0.5 transition-all duration-300`, `h-4` / `h-5` / `h-6`, slides `translate-x-4` / `-5` / `-6` with RTL mirror; `[ariaLabel]` for standalone switches |\n| `ply-radio-button` | `w-4 h-4 appearance-none bg-slate-50 dark:bg-slate-600 border border-slate-300 dark:border-slate-700 rounded-full me-2 peer` + its own `focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2` | `checked:bg-blue-500! checked:border-blue-500!` (per `color`; `warning` uses `orange-400`) | inputs `value`, `label`, `name`, `color`, `disabled`; place inside `ply-radio-group` which owns the value |\n\nAll three are CVAs (`ngModel`, `formControlName`, `[formField]`) with `disabled` input + forms-disabled merging. Selection controls are the one place the library uses raw `blue-500` instead of `--ply-primary`; match that when hand-styling a custom control next to them.\n\n## Overlays \u2014 dropdown, popover, tooltip\n\n| Primitive | Usage | Panel classes |\n|---|---|---|\n| `ply-dropdown-menu` + `[ply-dropdown-menu-trigger]=\"menu\"` | `<ply-dropdown-menu #menu size=\"200px\"><ply-dropdown-menu-item>\u2026</ply-dropdown-menu-item></ply-dropdown-menu>`; `placement` `start` / `end` / `left` / `right` / `top-start` / `top-end` on the trigger; `(closed)` output | `bg-white dark:bg-slate-800 rounded-md shadow-xl border border-slate-200 dark:border-slate-700 overflow-hidden min-w-40 outline-none`; enters with the `dropdown` animation (`.3s .1s` from `translateY(15px)`) |\n| `ply-dropdown-menu-item` | `[(checked)]` turns it into `menuitemcheckbox` and keeps the menu open; `stayOpen` attr; `class` merges | `w-full px-6 h-12 flex items-center text-sm transition-colors duration-200 cursor-pointer hover:bg-slate-100 dark:hover:bg-slate-700 text-slate-600 dark:text-slate-300 whitespace-nowrap not-prose outline-none focus-visible:bg-slate-100 dark:focus-visible:bg-slate-700`; keyboard: arrows, Home / End, typeahead, Escape, submenu via `aria-haspopup=\"menu\"` |\n| `ply-popover` | `<ply-popover placement=\"bottom-start\" minWidth=\"280px\"><button popover-trigger ply-button>Open</button><div>panel</div></ply-popover>`; or an external `[ply-popover-trigger]=\"pop\"`; `PopoverPlacement` = `top` / `bottom` / `left` / `right` / `*-start` / `*-end` | CDK overlay with transparent backdrop, `8px` gap, `reposition` scroll strategy; panel `rounded-xl border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 p-4 shadow-2xl`; focuses first focusable element on open, restores focus on close, Escape closes |\n| `[ply-tooltip]=\"text\"` | `tooltipPlacement` (`top` default / `bottom` / `left` / `right` \u2014 never bare `placement`, it collides with dropdown / drawer), `type=\"dark\"` (default) or `\"light\"`, `delay`, `tooltipClass`, `canShow` | `text-sm rounded-md text-center px-2 py-1 max-w-xs pointer-events-none transition-all duration-300 shadow-lg block relative` + `bg-slate-900 text-white` (dark) or `bg-white text-slate-700 border border-slate-200` (light), 4px arrow; slides in from `translate-y-2` / `-translate-x-2`; sets `aria-describedby` on the host while visible; hides on leave, blur, Escape, scroll. Text only \u2014 no interactive content |\n\nLayering: overlays are CDK overlays (viewport-attached), so they escape `overflow: hidden` parents. Custom floating panels use `Z_SCALE` from `utils/z-index.ts` and `overlayPositions()` / `dropdownConnectedPositions()` / `tooltipAbsolutePosition()` from `utils/overlay-position.ts`.\n\n## Tabs \u2014 `ply-tabs`\n\n`<ply-tabs type=\"underline\" [defaultTab]=\"0\" (tabChanged)=\"\u2026\">` with `<ply-tab label=\"\u2026\">` (or `<ply-tab-label>` for icon + text) and `<ply-tab-body>`. Inputs `type` (`underline` / `pills` / `folder` / unset), `position` (`left` / `center` / `right` / `full-width`), `ariaLabel`, `labelledBy`, `scrollRestricted`. Roving-tabindex tablist with arrow / Home / End keys and scroll arrows on overflow.\n\n| Piece | Classes |\n|---|---|\n| tab (all types) | `px-8 h-10 flex items-center text-sm bg-transparent whitespace-nowrap dark:text-slate-400 border-0` + `FOCUS_RING` |\n| `underline` list / active | list `border-b border-slate-300 dark:border-slate-700`; active `text-[var(--ply-primary)]! shadow-[0_1px_0_var(--ply-primary)]`; inactive hover `hover:shadow-tab` |\n| `pills` | `bg-slate-100 dark:bg-slate-600 dark:text-slate-200 rounded-md! me-2 transition-color duration-300`; active `text-[var(--ply-primary-foreground)]! bg-[var(--ply-primary)]!` |\n| `folder` list / active | list `bg-slate-100 dark:bg-slate-900 rounded-t-lg pt-1 px-1`; active `bg-white dark:bg-slate-800 text-[var(--ply-primary)] rounded-t-md` |\n\nUse `underline` for page sections, `pills` for view switches inside a card, `folder` for editor-like surfaces. Tab panels are `role=\"tabpanel\" tabindex=\"0\"`.\n\n## Divider, kbd, code, typography\n\n- `ply-divider` \u2014 host `block`; renders `w-full h-px bg-slate-300 dark:bg-slate-600` (or `w-px h-full` with `vertical`). One step darker than card borders; for a same-tone rule inside a card use `border-t border-slate-200 dark:border-slate-700` instead.\n- `ply-kbd` \u2014 `inline-flex h-6 min-w-6 items-center justify-center rounded-md border border-slate-200 bg-slate-50 px-1.5 font-mono text-xs font-medium text-slate-700 shadow-[0_1px_0_0_rgb(226_232_240)] dark:border-slate-600 dark:bg-slate-800 dark:text-slate-200`. Use for shortcuts in menus, empty states, and command palettes.\n- `ply-code` \u2014 `language` (`HTML`, `TypeScript`, `JavaScript`, `Bash`), `[(showCode)]`; host `block not-prose`; ships its own copy button (`ply-icon-button` + tooltip + toast). Inline code in prose: `<code class=\"rounded bg-slate-100 px-1.5 py-0.5 font-mono text-[0.85em] text-slate-800 dark:bg-slate-800 dark:text-slate-200\">`.\n- `[plyTypography]` / `[plyHeadingText]` (`directives/typography/ply-heading.directive.ts`) \u2014 base `text-slate-900 dark:text-white` + variant: `display` `text-4xl font-bold tracking-tight sm:text-5xl`, `title` `text-3xl font-bold tracking-tight`, `heading` (default, also `plyHeadingText`) `text-2xl font-semibold tracking-tight`, `subheading` `text-lg font-medium`, `body` `text-base font-normal text-slate-600 dark:text-slate-300`, `caption` `text-sm text-slate-500 dark:text-slate-400`, `overline` `text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400`. `class` merges through `cn()`, so `class=\"text-xl\"` overrides the size.\n\n## Type unions\n\n| Type | Values |\n|---|---|\n| `ButtonColor` / `StrokedButtonColor` | `primary` `success` `danger` `warning` `accent` `white` `black` `default` `secondary` `dark` `slate` `transparent` |\n| `ButtonSize` / `StrokedButtonSize` / `IconButtonSize` / `GroupButtonSize` | `sm` `default` `md` `lg` `xl` `xxl` |\n| `IconButtonColor` | as `ButtonColor` minus `dark` `slate`, plus `inverted` |\n| `LinkColor` / `LinkSize` | `primary` `success` `danger` `warning` `accent` `default` `secondary` / `sm` `md` `default` `lg` `xl` |\n| `BadgeColor` | `primary` `danger` `success` `accent` `warning` `default` `transparent` `slate-light` `primary-light` `danger-light` `success-light` `accent-light` `warning-light` |\n| `BadgeSize` / `BadgeShape` | `sm` `md` `default` `lg` `xl` / `rectangular` `circle` `rounded` `pill` |\n| `AlertColor` / `AlertVariant` | `''` `primary` `success` `danger` `accent` `warning` / `soft` `solid` `outline` |\n| `ToastColor` / `ToastPosition` | `primary` `success` `danger` `warning` `accent` / `top-*` `bottom-*` \xD7 `left` `right` `center` `start` `end` |\n| `CheckboxColor` / `RadioColor` / `ToggleColor` / `SliderColor` / `ProgressColor` / `ChartColor` | `primary` `danger` `success` `accent` `warning` |\n| `ToggleSize` / `ToggleShape` | `sm` `md` `default` `lg` / `rounded` `square` `smooth` |\n| `AvatarSize` / `AvatarShape` / `AvatarStatus` | `xs` `sm` `md` `lg` `xl` `full` / `circle` `square` / `active` `inactive` |\n| `ChipColor` / `ChipSize` | `''` `primary` `success` `danger` `warning` `accent` / `sm` `md` `default` `lg` |\n| `SpinnerColor` / `SpinnerSize` | `primary` `success` `danger` `warning` `accent` `inverted` / `sm` `md` `default` `lg` `xl` |\n| `SkeletonVariant` | `text` `circular` `rectangular` `card` `table-row` |\n| `StatCardColor` / `StatCardVariant` / `TimelineColor` | `primary` `success` `danger` `warning` `accent` `default` / `classic` `metric` / as `StatCardColor` |\n| `DropdownPlacement` / `PopoverPlacement` / `TooltipPlacement` | `start` `end` `left` `right` `top-start` `top-end` / `top` `bottom` `left` `right` `top-start` `top-end` `bottom-start` `bottom-end` / `top` `bottom` `left` `right` |\n| `DrawerPosition` / `DrawerSize` / `BottomSheetHeight` | `left` `right` `top` `bottom` `start` `end` / `sm` `md` `lg` `xl` or CSS length / `auto` `half` `full` or CSS length |\n| `TableColumn` | `{ key, label, sortable?, width?, minWidth?, resizable?, filterable?, align?: 'left' \\| 'center' \\| 'right' }`; `TableSortDirection` `asc` `desc` `''` |\n| `ComboboxOption` / `ToastAction` / `MeterGroupItem` / `ChartDataPoint` | `{ value, label, disabled?, description? }` / `{ label, onClick, dismiss? }` / `{ label, value, color?, icon? }` / `{ label, value, color? }` |\n\n`start` / `end` variants follow `dir` (RTL-aware); `left` / `right` are physical. Prefer `start` / `end` in shared components.\n\n## Statistical profile of the library (for judgement calls)\n\n- Neutrals are slate only (~5.5k `slate-*` utilities, zero `zinc` / `gray` / `neutral`). Brand blue is `--ply-primary` on interactive surfaces, raw `blue-500` on selection controls and links, `blue-600` on solid badges.\n- Spacing uses the standard scale (`p-4`, `gap-2`). Do not introduce `[Npx]` for padding, margin, or gap. A fixed control size that is not on the scale may stay a pixel value.\n- Radius: controls `rounded-[var(--ply-radius)]` (0.5rem default), surfaces `rounded-xl`, menus / tooltips / kbd `rounded-md`, checkbox `rounded`, radio / chips / avatars `rounded-full`.\n- Motion: `duration-200` dominates (`transition-all` on controls, `transition-colors` on rows and links); `duration-300` for toggles, tooltips, tab pills, progress; `duration-150` only for row hovers and exit transitions. No `motion-reduce:` utilities \u2014 honor `prefers-reduced-motion` in the global stylesheet.\n- Text: `text-sm` is the default UI size; `text-xs` for meta, errors, hints, badges; `text-base` only for `body` prose and `lg` controls. `tabular-nums` already ships in stat-card, progress, countdown, animated-counter, meter-group, time-picker. `tracking-widest` is unused \u2014 the widest tracking in the library is `tracking-wider` (`overline`, table heads) and `tracking-[2px]` on form-block labels.\n\n## Motion inventory\n\n| Where | Values |\n|---|---|\n| `ply-button`, `[ply-input]`, `ply-alert` | `transition-all duration-200` |\n| table rows, list rows | `transition-colors duration-150` |\n| `animations.ts` triggers | `dropdown` (enter `.3s .1s` from `translateY(15px)`), `openClose` (height `.3s`), `rotate180`, `tabAnimation`, `slideLeft/Right/Top/Bottom` (`.3s`) |\n| Dialog | box 200ms + 100ms `ease-out` in, 100ms + 200ms `ease-in` out; backdrop 230ms |\n| Bottom sheet | backdrop 200ms `ease-out` / 150ms `ease-in`; panel 300ms |\n| Toast | spring `400ms cubic-bezier(0.22, 1.12, 0.36, 1)`, exit 280ms |\n| `ply-progress` fill | `duration-300`; tooltip bubble `scale-95 \u2192 scale-100 duration-150 ease-out` |\n| Global | `@media (prefers-reduced-motion: reduce)` in the global stylesheet sets animation and transition durations to `0.01ms` |\n\n## Icon names\n\nThe outline sprite (`icons.svg`) has 392 `<symbol>` tags and 389 unique names (`user`, `share`, and `shuffle` each appear twice). The filled sprite (`icons-filled.svg`) has `star` and `heart` only.\n\nState pairings the library already uses: empty `inbox` / `search` / `folder`; error `alert-triangle`; warning `alert-circle`; success `check`; info `info-circle`; loading `loader`; close `close`; avatar fallback `user`; trend `trend-up` / `trend-down`, `arrow-up` / `arrow-down`.\n\naccessibility, activity, airplay, alarm-clock, alert-circle, alert-octagon, alert-triangle, align-center, align-justify, align-left, align-right, app-window, archive, area-chart, arrow-down, arrow-down-circle, arrow-down-left, arrow-down-right, arrow-in, arrow-in-circle, arrow-in-square, arrow-left, arrow-left-circle, arrow-out, arrow-out-circle, arrow-out-square, arrow-right, arrow-right-circle, arrow-up, arrow-up-circle, arrow-up-left, arrow-up-right, at-sign, attach, award, aws, backward, backward-step, badge-check, bar-chart, bar-chart-2, bar-chart-3, bar-chart-4, barcode, battery, battery-charging, bell, bell-off, blocks, blog, bluetooth, bold, bolt, book, book-open, bookmark, bot, box, boxes, brain, briefcase, bug, building, cable, calculator, calendar, calendar-check, calendar-plus, calendar-x, camera, camera-off, car, cart, cast, check, check-circle, check-square, checkbox, chevron-down, chevron-left, chevron-right, chevron-up, chevrons-down, chevrons-left, chevrons-right, chevrons-up, circle, circle-dashed, clipboard, clipboard-check, clipboard-list, clock, close, cloud, code, command, compas, component, contact, cookie, copy, corner-down-left, corner-down-right, corner-left-down, corner-left-up, corner-right-down, corner-right-up, corner-up-left, corner-up-right, cpu, credit-card, crop, cut, dark, database, diamond, disc, doc, dollar-sign, download, download-cloud, edit, edit-box, edit-line, elements, elements-ui, email, eraser, expand, export, external-link, eye, eye-not, facebook, figma, file, file-check, file-code, file-minus, file-minus-2, file-plus, file-plus-2, file-search, file-text, file-x, file-x-2, file-zip, film, filter, fingerprint, flag, flip-horizontal, flip-vertical, folder, folder-minus, folder-minus-2, folder-open, folder-plus, folder-plus-2, folder-search, folder-x, folder-x-2, forbidden, forward, forward-step, gamepad, gauge, gift, git-branch, git-commit, git-merge, git-pull-request, github, glob, grid, grid-ui, group, handshake, hard-drive, hash, heading, headphones, heart, heart-off, help-circle, hexagon, highlighter, history, home, home-2, hourglass, id-card, images, img, import, inbox, indent, info, info-circle, instagram, italic, key, keyboard, languages, laptop, layers, layout, layout-2, layout-3, layout-4, layout-5, layout-6, layout-columns, layout-grid, layout-rows, layout-sidebar, layout-topbar, library, life-buoy, light, line-chart, line-chart-2, link, linkedin, list, list-checks, list-ordered, list-todo, loader, location, lock, mail-open, map, map-marker-2, map-marker-3, maximize, megaphone, menu, message, mic, mic-off, minimize, minus, minus-circle, minus-square, monitor, more, more-horisontal, mouse, move, music, network, notebook, octagon, outdent, package, paintbrush, palette, panel-bottom, panel-left, panel-right, panel-top, paste, pause, pause-circle, pentagon, percent, phone, phone-call, phone-off, pie-chart, pie-chart-2, pie-chart-3, pie-chart-4, pie-chart-5, pin, plane, play, play-circle, plug, plus, plus-circle, plus-square, power, printer, puzzle, qr-code, quote, radar, radio, receipt, refresh, repeat, replace, reply, rocket, rotate-left, rotate-right, rss, save, scale, scan, scan-face, scan-line, scissors, search, search-minus, search-plus, send, server, settings, share, shield, shield-alert, shield-check, shopping-bag, shuffle, signal, slack, sliders, smile, sparkles, speaker, split, square, square-dashed, star, sticky-note, stop, stop-circle, store, strikethrough, table, table-2, tablet, tag, target, templates, templates-ui, terminal, thumb-down, thumb-up, ticket, timer, toggle-left, toggle-right, translate, trash, trend-down, trend-up, triangle, trophy, truck, tv, type, underline, ungroup, unlink, unlock, unplug, upload, upload-cloud, usb, user, user-check, user-circle, user-cog, user-minus, user-plus, user-x, users, video, video-off, volume, volume-1, volume-x, wallet, wand, warehouse, watch, wifi, wifi-off, workflow, wrap-text, x, x-circle, x-square, x-twitter, zap\n", "examples.md": '# Ply UI Design \u2014 complete examples\n\nAll templates use verified Ply APIs. In the demo app import classes from `\'Base\'`; in the library use relative paths; consumers import from the copied `src/app/components/<name>/` files. Class names: `CardComponent`, `CardHeaderComponent`, `CardBodyComponent`, `CardFooterComponent`, `AvatarComponent`, `SkeletonComponent`, `EmptyStateComponent`, `AlertComponent`, `AlertActionsComponent`, `StatCardComponent`, `ProgressButtonComponent`, `LoadingOverlayComponent`, `DataTableComponent`, `TableCellDirective`, `TableEmptyDirective`, `BadgeComponent`, `InputGroupComponent`, `LabelComponent`, `ErrorComponent`, `InfoTextComponent`, `BaseInputDirective`, `BaseButtonDirective`, `StrokedButtonDirective`, `IconButtonDirective`, `IconComponent`, `DialogComponent`, `DialogHeaderComponent`, `DialogBodyComponent`, `DialogFooterComponent`, `DialogCloseDirective`, `DialogContext`, `DialogService`, `ToastService`.\n\n## 1. Four-state data region (loading \u2192 error \u2192 empty \u2192 data)\n\nOne container, one `min-h`, one branch rendered at a time. The skeleton mirrors the final row layout (avatar + two lines + amount) and row count.\n\n```ts\n@Component({\n selector: \'app-recent-orders\',\n changeDetection: ChangeDetectionStrategy.OnPush,\n imports: [\n CardComponent, CardHeaderComponent, CardBodyComponent, AvatarComponent,\n SkeletonComponent, EmptyStateComponent, AlertComponent, AlertActionsComponent,\n BaseButtonDirective, StrokedButtonDirective, IconComponent, CurrencyPipe, DatePipe,\n ],\n templateUrl: \'./recent-orders.component.html\',\n})\nexport class RecentOrdersComponent {\n private readonly api = inject(OrdersApi);\n\n readonly orders = signal<Order[]>([]);\n readonly loading = signal(true);\n readonly error = signal<string | null>(null);\n readonly filterActive = signal(false);\n\n constructor() {\n this.load();\n }\n\n load(): void {\n this.loading.set(true);\n this.error.set(null);\n this.api.recent().subscribe({\n next: (rows) => { this.orders.set(rows); this.loading.set(false); },\n error: () => { this.error.set("Couldn\'t load recent orders."); this.loading.set(false); },\n });\n }\n}\n```\n\n```html\n<ply-card>\n <ply-card-header class="h-auto py-4">\n <div>\n <p class="m-0! text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400">Sales</p>\n <h3 class="m-0! text-xl font-bold tracking-tight text-slate-900 dark:text-white">Recent orders</h3>\n </div>\n <button ply-stroked-button size="sm" (click)="load()" [disabled]="loading()">\n <ply-icon name="refresh" class="h-4 w-4"></ply-icon>\n Refresh\n </button>\n </ply-card-header>\n\n <ply-card-body class="min-h-80 p-6">\n @if (loading()) {\n <ul class="m-0! list-none space-y-4 p-0" aria-busy="true" aria-label="Loading recent orders">\n @for (i of [1, 2, 3, 4, 5]; track i) {\n <li class="flex items-center gap-3">\n <ply-skeleton variant="circular" width="40px" height="40px"></ply-skeleton>\n <div class="min-w-0 flex-1 space-y-2">\n <ply-skeleton width="60%"></ply-skeleton>\n <ply-skeleton width="35%"></ply-skeleton>\n </div>\n <ply-skeleton width="4.5rem" height="1rem"></ply-skeleton>\n </li>\n }\n </ul>\n } @else if (error()) {\n <ply-alert color="danger" icon="alert-triangle">\n {{ error() }}\n <ply-alert-actions>\n <button ply-stroked-button color="danger" size="sm" (click)="load()">Retry</button>\n </ply-alert-actions>\n </ply-alert>\n } @else {\n <ul class="m-0! list-none divide-y divide-slate-200 p-0 dark:divide-slate-700">\n @for (order of orders(); track order.id) {\n <li class="flex items-center gap-3 py-3 first:pt-0 last:pb-0">\n <ply-avatar size="md" shape="circle" [initials]="order.customerInitials"></ply-avatar>\n <div class="min-w-0 flex-1">\n <p class="m-0! truncate text-sm font-medium text-slate-900 dark:text-white" [title]="order.customer">\n {{ order.customer }}\n </p>\n <p class="m-0! line-clamp-1 text-xs text-slate-500 dark:text-slate-400">\n #{{ order.id }} \xB7 {{ order.placedAt | date: \'mediumDate\' }}\n </p>\n </div>\n <span class="shrink-0 text-sm font-semibold tabular-nums text-slate-900 dark:text-white">\n {{ order.total | currency: order.currency }}\n </span>\n </li>\n } @empty {\n @if (filterActive()) {\n <ply-empty-state iconName="search" title="No matching orders"\n description="Nothing matches the current filter. Clear it to see all orders.">\n <button ply-stroked-button (click)="filterActive.set(false)">Clear filter</button>\n </ply-empty-state>\n } @else {\n <ply-empty-state iconName="inbox" title="No orders yet"\n description="Orders appear here as soon as a customer checks out.">\n <button ply-button color="primary">Create test order</button>\n </ply-empty-state>\n }\n }\n </ul>\n }\n </ply-card-body>\n</ply-card>\n```\n\n## 2. Dashboard KPI grid with a matching skeleton\n\nThe skeleton grid uses the same `grid` classes and card chrome as the metrics, so the page does not reflow when data lands.\n\n```html\n<section aria-labelledby="kpi-heading">\n <h2 id="kpi-heading" class="sr-only">Key metrics</h2>\n\n @if (loading()) {\n <div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4" aria-busy="true">\n @for (i of [1, 2, 3, 4]; track i) {\n <div class="rounded-xl border border-slate-200 bg-white p-6 dark:border-slate-700 dark:bg-slate-800">\n <ply-skeleton width="45%" height="0.6rem" class="mb-4"></ply-skeleton>\n <ply-skeleton width="70%" height="1.875rem" class="mb-4"></ply-skeleton>\n <ply-skeleton variant="rectangular" width="100%" height="3rem"></ply-skeleton>\n </div>\n }\n </div>\n } @else {\n <div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">\n <ply-stat-card variant="metric" label="Revenue" [value]="kpi().revenue | currency: \'USD\' : \'symbol\' : \'1.0-0\'"\n [delta]="kpi().revenueDelta" [series]="kpi().revenueSeries" caption="vs. last 30 days"></ply-stat-card>\n <ply-stat-card variant="metric" label="Orders" [value]="kpi().orders | number"\n [delta]="kpi().ordersDelta" [series]="kpi().ordersSeries" color="success"></ply-stat-card>\n <ply-stat-card variant="metric" label="Refund rate" [value]="kpi().refundRate | percent: \'1.1-1\'"\n [delta]="kpi().refundDelta" color="warning"></ply-stat-card>\n <ply-stat-card variant="metric" label="Avg. order" [value]="kpi().avgOrder | currency: \'USD\'"\n [delta]="kpi().avgOrderDelta"></ply-stat-card>\n </div>\n }\n</section>\n```\n\nAnything numeric you write yourself follows the same recipe as the stat card value:\n\n```html\n<p class="m-0! text-[30px] font-black leading-none tracking-tight tabular-nums text-slate-900 dark:text-white">\n {{ total() | currency }}\n</p>\n```\n\n## 3. Dialog form with inline validation, async submit, and destructive confirm\n\nOpen with `inject(DialogService).open<ProjectDraft | undefined, ProjectDraft>(ProjectDialogComponent, existing)`. The container supplies backdrop blur, scale-in, focus trap, Escape, and labelling \u2014 the dialog component adds nothing for those.\n\n```ts\n@Component({\n selector: \'app-project-dialog\',\n changeDetection: ChangeDetectionStrategy.OnPush,\n imports: [\n ReactiveFormsModule,\n DialogComponent, DialogHeaderComponent, DialogBodyComponent, DialogFooterComponent, DialogCloseDirective,\n InputGroupComponent, LabelComponent, BaseInputDirective, ErrorComponent, InfoTextComponent,\n ProgressButtonComponent, StrokedButtonDirective, IconButtonDirective, IconComponent,\n ],\n templateUrl: \'./project-dialog.component.html\',\n})\nexport class ProjectDialogComponent {\n private readonly context = inject<DialogContext<ProjectDraft | undefined, ProjectDraft>>(DialogContext);\n private readonly dialog = inject(DialogService);\n private readonly toast = inject(ToastService);\n private readonly api = inject(ProjectsApi);\n private readonly fb = inject(NonNullableFormBuilder);\n\n readonly isEdit = !!this.context.data;\n readonly saving = signal(false);\n readonly form = this.fb.group({\n name: [this.context.data?.name ?? \'\', [Validators.required, Validators.maxLength(60)]],\n owner: [this.context.data?.owner ?? \'\', [Validators.required, Validators.email]],\n });\n\n save(): void {\n if (this.form.invalid) { this.form.markAllAsTouched(); return; }\n this.saving.set(true);\n this.api.save(this.form.getRawValue()).subscribe({\n next: (saved) => { this.toast.success(\'Project saved\'); this.context.close(saved); },\n error: () => { this.saving.set(false); this.toast.error("Couldn\'t save project", { action: { label: \'Retry\', onClick: () => this.save() } }); },\n });\n }\n\n remove(): void {\n this.dialog.confirm({\n title: \'Delete this project?\',\n description: \'Members lose access immediately. This cannot be undone.\',\n confirmLabel: \'Delete\',\n destructive: true,\n }).subscribe((ok) => {\n if (!ok) return;\n this.api.delete(this.context.data!.id).subscribe(() => {\n this.toast.success(\'Project deleted\');\n this.context.close();\n });\n });\n }\n}\n```\n\n```html\n<ply-dialog width="480">\n <ply-dialog-header>\n {{ isEdit ? \'Edit project\' : \'New project\' }}\n <button type="button" ply-icon-button color="transparent" ply-dialog-close aria-label="Close">\n <ply-icon name="close"></ply-icon>\n </button>\n </ply-dialog-header>\n\n <form [formGroup]="form" (ngSubmit)="save()" novalidate>\n <ply-dialog-body>\n <!-- ply-input-group links the label, shows ply-error after touch + invalid, paints the red shell,\n and sets aria-invalid / aria-describedby. No @if around ply-error, no manual ids. -->\n <div class="flex flex-col gap-4 py-1">\n <ply-input-group>\n <ply-label>Name</ply-label>\n <input ply-input formControlName="name" placeholder="Acme checkout" autocomplete="off" />\n <ply-error>\n @if (form.controls.name.hasError(\'required\')) { Enter a project name. }\n @else { Keep the name under 60 characters. }\n </ply-error>\n <ply-info-text>Shown in the sidebar and on invoices.</ply-info-text>\n </ply-input-group>\n\n <ply-input-group>\n <ply-label>Owner email</ply-label>\n <input ply-input type="email" formControlName="owner" placeholder="you@company.com" />\n <ply-error>Enter a valid email address.</ply-error>\n </ply-input-group>\n </div>\n </ply-dialog-body>\n\n <ply-dialog-footer class="justify-between">\n <div>\n @if (isEdit) {\n <button type="button" ply-stroked-button color="danger" (click)="remove()" [disabled]="saving()">Delete</button>\n }\n </div>\n <div class="flex gap-2">\n <button type="button" ply-stroked-button ply-dialog-close [disabled]="saving()">Cancel</button>\n <ply-progress-button type="submit" color="primary" [loading]="saving()">\n {{ isEdit ? \'Save changes\' : \'Create project\' }}\n </ply-progress-button>\n </div>\n </ply-dialog-footer>\n </form>\n</ply-dialog>\n```\n\n## 4. Data table with formatted numeric columns and every state\n\n`ply-data-table` renders skeleton rows for `loading` and a message row for empty data; only the error branch is yours.\n\n```ts\nreadonly columns: TableColumn[] = [\n { key: \'id\', label: \'Invoice\', sortable: true, width: \'120px\' },\n { key: \'customer\', label: \'Customer\', sortable: true },\n { key: \'issued\', label: \'Issued\', sortable: true, width: \'140px\' },\n { key: \'amount\', label: \'Amount\', sortable: true, align: \'right\', width: \'140px\' },\n { key: \'status\', label: \'Status\', width: \'120px\' },\n];\n```\n\n```html\n<div class="mb-4 flex flex-wrap items-center justify-between gap-3">\n <div>\n <p class="m-0! text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400">Billing</p>\n <h2 class="m-0! text-xl font-bold tracking-tight text-slate-900 dark:text-white">Invoices</h2>\n </div>\n <button ply-button color="primary" size="sm" (click)="openNewInvoice()">\n <ply-icon name="plus" class="h-4 w-4"></ply-icon>\n New invoice\n </button>\n</div>\n\n@if (error()) {\n <ply-alert color="danger" icon="alert-triangle" class="mb-4">\n {{ error() }}\n <ply-alert-actions>\n <button ply-stroked-button color="danger" size="sm" (click)="fetch(lastPage())">Retry</button>\n </ply-alert-actions>\n </ply-alert>\n}\n\n<ply-data-table\n [columns]="columns"\n [data]="rows()"\n [loading]="loading()"\n sortable pageable bordered hoverable\n serverSide [pageSize]="25" [totalItems]="total()"\n emptyMessage="No invoices match these filters"\n (pageChange)="fetch($event)"\n (sortChange)="onSort($event)"\n (rowClick)="openInvoice($event)">\n\n <ng-template plyTableCell="id" let-value="value">\n <span class="font-mono text-xs text-slate-500 dark:text-slate-400">{{ value }}</span>\n </ng-template>\n\n <ng-template plyTableCell="customer" let-row>\n <div class="flex min-w-0 items-center gap-3">\n <ply-avatar size="sm" shape="circle" [initials]="row.initials"></ply-avatar>\n <div class="min-w-0">\n <p class="m-0! truncate text-sm font-medium text-slate-900 dark:text-white" [title]="row.customer">{{ row.customer }}</p>\n <p class="m-0! truncate text-xs text-slate-500 dark:text-slate-400" [title]="row.email">{{ row.email }}</p>\n </div>\n </div>\n </ng-template>\n\n <ng-template plyTableCell="issued" let-value="value">\n <span class="whitespace-nowrap tabular-nums">{{ value | date: \'mediumDate\' }}</span>\n </ng-template>\n\n <ng-template plyTableCell="amount" let-row let-value="value">\n <span class="whitespace-nowrap font-semibold tabular-nums text-slate-900 dark:text-white">\n {{ value | currency: row.currency }}\n </span>\n </ng-template>\n\n <ng-template plyTableCell="status" let-value="value">\n <ply-badge [color]="statusColor(value)" size="sm" shape="rounded">{{ value }}</ply-badge>\n </ng-template>\n\n <ng-template plyTableEmpty>\n <div class="py-6">\n <ply-empty-state iconName="filter" title="No invoices match"\n description="Widen the date range or clear the status filter.">\n <button ply-stroked-button size="sm" (click)="clearFilters()">Clear filters</button>\n </ply-empty-state>\n </div>\n </ng-template>\n</ply-data-table>\n```\n\n## 5. Refreshing in place and lazy-loading heavy widgets\n\n```html\n<!-- Keep stale data visible while refreshing -->\n<ply-loading-overlay [visible]="refreshing()" message="Updating\u2026" class="rounded-xl">\n <app-revenue-chart [series]="series()"></app-revenue-chart>\n</ply-loading-overlay>\n\n<!-- Defer a heavy widget with a same-size placeholder -->\n<ply-card>\n <ply-card-body class="p-6">\n @defer (on viewport; prefetch on idle) {\n <app-revenue-chart [series]="series()" class="block h-64"></app-revenue-chart>\n } @placeholder {\n <ply-skeleton variant="rectangular" width="100%" height="16rem"></ply-skeleton>\n } @loading (minimum 300ms) {\n <ply-skeleton variant="rectangular" width="100%" height="16rem"></ply-skeleton>\n }\n </ply-card-body>\n</ply-card>\n```\n\n## 6. Custom menu panel (only when `ply-dropdown-menu` / `ply-popover` do not fit)\n\nPrefer `<ply-dropdown-menu>` with `[ply-dropdown-menu-trigger]` and `ply-dropdown-menu-item`, or `<ply-popover>` with `[ply-popover-trigger]`. If you must hand-roll a panel, copy the dropdown\'s surface and item recipe exactly, position with `dropdownConnectedPositions()`, and layer with `Z_SCALE.popover`.\n\n```html\n<div\n role="menu"\n class="min-w-40 origin-top overflow-hidden rounded-md border border-slate-200 bg-white shadow-xl outline-none transition-[opacity,transform] dark:border-slate-700 dark:bg-slate-800"\n [class]="open() ? \'scale-100 opacity-100 duration-200 ease-out\' : \'pointer-events-none scale-95 opacity-0 duration-150 ease-in\'"\n [style.z-index]="Z_SCALE.popover">\n <button role="menuitem" class="flex h-12 w-full items-center gap-3 px-6 text-sm text-slate-600 outline-none transition-colors duration-200 hover:bg-slate-100 focus-visible:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-700 dark:focus-visible:bg-slate-700">\n <ply-icon name="edit" class="h-4 w-4 stroke-current"></ply-icon>\n Rename\n </button>\n <button role="menuitem" class="flex h-12 w-full items-center gap-3 px-6 text-sm text-red-600 outline-none transition-colors duration-200 hover:bg-red-50 focus-visible:bg-red-50 dark:text-red-400 dark:hover:bg-red-900/30 dark:focus-visible:bg-red-900/30">\n <ply-icon name="trash" class="h-4 w-4 stroke-current"></ply-icon>\n Delete\n </button>\n</div>\n```\n' };
|
|
93
105
|
|
|
94
106
|
// src/brand.ts
|
|
95
107
|
var CLI_BIN = "ply-ui-cli";
|
|
@@ -254,12 +266,14 @@ function pickAngularProject(angularJson) {
|
|
|
254
266
|
return { name: appName, project: projects[appName] };
|
|
255
267
|
}
|
|
256
268
|
async function downloadSprite(name, targetDir) {
|
|
269
|
+
const dest = import_path2.default.join(targetDir, name);
|
|
270
|
+
if (import_fs3.default.existsSync(dest)) return true;
|
|
257
271
|
try {
|
|
258
272
|
const res = await fetch(`${import_registry.SITE_URL}/assets/${name}`);
|
|
259
273
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
260
274
|
const body = Buffer.from(await res.arrayBuffer());
|
|
261
275
|
import_fs3.default.mkdirSync(targetDir, { recursive: true });
|
|
262
|
-
import_fs3.default.writeFileSync(
|
|
276
|
+
import_fs3.default.writeFileSync(dest, body);
|
|
263
277
|
return true;
|
|
264
278
|
} catch (err) {
|
|
265
279
|
console.warn(`
|
|
@@ -284,60 +298,151 @@ var INIT_DEFAULTS = {
|
|
|
284
298
|
styles: "src/styles.scss",
|
|
285
299
|
tailwind: "src/tailwind.css"
|
|
286
300
|
};
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
301
|
+
var STYLE_EXTENSIONS = /* @__PURE__ */ new Set([".css", ".scss", ".sass", ".less"]);
|
|
302
|
+
function hasTailwindImport(content) {
|
|
303
|
+
return content.includes('@import "tailwindcss"') || content.includes("@import 'tailwindcss'");
|
|
304
|
+
}
|
|
305
|
+
function listedStyleFiles(angularJson) {
|
|
306
|
+
const picked = pickAngularProject(angularJson);
|
|
307
|
+
const styles = picked?.project?.architect?.build?.options?.styles || [];
|
|
308
|
+
const files = [];
|
|
309
|
+
for (const entry of styles) {
|
|
310
|
+
if (typeof entry === "string") files.push(entry);
|
|
311
|
+
else if (entry && typeof entry.input === "string") files.push(entry.input);
|
|
312
|
+
}
|
|
313
|
+
return files.map((file) => file.replace(/\\/g, "/")).filter((file) => STYLE_EXTENSIONS.has(import_path2.default.extname(file).toLowerCase()));
|
|
314
|
+
}
|
|
315
|
+
function toPosix(filePath) {
|
|
316
|
+
return filePath.replace(/\\/g, "/");
|
|
317
|
+
}
|
|
318
|
+
function discoverProjectLayout(cwd) {
|
|
319
|
+
let componentsAlias = INIT_DEFAULTS.componentsAlias;
|
|
320
|
+
let listed = [];
|
|
321
|
+
const angularPath = import_path2.default.resolve(cwd, "angular.json");
|
|
322
|
+
if (import_fs3.default.existsSync(angularPath)) {
|
|
323
|
+
try {
|
|
324
|
+
const angularJson = JSON.parse(import_fs3.default.readFileSync(angularPath, "utf8"));
|
|
325
|
+
listed = listedStyleFiles(angularJson);
|
|
326
|
+
const sourceRoot = pickAngularProject(angularJson)?.project?.sourceRoot;
|
|
327
|
+
if (typeof sourceRoot === "string" && sourceRoot.trim()) {
|
|
328
|
+
const root = toPosix(sourceRoot).replace(/\/+$/, "");
|
|
329
|
+
componentsAlias = `${root}/app/components`;
|
|
330
|
+
}
|
|
331
|
+
} catch {
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
const candidates = [...listed];
|
|
335
|
+
for (const extra of ["src/tailwind.css", "src/styles.css", "src/styles.scss"]) {
|
|
336
|
+
if (!candidates.includes(extra)) candidates.push(extra);
|
|
337
|
+
}
|
|
338
|
+
let tailwindFound = null;
|
|
339
|
+
for (const rel of candidates) {
|
|
340
|
+
const abs = import_path2.default.resolve(cwd, rel);
|
|
341
|
+
if (!import_fs3.default.existsSync(abs)) continue;
|
|
342
|
+
if (hasTailwindImport(import_fs3.default.readFileSync(abs, "utf8"))) {
|
|
343
|
+
tailwindFound = toPosix(rel);
|
|
344
|
+
break;
|
|
306
345
|
}
|
|
307
|
-
|
|
346
|
+
}
|
|
347
|
+
const existingStyles = listed.find((rel) => import_fs3.default.existsSync(import_path2.default.resolve(cwd, rel)));
|
|
348
|
+
const styles = existingStyles ? toPosix(existingStyles) : tailwindFound || INIT_DEFAULTS.styles;
|
|
349
|
+
const tailwind = tailwindFound || (import_fs3.default.existsSync(import_path2.default.resolve(cwd, INIT_DEFAULTS.tailwind)) ? INIT_DEFAULTS.tailwind : styles);
|
|
350
|
+
return { componentsAlias, styles, tailwind };
|
|
351
|
+
}
|
|
352
|
+
function insertComponentSource(css, cssFile, componentsDir) {
|
|
353
|
+
const cssDir = import_path2.default.dirname(cssFile);
|
|
354
|
+
const matches = css.match(/@source\s+["']([^"']+)["']/g) || [];
|
|
355
|
+
const covers = matches.some((match) => {
|
|
356
|
+
const raw = match.match(/@source\s+["']([^"']+)["']/)?.[1];
|
|
357
|
+
return !!raw && sourceDirectiveCovers(raw, cssDir, componentsDir);
|
|
358
|
+
});
|
|
359
|
+
if (covers) return { content: css, inserted: false };
|
|
360
|
+
let rel = import_path2.default.relative(cssDir, componentsDir);
|
|
361
|
+
if (!rel.startsWith(".")) rel = `.${import_path2.default.sep}${rel}`;
|
|
362
|
+
rel = toPosix(rel);
|
|
363
|
+
const line = `@source "${rel}";`;
|
|
364
|
+
const content = css.endsWith("\n") || css.length === 0 ? `${css}${line}
|
|
365
|
+
` : `${css}
|
|
366
|
+
${line}
|
|
367
|
+
`;
|
|
368
|
+
return { content, inserted: true };
|
|
369
|
+
}
|
|
370
|
+
async function init(options = {}) {
|
|
371
|
+
const cwd = process.cwd();
|
|
372
|
+
const discovered = discoverProjectLayout(cwd);
|
|
373
|
+
const existingConfigPath = configFilePath(cwd);
|
|
374
|
+
let response;
|
|
375
|
+
let writeConfig = true;
|
|
376
|
+
if (existingConfigPath) {
|
|
377
|
+
writeConfig = false;
|
|
378
|
+
const existing = JSON.parse(import_fs3.default.readFileSync(existingConfigPath, "utf8"));
|
|
379
|
+
response = {
|
|
380
|
+
componentsAlias: existing?.aliases?.components || discovered.componentsAlias,
|
|
381
|
+
styles: existing?.tailwind?.css || discovered.styles,
|
|
382
|
+
tailwind: existing?.tailwind?.config || discovered.tailwind
|
|
383
|
+
};
|
|
384
|
+
} else if (options.yes) {
|
|
385
|
+
response = discovered;
|
|
386
|
+
} else {
|
|
387
|
+
response = await (0, import_vendor.prompts)([
|
|
388
|
+
{
|
|
389
|
+
type: "text",
|
|
390
|
+
name: "componentsAlias",
|
|
391
|
+
message: "Configure the import alias for components:",
|
|
392
|
+
initial: discovered.componentsAlias
|
|
393
|
+
},
|
|
394
|
+
{
|
|
395
|
+
type: "text",
|
|
396
|
+
name: "styles",
|
|
397
|
+
message: "Where is your global CSS file?",
|
|
398
|
+
initial: discovered.styles
|
|
399
|
+
},
|
|
400
|
+
{
|
|
401
|
+
type: "text",
|
|
402
|
+
name: "tailwind",
|
|
403
|
+
message: "Path to your Tailwind CSS entry file:",
|
|
404
|
+
initial: discovered.tailwind
|
|
405
|
+
}
|
|
406
|
+
]);
|
|
407
|
+
}
|
|
308
408
|
if (!response.componentsAlias) {
|
|
309
409
|
console.log("Initialization cancelled.");
|
|
310
410
|
return;
|
|
311
411
|
}
|
|
312
|
-
const spinner = (0, import_vendor.ora)(`Writing ${CONFIG_FILE}...`).start();
|
|
313
|
-
const configPath = import_path2.default.resolve(
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
412
|
+
const spinner = (0, import_vendor.ora)(writeConfig ? `Writing ${CONFIG_FILE}...` : `Keeping ${import_path2.default.basename(existingConfigPath)}...`).start();
|
|
413
|
+
const configPath = existingConfigPath || import_path2.default.resolve(cwd, CONFIG_FILE);
|
|
414
|
+
if (writeConfig) {
|
|
415
|
+
const styleExt = import_path2.default.extname(response.styles).replace(".", "");
|
|
416
|
+
const config = {
|
|
417
|
+
$schema: "https://ply-ui.com/schema.json",
|
|
418
|
+
style: styleExt === "css" || styleExt === "scss" ? styleExt : "scss",
|
|
419
|
+
tailwind: {
|
|
420
|
+
config: response.tailwind,
|
|
421
|
+
css: response.styles,
|
|
422
|
+
baseColor: "slate",
|
|
423
|
+
cssVariables: true
|
|
424
|
+
},
|
|
425
|
+
aliases: {
|
|
426
|
+
components: response.componentsAlias
|
|
427
|
+
}
|
|
428
|
+
};
|
|
429
|
+
import_fs3.default.writeFileSync(configPath, JSON.stringify(config, null, 2), "utf8");
|
|
430
|
+
}
|
|
328
431
|
const lockPath = import_path2.default.resolve(process.cwd(), LOCKFILE_NAME);
|
|
329
432
|
if (!import_fs3.default.existsSync(lockPath)) {
|
|
330
433
|
writeLockFile(readLockFile());
|
|
331
434
|
}
|
|
332
|
-
spinner.text = "Writing global styles...";
|
|
333
|
-
const stylesPath = import_path2.default.resolve(
|
|
435
|
+
spinner.text = import_fs3.default.existsSync(import_path2.default.resolve(import_path2.default.dirname(import_path2.default.resolve(cwd, response.styles)), CSS_FILE)) ? `Keeping ${CSS_FILE}...` : "Writing global styles...";
|
|
436
|
+
const stylesPath = import_path2.default.resolve(cwd, response.styles);
|
|
334
437
|
const stylesDir = import_path2.default.dirname(stylesPath);
|
|
335
438
|
const cssPath = import_path2.default.join(stylesDir, CSS_FILE);
|
|
336
439
|
const importLine = `@import './${CSS_FILE}';`;
|
|
337
440
|
const legacyImport = `@import './${LEGACY_CSS_FILE}';`;
|
|
338
441
|
try {
|
|
339
442
|
import_fs3.default.mkdirSync(stylesDir, { recursive: true });
|
|
340
|
-
import_fs3.default.
|
|
443
|
+
if (!import_fs3.default.existsSync(cssPath)) {
|
|
444
|
+
import_fs3.default.writeFileSync(cssPath, BASE_CSS, "utf8");
|
|
445
|
+
}
|
|
341
446
|
if (import_fs3.default.existsSync(stylesPath)) {
|
|
342
447
|
let stylesContent = import_fs3.default.readFileSync(stylesPath, "utf8");
|
|
343
448
|
if (stylesContent.includes(legacyImport) && !stylesContent.includes(importLine)) {
|
|
@@ -356,6 +461,15 @@ ${stylesContent}`, "utf8");
|
|
|
356
461
|
} catch (error) {
|
|
357
462
|
console.warn(`
|
|
358
463
|
\u26A0 Could not update global styles (${error.message}). Add "${importLine}" to ${response.styles} manually and create ${CSS_FILE} next to it.`);
|
|
464
|
+
}
|
|
465
|
+
const tailwindPath = import_path2.default.resolve(cwd, response.tailwind);
|
|
466
|
+
if (import_fs3.default.existsSync(tailwindPath)) {
|
|
467
|
+
const current = import_fs3.default.readFileSync(tailwindPath, "utf8");
|
|
468
|
+
const next = insertComponentSource(current, tailwindPath, import_path2.default.resolve(cwd, response.componentsAlias));
|
|
469
|
+
if (next.inserted) import_fs3.default.writeFileSync(tailwindPath, next.content, "utf8");
|
|
470
|
+
} else {
|
|
471
|
+
console.warn(`
|
|
472
|
+
\u26A0 ${response.tailwind} was not found, so @source was not inserted. Create that Tailwind entry and re-run init.`);
|
|
359
473
|
}
|
|
360
474
|
spinner.text = "Setting up icon sprites...";
|
|
361
475
|
let assetsDir = import_path2.default.resolve(process.cwd(), "src/assets");
|
|
@@ -430,5 +544,7 @@ ${stylesContent}`, "utf8");
|
|
|
430
544
|
}
|
|
431
545
|
// Annotate the CommonJS export names for ESM import in node:
|
|
432
546
|
0 && (module.exports = {
|
|
433
|
-
|
|
547
|
+
discoverProjectLayout,
|
|
548
|
+
init,
|
|
549
|
+
insertComponentSource
|
|
434
550
|
});
|
package/dist/commands/update.js
CHANGED
|
@@ -382,7 +382,10 @@ async function update(components, options = {}) {
|
|
|
382
382
|
}
|
|
383
383
|
if (conflictsToUpstream) console.log(`\u2191 ${conflictsToUpstream} conflict${conflictsToUpstream === 1 ? "" : "s"} resolved by taking upstream.`);
|
|
384
384
|
if (conflictsSavedAside) console.log(`\u21C4 ${conflictsSavedAside} conflict${conflictsSavedAside === 1 ? "" : "s"} saved as .upstream for manual merge.`);
|
|
385
|
-
if (conflictsSkipped)
|
|
385
|
+
if (conflictsSkipped) {
|
|
386
|
+
console.log(`= ${conflictsSkipped} conflict${conflictsSkipped === 1 ? "" : "s"} skipped (kept local). Re-run without --yes, or with --force, to resolve.`);
|
|
387
|
+
process.exitCode = 1;
|
|
388
|
+
}
|
|
386
389
|
if (!updated && !added && !overwrittenLocal && !conflictsToUpstream && !conflictsSavedAside && !conflictsSkipped && !conflictsMerged && !conflictsMergeDirty) {
|
|
387
390
|
console.log("Everything is up to date.");
|
|
388
391
|
}
|
package/dist/index.js
CHANGED
|
@@ -43,8 +43,8 @@ Commands:
|
|
|
43
43
|
doctor Check that your project is set up correctly for Ply
|
|
44
44
|
|
|
45
45
|
Options:
|
|
46
|
-
-y, --yes Skip prompts
|
|
47
|
-
-o, --overwrite
|
|
46
|
+
-y, --yes Skip prompts. Leaves existing components, ply-ui.json, and ply-ui.css in place
|
|
47
|
+
-o, --overwrite Replace components that already exist (add)
|
|
48
48
|
-f, --force On conflict, take the upstream version (update)
|
|
49
49
|
--overwrite-local On update, also revert files you edited that upstream did not change
|
|
50
50
|
--merge On conflict, 3-way merge using the installed snapshot as ancestor
|
|
@@ -108,7 +108,7 @@ function parseArgs(argv) {
|
|
|
108
108
|
async function main() {
|
|
109
109
|
const { command, positionals, flags } = parseArgs(process.argv.slice(2));
|
|
110
110
|
if (flags.version && !command) {
|
|
111
|
-
console.log("1.1.
|
|
111
|
+
console.log("1.1.139");
|
|
112
112
|
return;
|
|
113
113
|
}
|
|
114
114
|
if (flags.help || !command) {
|
|
@@ -158,7 +158,7 @@ async function main() {
|
|
|
158
158
|
}
|
|
159
159
|
case "--version":
|
|
160
160
|
case "-V":
|
|
161
|
-
console.log("1.1.
|
|
161
|
+
console.log("1.1.139");
|
|
162
162
|
return;
|
|
163
163
|
default:
|
|
164
164
|
console.error(`Unknown command: ${command}`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ply-ui-cli",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.139",
|
|
4
4
|
"description": "Ply CLI — add Angular + Tailwind CSS components to your project (shadcn-style, code is copied in and owned by you). Not MUI Base UI.",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"publishConfig": {
|