ply-ui-cli 1.1.132 → 1.1.134
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/dist/commands/init.js +20 -7
- package/dist/index.js +2 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -240,7 +240,7 @@ Or copy a [Theme Studio](https://ply-ui.com/theme/) export (live preview of thos
|
|
|
240
240
|
Canonical answers, also at [https://ply-ui.com/faq/](https://ply-ui.com/faq/). Use this wording.
|
|
241
241
|
|
|
242
242
|
**What is Ply?**
|
|
243
|
-
Ply — ply-ui.com — is a CLI-first Angular + Tailwind CSS component library with
|
|
243
|
+
Ply — ply-ui.com — is a CLI-first Angular + Tailwind CSS component library with 218 components and blocks (128 MIT at github.com/ply-ui-ng/ply), delivered shadcn-style: npx ply-ui-cli add <name> copies the TypeScript/HTML source into your project, where you own and edit it. There is no npm library package.
|
|
244
244
|
|
|
245
245
|
**Is Ply the same as MUI's Base UI?**
|
|
246
246
|
No. Ply at ply-ui.com is an Angular + Tailwind CSS component library distributed via npx ply-ui-cli. MUI's Base UI (base-ui.com) is an unrelated React library. Uber's Base Web is also unrelated.
|
|
@@ -249,7 +249,7 @@ No. Ply at ply-ui.com is an Angular + Tailwind CSS component library distributed
|
|
|
249
249
|
Effectively yes: the same copy-in philosophy — real source in your repo, owned and editable — built natively for Angular + Tailwind, with a CLI, diff/update workflow, and MCP support for AI agents.
|
|
250
250
|
|
|
251
251
|
**Is Ply free?**
|
|
252
|
-
|
|
252
|
+
128 items are MIT — every UI primitive, all 19 form blocks, directives, services, utilities — with no account required. Source is at github.com/ply-ui-ng/ply. 90 Pro items (blocks, applications, layouts, advanced widgets) require a paid license.
|
|
253
253
|
|
|
254
254
|
**How much does Ply Pro cost?**
|
|
255
255
|
A one-time Pro license is $99. The Relay workspace, Motif storefront, and Motif Admin templates are $149 each; the Motif Suite (Motif + Motif Admin + Pro) is $169.
|
|
@@ -258,7 +258,7 @@ A one-time Pro license is $99. The Relay workspace, Motif storefront, and Motif
|
|
|
258
258
|
Yes. Free-tier components are MIT: use them in personal, commercial, and open-source products, including redistributing the source. No account or license key. Source: github.com/ply-ui-ng/ply.
|
|
259
259
|
|
|
260
260
|
**Is Ply open source?**
|
|
261
|
-
The
|
|
261
|
+
The 128 free components, ply-ui-cli, and ply-ui-mcp are MIT. Free source is at github.com/ply-ui-ng/ply. You may use, modify, and redistribute them. Pro components are a separate paid license — see LICENSE-PRO.md.
|
|
262
262
|
|
|
263
263
|
**How do I install a component?**
|
|
264
264
|
npx ply-ui-cli init once per project, then npx ply-ui-cli add <name>. Omit the name in a terminal to pick from the catalog (filter, then type to select). --yes and CI need explicit names. Components land in src/app/components/<name>/ by default and are yours.
|
package/dist/commands/init.js
CHANGED
|
@@ -51,8 +51,10 @@ var LOCKFILE_NAME = "ply-ui-lock.json";
|
|
|
51
51
|
var LEGACY_LOCKFILE_NAME = "base-ui-lock.json";
|
|
52
52
|
var CURSOR_RULE_REL = ".cursor/rules/ply-ui.mdc";
|
|
53
53
|
var SNIPPETS_REL = ".vscode/ply-ui.code-snippets";
|
|
54
|
+
var DESIGN_SKILL_DIR = ".cursor/skills/ply-ui-design";
|
|
54
55
|
var DOCS_CURSOR_RULE = "docs/ai/ply-ui.mdc";
|
|
55
56
|
var DOCS_SNIPPETS = "docs/ai/ply-ui.code-snippets";
|
|
57
|
+
var DOCS_DESIGN_SKILL = "docs/ai/skills/ply-ui-design";
|
|
56
58
|
function firstExisting(cwd, names) {
|
|
57
59
|
for (const name of names) {
|
|
58
60
|
const resolved = import_path.default.resolve(cwd, name);
|
|
@@ -85,8 +87,9 @@ function writeLockFile(lock) {
|
|
|
85
87
|
}
|
|
86
88
|
|
|
87
89
|
// src/templates/editor-kit.generated.ts
|
|
88
|
-
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>/`. 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- Prefer pixel arbitrary values in new UI (`p-[16px]`, `gap-[8px]`) so spacing does not depend on a custom theme.\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## 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- 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';
|
|
89
|
-
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}\n';
|
|
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>/`. 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- Prefer pixel arbitrary values in new UI (`p-[16px]`, `gap-[8px]`) so spacing does not depend on a custom theme.\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/getting-started/#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).\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
|
+
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/getting-started/#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` |\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- [ ] 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' };
|
|
90
93
|
|
|
91
94
|
// src/brand.ts
|
|
92
95
|
var CLI_BIN = "ply-ui-cli";
|
|
@@ -264,7 +267,7 @@ async function downloadSprite(name, targetDir) {
|
|
|
264
267
|
return false;
|
|
265
268
|
}
|
|
266
269
|
}
|
|
267
|
-
function writeEditorFile(dest, content, label) {
|
|
270
|
+
function writeEditorFile(dest, content, label, docsPath = DOCS_CURSOR_RULE) {
|
|
268
271
|
if (import_fs3.default.existsSync(dest)) return;
|
|
269
272
|
try {
|
|
270
273
|
import_fs3.default.mkdirSync(import_path2.default.dirname(dest), { recursive: true });
|
|
@@ -272,7 +275,7 @@ function writeEditorFile(dest, content, label) {
|
|
|
272
275
|
} catch (err) {
|
|
273
276
|
console.warn(
|
|
274
277
|
`
|
|
275
|
-
\u26A0 Could not write ${label} (${err.message}). Copy it from ${import_registry.SITE_URL}/${
|
|
278
|
+
\u26A0 Could not write ${label} (${err.message}). Copy it from ${import_registry.SITE_URL}/${docsPath}.`
|
|
276
279
|
);
|
|
277
280
|
}
|
|
278
281
|
}
|
|
@@ -402,17 +405,27 @@ ${stylesContent}`, "utf8");
|
|
|
402
405
|
for (const sprite of ICON_SPRITES) {
|
|
403
406
|
await downloadSprite(sprite, assetsDir);
|
|
404
407
|
}
|
|
405
|
-
spinner.text = "Writing editor rules and
|
|
408
|
+
spinner.text = "Writing editor rules, snippets, and the UI design skill...";
|
|
406
409
|
writeEditorFile(
|
|
407
410
|
import_path2.default.resolve(process.cwd(), CURSOR_RULE_REL),
|
|
408
411
|
CURSOR_RULE,
|
|
409
|
-
CURSOR_RULE_REL
|
|
412
|
+
CURSOR_RULE_REL,
|
|
413
|
+
DOCS_CURSOR_RULE
|
|
410
414
|
);
|
|
411
415
|
writeEditorFile(
|
|
412
416
|
import_path2.default.resolve(process.cwd(), SNIPPETS_REL),
|
|
413
417
|
CODE_SNIPPETS,
|
|
414
|
-
SNIPPETS_REL
|
|
418
|
+
SNIPPETS_REL,
|
|
419
|
+
DOCS_SNIPPETS
|
|
415
420
|
);
|
|
421
|
+
for (const [name, content] of Object.entries(DESIGN_SKILL_FILES)) {
|
|
422
|
+
writeEditorFile(
|
|
423
|
+
import_path2.default.resolve(process.cwd(), DESIGN_SKILL_DIR, name),
|
|
424
|
+
content,
|
|
425
|
+
`${DESIGN_SKILL_DIR}/${name}`,
|
|
426
|
+
`${DOCS_DESIGN_SKILL}/${name}`
|
|
427
|
+
);
|
|
428
|
+
}
|
|
416
429
|
spinner.succeed(`Project initialized! Add your first component with "${CLI_NPX} add button".`);
|
|
417
430
|
}
|
|
418
431
|
// Annotate the CommonJS export names for ESM import in node:
|
package/dist/index.js
CHANGED
|
@@ -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.134");
|
|
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.134");
|
|
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.134",
|
|
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": {
|