Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 108 additions & 0 deletions .claude/rules/compound-api.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
# Rule: compound API — composition first, props for data, names from anatomy

Composition (`structure: composition`) components in the webkit layer expose a **compound API**: every public sub-component is importable on its own **and** attached to the root for dot-notation (`<Table.Row>`, `<Paginator.Button>`). This rule fixes how that API is shaped, named, and typed so every composition component reads the same way.

It rests on three decisions, in priority order:

1. **Anatomy is elements, not props.** The structural parts of a component (where things go) are sub-components the consumer composes by hand. Props are for **bulk data** (the data-driven `data` + `columns` model) and **scalar configuration** (`maxHeight`, `paginated`, `pageSize`) — never for things that have the shape of a slot.
2. **Shared state flows through `provide`/`inject`,** surfaced as **context-aware sub-components** so the consumer wires nothing. `<Table.Search>` reads and drives the table's global filter through the injected context; it takes no `model-value` / `@update`.
3. **Names come from the component's own anatomy,** not from a convention copied between components. `<Table.SortButton>`, not `<Table.Trigger>`.

## The compound API shape

Each composition component ships an **`index.ts`** next to the root `.vue`. The root is the default export with the sub-components attached via `Object.assign`. Because it is a `.ts` file, `vue-tsc` generates the adjacent `index.d.ts` at build time — so `<Table.Row>` is fully typed.

```ts
// index.ts — one source of truth (runtime + the type vue-tsc derives from it).
import Table from './table.vue'
import TableRow from './table-row/table-row.vue'
import TableCell from './table-cell/table-cell.vue'
// ...one import per public sub-component

export default Object.assign(Table, {
Row: TableRow,
Cell: TableCell
// ...
})
```

`Object.assign` returns `typeof Table & { Row: ..., Cell: ... }`, so the generated `index.d.ts` types `<Table.Row>` / `<Table.Cell>` with no manual annotation. **Do not hand-write `index.d.ts`** — `.d.ts` is gitignored (a build artifact); a hand-written one is never committed, and `vue-tsc` cannot derive types from a plain `index.js` (the package does not enable `allowJs`). The index must be `.ts`.

Both forms stay available to the consumer:

```vue
<script setup>
import Table from '@aziontech/webkit/data/table' // compound — leads the docs
import TableRow from '@aziontech/webkit/data/table-row' // standalone — for tree-shaking
</script>

<template>
<Table>
<Table.Header><Table.Row><Table.HeadCell>Name</Table.HeadCell></Table.Row></Table.Header>
</Table>
</template>
```

The dot-notation **must** use a PascalCase root binding (`Table`). `table` (lowercase) collides with the native `<table>` element and will not resolve to the component.

## Wiring rules (package.json + exports)

- **Root local `package.json`** points at the `.ts` source (types at the generated `.d.ts`):
```json
{ "main": "./index.ts", "module": "./index.ts", "types": "./index.d.ts", "browser": { "./sfc": "./index.ts" }, "sideEffects": ["*.vue"] }
```
- **Root export** in `packages/webkit/package.json#exports` points the component's public path at `index.ts` (not the root `.vue`):
```json
"./data/table": "./src/components/data/table/index.ts"
```
- **Sub-component exports stay flat and unchanged** — one entry per public sub-component pointing at its `.vue` (`"./data/table-row": ".../table-row/table-row.vue"`).
- **The package ships source and is consumed as source** (the exports map points at `./src/...`, and `.vue` files already import `.ts` such as `injection-key.ts`). A `.ts` index is therefore safe — the consumer's build transpiles it the same way it transpiles every `.vue` and the shared `injection-key.ts`. `.d.ts` files are gitignored; `build:dts` regenerates them.

## Naming — anatomy, not generic convention

The members of the compound mirror **that component's** real anatomy. Do not copy a part name from another component because it exists there.

| Concern | Correct | Wrong |
|---|---|---|
| A part that is structural | `Header`, `Body`, `Row`, `Cell` | — |
| A control that toggles state but opens nothing | `SortButton` (says what it does) | `Trigger` (it opens no `Content`) |
| A part that opens a paired overlay with `open`/`closed` + `aria-expanded` | `Trigger` + `Content` | naming it after the table/list it sits in |

`Trigger` / `Content` / `Portal` belong to **overlay / disclosure** components (dropdown, dialog, popover) — anything with `data-state="open|closed"`. A component with no open/close state has **no** `Trigger`. When a non-overlay component needs an overlay (row-action menu, filter popover), it **composes** the overlay component; the trigger comes from that overlay, not from the host's compound.

## Elements vs props — the anti-pattern

Anything slot-shaped stays a slot/sub-component; do not collapse it into a config array.

```vue
<!-- ✅ Compose real elements -->
<template #toolbar>
<IconButton icon="pi pi-filter" />
<Table.Search placeholder="Search" />
</template>

<!-- ❌ Config-array for UI — inextensible on the first customization -->
<Table :toolbar-actions="[{ icon: 'pi pi-filter' }, { type: 'search' }]" />
```

Repeated **data** is the one thing that earns props (`:data` + `:columns`) — hand-authoring N rows as elements is the wrong trade. That data-driven mode renders through the same sub-components (our markup / tokens / a11y); it does not bypass them.

## When a sub-component earns its existence

Interactivity alone does not justify a sub-component. A clickable row is `@click` fallthrough (or a `row-click` event in data-driven mode); a clickable cell is a `clickable` prop + composed content. A part becomes a sub-component only when it owns enough of its **own** markup, a11y, or state to stand alone (`SortButton`: glyph + three sort states + focus ring + touch target).

## Hard prohibitions

- Do not export composition sub-components without also attaching them to the root compound (`index.ts` via `Object.assign`).
- Do not make the index a plain `index.js` — `vue-tsc` cannot derive types from it (no `allowJs`), so `<Root.Part>` ends up untyped. It must be `index.ts`.
- Do not hand-write `index.d.ts` — it is gitignored and regenerated by `build:dts` from `index.ts`. Point the root `types` at the generated `index.d.ts`.
- Do not invent a `Trigger` (or any overlay part name) on a component that has no `data-state="open|closed"`.
- Do not turn a slot-shaped concern into a prop (config arrays of UI). Anatomy is elements; props are data + scalar config.
- Do not require the consumer to wire shared state by hand when a context-aware sub-component can read it from `inject`.

## Enforcement

- `scaffolder` emits `index.ts` for every `structure: composition` component and points the local `package.json` `main`/`module` at `index.ts`, `types` at the generated `index.d.ts` (see [`.claude/skills/component-scaffold/SKILL.md`](../skills/component-scaffold/SKILL.md)).
- The spec's `## Usage` block (composition) **leads with the compound dot-notation** form; the standalone imports are documented as the tree-shaking alternative. See [`.specs/_template.md`](../../.specs/_template.md).
- `validate-references.mjs` blocks any phantom `@aziontech/webkit/*` import; the standalone sub-component paths must exist in `packages/webkit/package.json#exports`.
- Sub-agent prompts inject this rule alongside [`no-invention.md`](./no-invention.md), [`styling.md`](./styling.md), [`dependencies.md`](./dependencies.md), and [`migration.md`](./migration.md). Any deviation surfaces as `BLOCKED:` and stops the run.
11 changes: 10 additions & 1 deletion .claude/rules/dependencies.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ The webkit layer **must not** depend on external libraries for positioning, anch
- **`@vueuse/core`** — small reactive utilities (e.g. `useElementVisibility`). **Not** `@vueuse/motion`.
- **PrimeVue** — only as wrapped via `core/primevue/*`. New components prefer the webkit layer with no PrimeVue dependency.
- **VeeValidate** — only inside `core/form/*` per existing pattern.
- **`@tanstack/vue-table`** — headless table state engine (sorting, pagination, row selection, column models) **only** inside the single `data/table` component's data-driven mode (`data` + `columns`); `data/data-table` is an alias of `data/table`. Granted via the **Exceptions** process below. This is `@tanstack/vue-table` / `@tanstack/table-core` (headless logic) — **not** `@tanstack/virtual` (scroll virtualization), which remains forbidden in the table above.

## How to anchor / position without a lib

Expand Down Expand Up @@ -70,4 +71,12 @@ BLOCKED: forbidden dependency <name>. Use CSS + tokens per .claude/rules/depende

## Exceptions

There are none today. If a future component genuinely cannot be built without an external lib (e.g. an embedded code editor like Monaco), it requires (a) a written rationale in the spec's **Purpose** section, (b) explicit human approval, and (c) an entry in this file's **Allowed** section.
If a future component genuinely cannot be built without an external lib (e.g. an embedded code editor like Monaco), it requires (a) a written rationale in the spec's **Purpose** section, (b) explicit human approval, and (c) an entry in this file's **Allowed** section.

### Granted exceptions

| Lib | Scope | Rationale | Approved |
|---|---|---|---|
| `@tanstack/vue-table` (`@tanstack/table-core`) | `data/table` only (data-driven mode) | A correct, accessible, headless table state engine (sorting / pagination / row selection / column visibility, client- and server-side) is large surface to own in-house and is a pure-logic library — it ships **no** markup, styles, positioning, or animation, so it does not fight DESIGN.md. Rendering stays 100% in our own `data/table` sub-components (our tokens, our a11y). Rationale recorded in `.specs/table.md` § Purpose (added when the data-driven mode lands). | Human-approved 2026-06-15 |

> The exception is **narrow**: `@tanstack/vue-table` is permitted only as the state engine behind the single `data/table` component. Do not import it in other components, and do not pull in `@tanstack/virtual` or any other `@tanstack/*` package without a new exception row.
49 changes: 43 additions & 6 deletions .claude/skills/component-scaffold/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ Convert an approved `.specs/<name>.md` into:
- `packages/webkit/src/components/webkit/<category>/<name>/package.json` for the root component.
- (Composition only) **One folder per sub-component** under the root component directory — `<name>/<name>-<part>/<name>-<part>.vue` plus a sibling `<name>/<name>-<part>/package.json`. The full file name is preserved (`dialog-trigger.vue`, not `index.vue`) so error traces and editor breadcrumbs are unambiguous.
- (Composition only) `packages/webkit/src/components/webkit/<category>/<name>/injection-key.ts` at the root level (shared by every sub-component).
- New entry/entries in `packages/webkit/package.json#exports` — one per public component (root + each public sub-component). The public path keeps the short, flat form (`./overlay/dialog-trigger`) regardless of the folder nesting.
- (Composition only) `packages/webkit/src/components/webkit/<category>/<name>/index.ts` — the **compound API** that attaches every sub-component to the root (`<Root.Part>`) via `Object.assign`; because it is a `.ts` file, `vue-tsc` generates the adjacent `index.d.ts` (do not hand-write it — `.d.ts` is gitignored). See [`.claude/rules/compound-api.md`](../../rules/compound-api.md).
- New entry/entries in `packages/webkit/package.json#exports` — one per public component (root + each public sub-component). The public path keeps the short, flat form (`./overlay/dialog-trigger`) regardless of the folder nesting. For composition, the **root** export points at `index.ts`, not the root `.vue`.

Nothing else. The story, the Code Connect file, and the validation pass live in other skills.

Expand All @@ -24,7 +25,8 @@ Nothing else. The story, the Code Connect file, and the validation pass live in
```
packages/webkit/src/components/webkit/overlay/dialog/
├── dialog.vue # root
├── package.json # root package
├── index.ts # compound (Object.assign → Dialog.Trigger, ...); vue-tsc emits index.d.ts
├── package.json # root package (main/module → ./index.ts, types → ./index.d.ts)
├── injection-key.ts # shared InjectionKey<DialogContext>
├── dialog-trigger/
│ ├── dialog-trigger.vue
Expand Down Expand Up @@ -67,7 +69,7 @@ packages/webkit/src/components/webkit/actions/button/

- The full text of `.specs/<name>.md` (verbatim).
- The Constraints block (verbatim).
- [`.claude/rules/no-invention.md`](../../rules/no-invention.md), [`.claude/docs/COMPONENT_REQUIREMENTS.md`](../../docs/COMPONENT_REQUIREMENTS.md), [`.claude/docs/COMPONENT_REQUIREMENTS.md`](../../docs/COMPONENT_REQUIREMENTS.md), [`.claude/docs/DESIGN.md`](../../docs/DESIGN.md).
- [`.claude/rules/no-invention.md`](../../rules/no-invention.md), [`.claude/rules/compound-api.md`](../../rules/compound-api.md) (composition only), [`.claude/docs/COMPONENT_REQUIREMENTS.md`](../../docs/COMPONENT_REQUIREMENTS.md), [`.claude/docs/DESIGN.md`](../../docs/DESIGN.md).
- The canonical files for cross-reference (read-only):
- `packages/webkit/src/components/webkit/actions/button/button.vue`
- `packages/webkit/src/components/webkit/actions/icon-button/icon-button.vue`
Expand Down Expand Up @@ -190,8 +192,26 @@ packages/webkit/src/components/webkit/actions/button/

Root `.vue` imports it via `import { DialogInjectionKey } from './injection-key'`. Sub-components import it via `import { DialogInjectionKey } from '../injection-key'`.

4b. **(Composition only) Write the compound `index.ts`** at the root level (sibling of `<name>.vue`). The root is the default export with every public sub-component attached via `Object.assign`. Member names mirror the spec's Sub-components section — strip the `<name>-` prefix and PascalCase (`dialog-trigger` → `Trigger`). Use a generic name only when the spec does (`SortButton`, not `Trigger`, when the part opens no `Content`). See [`.claude/rules/compound-api.md`](../../rules/compound-api.md).

```ts
// index.ts — one source of truth; vue-tsc derives index.d.ts from it.
import Dialog from './dialog.vue'
import DialogTrigger from './dialog-trigger/dialog-trigger.vue'
import DialogContent from './dialog-content/dialog-content.vue'

export default Object.assign(Dialog, {
Trigger: DialogTrigger,
Content: DialogContent
})
```

**The index is `.ts`, not `.js`** — `vue-tsc` cannot derive declarations from a plain `.js` (no `allowJs`), so `<Dialog.Trigger>` would be untyped. **Do not hand-write `index.d.ts`** — it is gitignored and regenerated by `build:dts`. The package is consumed as source (the exports map points at `./src/...`, and `.vue` files already import `.ts` like `injection-key.ts`), so the consumer transpiles `index.ts` the same way.

5. **Write `package.json`** for the **root** component:

**Monolithic root** points at the `.vue`:

```json
{
"name": "@aziontech/webkit-<category>-<name>",
Expand All @@ -203,6 +223,19 @@ packages/webkit/src/components/webkit/actions/button/
}
```

**Composition root** points at the compound `index.ts` (types at the generated `index.d.ts`), so the consumer gets `<Root.Part>` typed:

```json
{
"name": "@aziontech/webkit-<category>-<name>",
"main": "./index.ts",
"module": "./index.ts",
"types": "./index.d.ts",
"browser": { "./sfc": "./index.ts" },
"sideEffects": ["*.vue"]
}
```

**(Composition only) Also write one `package.json` per sub-component folder** (sibling of the sub-component's `.vue`):

```json
Expand All @@ -218,16 +251,18 @@ packages/webkit/src/components/webkit/actions/button/

6. **Update `packages/webkit/package.json#exports`** — add one entry per public component (root + each public sub-component) preserving alphabetical order inside the category. The **public export path stays flat** (`./<category>/<name>-<part>`) so consumers don't see the folder nesting; only the right-hand side changes:

The **composition root** points at `index.ts` (the compound); monolithic roots point at `<name>.vue`:

```json
"./<category>/<name>": "./src/components/webkit/<category>/<name>/<name>.vue",
"./<category>/<name>": "./src/components/webkit/<category>/<name>/index.ts",
"./<category>/<name>-trigger": "./src/components/webkit/<category>/<name>/<name>-trigger/<name>-trigger.vue",
"./<category>/<name>-content": "./src/components/webkit/<category>/<name>/<name>-content/<name>-content.vue"
```

Concrete example for a new `popover` composition component:

```json
"./overlay/popover": "./src/components/webkit/overlay/popover/popover.vue",
"./overlay/popover": "./src/components/webkit/overlay/popover/index.ts",
"./overlay/popover-trigger": "./src/components/webkit/overlay/popover/popover-trigger/popover-trigger.vue",
"./overlay/popover-content": "./src/components/webkit/overlay/popover/popover-content/popover-content.vue"
```
Expand All @@ -241,6 +276,7 @@ packages/webkit/src/components/webkit/actions/button/
## Rules

- Every prop, event, slot, and sub-component MUST come from the spec — no inventions. (`validate-spec-compliance.mjs` enforces this on Write; it will reject the run if you stray.)
- (Composition only) Emit the compound `index.ts` (vue-tsc generates `index.d.ts`; never hand-write it) and point the root `package.json` `main`/`module` → `index.ts`, `types` → `index.d.ts`, and the root export → `index.ts`; member names mirror the component's anatomy. Never invent overlay part names (`Trigger`/`Content`) on a component with no open/closed state. See [`.claude/rules/compound-api.md`](../../rules/compound-api.md).
- Use the canonicals (`button.vue`, `card-pricing.vue`) as the **shape** reference — but substitute spec content, never copy spec content from a canonical.
- All visual tokens come from [`.claude/docs/DESIGN.md`](../../docs/DESIGN.md). No HEX, no Tailwind palette, no raw typography.
- TypeScript only (`<script setup lang="ts">`); no `any`; no `@ts-ignore`; no `class` in `defineProps`.
Expand Down Expand Up @@ -282,7 +318,8 @@ packages/webkit/src/components/webkit/actions/button/
- [ ] Root `package.json` written.
- [ ] Composition: every sub-component is **its own folder** under the component root — `<name>/<name>-<part>/<name>-<part>.vue` + `<name>/<name>-<part>/package.json`.
- [ ] Composition: `injection-key.ts` written at the root level (sibling of `<name>.vue`), not inside any sub-component folder. Root imports it via `./injection-key`; sub-components via `../injection-key`.
- [ ] New entries added to `packages/webkit/package.json#exports`. Public paths stay flat (`./<category>/<name>-<part>`); right-hand paths reflect the folder nesting.
- [ ] Composition: `index.ts` written at the root level (sibling of `<name>.vue`), attaching every sub-component to the root via `Object.assign` (no hand-written `index.d.ts` — vue-tsc generates it); root `package.json` points `main`/`module` → `./index.ts` and `types` → `./index.d.ts`. Member names mirror the spec's anatomy (no invented `Trigger`/`Content` on a non-overlay component).
- [ ] New entries added to `packages/webkit/package.json#exports`. Public paths stay flat (`./<category>/<name>-<part>`); right-hand paths reflect the folder nesting. The composition root export points at `index.ts`.
- [ ] No HEX / Tailwind palette / raw typography / `any` / `@ts-ignore`.
- [ ] `defineOptions.name` is PascalCase and matches the directory.
- [ ] `data-testid` fallback equals `'<category>-<name>'` on the root and `'<category>-<name>__<part>'` on each sub-component.
Expand Down
Loading