Skip to content
Merged
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
4 changes: 3 additions & 1 deletion .claude/docs/COMPONENT_REQUIREMENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -1096,10 +1096,12 @@ Authoring the `.figma.ts` file works without the token; only publishing needs it

### 13. Storybook story — canonical pattern (Vue 3 + Storybook 8)

> **"Show code" is governed by [`.claude/rules/storybook-source.md`](../rules/storybook-source.md).** The Docs panel must emit a **single runnable SFC with PascalCase tags** that match the import. Keep `parameters.docs` a plain object literal and give every story an explicit `source.code: toSfc(IMPORT, TEMPLATE)` (from `apps/storybook/src/stories/_shared/story-source.js`); never use `docs.source.transform` / `source.type: 'dynamic'`, and never set `docs` to a function call. `parameters.docs.description.component` is a **short prose lead** from `## Purpose` — the `## Usage` block is NOT appended into it (older guidance below is superseded on this point). Enforced by `validate-story-source.mjs`.

Stories follow the **market-standard CSF3 pattern for Vue 3** — concretely, the existing [`apps/storybook/src/stories/webkit/actions/button/Button.stories.js`](../../apps/storybook/src/stories/webkit/actions/button/Button.stories.js). The two distinguishing traits versus a generic CSF3 file:

1. **Composite stories `Types` and `Sizes`** render every variant side-by-side in a single frame — replacing one-story-per-variant (`Primary`, `Secondary`, `Outlined`, …).
2. **`parameters.docs.description.component` is built from the spec.** The Purpose paragraph is the lead-in; the `## Usage` fenced `vue` block (import + minimal `<script setup>` + `<template>`) is appended verbatim as a code snippet. The same block is the single source of truth for both spec docs and Storybook Docs.
2. **`parameters.docs.description.component` is the short Purpose prose lead** (see the rule callout above); the runnable usage is surfaced by the "Show code" panel via `runnableDocs` / `toSfc`, not by pasting the `## Usage` block into the description.

```js
// <Name>.stories.js — canonical shape (matches Button.stories.js)
Expand Down
100 changes: 100 additions & 0 deletions .claude/hooks/__tests__/run.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,106 @@ group('hook: validate-spec-compliance.mjs', () => {
})
})

group('hook: validate-story-source.mjs', () => {
const story = (content) => ({ tool_name: 'Write', tool_input: { file_path: resolve(ROOT, 'x.stories.js'), content } })

test('non-story path passes through (exit 0)', () => {
const r = runHook('.claude/hooks/validate-story-source.mjs', {
tool_name: 'Write',
tool_input: { file_path: resolve(ROOT, 'random/file.vue'), content: '<foo />' }
})
assertEqual(r.code, 0)
})
test('new story with literal docs + toSfc + PascalCase passes (exit 0)', () => {
const r = runHook(
'.claude/hooks/validate-story-source.mjs',
story(
[
"import Foo from '@aziontech/webkit/foo'",
"import { toSfc } from '../../_shared/story-source'",
"tags: ['autodocs']",
'const T = `<Foo bar="1" />`',
'docs: { canvas: { sourceState: "shown" }, source: { code: toSfc(IMPORT, T) } }'
].join('\n')
)
)
assertEqual(r.code, 0)
})
test('docs as a function call blocks (exit 2)', () => {
const r = runHook(
'.claude/hooks/validate-story-source.mjs',
story(
[
"import Foo from '@aziontech/webkit/foo'",
"import { toSfc } from '../../_shared/story-source'",
"tags: ['autodocs']",
"docs: runnableDocs({ imports: IMPORT })"
].join('\n')
)
)
assertEqual(r.code, 2)
assertTrue(/docs-not-literal/.test(r.stderr), 'should flag docs function call')
})
test('lowercase/kebab component tag blocks (exit 2)', () => {
const r = runHook(
'.claude/hooks/validate-story-source.mjs',
story(
[
"import EmptyState from '@aziontech/webkit/empty-state'",
"import { toSfc } from '../../_shared/story-source'",
"tags: ['autodocs']",
'const T = `<empty-state title="x" />`',
'docs: { canvas: { sourceState: "shown" }, source: { code: toSfc(IMPORT, T) } }'
].join('\n')
)
)
assertEqual(r.code, 2)
assertTrue(/lowercase-tag/.test(r.stderr), 'should flag lowercase tag')
})
test('import binding not matching export subpath blocks (exit 2)', () => {
const r = runHook(
'.claude/hooks/validate-story-source.mjs',
story(
[
"import Chip from '@aziontech/webkit/chips'",
"import { toSfc } from '../../_shared/story-source'",
"tags: ['autodocs']",
'const T = `<Chip label="x" />`',
'docs: { canvas: { sourceState: "shown" }, source: { code: toSfc(IMPORT, T) } }'
].join('\n')
)
)
assertEqual(r.code, 2)
assertTrue(/import-binding-mismatch/.test(r.stderr), 'should flag binding/subpath mismatch')
})
test('hand-rolled transform blocks (exit 2)', () => {
const r = runHook(
'.claude/hooks/validate-story-source.mjs',
story(
["import Foo from '@aziontech/webkit/foo'", "tags: ['autodocs']", 'docs: { source: { transform: (code) => code } }'].join('\n')
)
)
assertEqual(r.code, 2)
assertTrue(/handrolled-transform|missing-helper/.test(r.stderr), 'should flag hand-rolled / missing helper')
})
test('nested <template> blocks (exit 2)', () => {
const r = runHook(
'.claude/hooks/validate-story-source.mjs',
story(
[
"import Foo from '@aziontech/webkit/foo'",
"import { toSfc } from '../../_shared/story-source'",
"tags: ['autodocs']",
'const T = `<template>\n <template>\n <Foo />\n </template>\n</template>`',
'docs: { canvas: { sourceState: "shown" }, source: { code: toSfc(IMPORT, T) } }'
].join('\n')
)
)
assertEqual(r.code, 2)
assertTrue(/nested-template/.test(r.stderr), 'should flag nested template')
})
})

// ---- summary ----

const failed = results.filter((r) => !r.ok)
Expand Down
226 changes: 226 additions & 0 deletions .claude/hooks/validate-story-source.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
#!/usr/bin/env node
// PreToolUse hook: blocks Write/Edit/MultiEdit on Storybook *.stories.* files
// whose Docs "Show code" output would not be a single, runnable, PascalCase SFC.
// Enforces .claude/rules/storybook-source.md:
// - `parameters.docs` is a plain OBJECT LITERAL (a function call there makes
// Storybook print the raw CSF story object instead of the snippet);
// - the snippet comes from an explicit `source.code` built with `toSfc`
// (no dynamic `source.transform`);
// - no lowercase/kebab tag of an imported component, no nested <template>;
// - new stories import the shared helper and set `sourceState: 'shown'`.
// Only NEWLY introduced violations block; pre-existing legacy is left alone.

import { readFileSync } from 'node:fs'
import { relative, resolve } from 'node:path'

const ROOT = process.cwd()
const STORY_RE = /\.stories\.(js|jsx|mjs|ts|tsx)$/

function readStdin() {
return new Promise((resolveStdin) => {
let data = ''
process.stdin.on('data', (chunk) => (data += chunk))
process.stdin.on('end', () => resolveStdin(data))
})
}

function readExistingFile(filePath) {
try {
return readFileSync(filePath, 'utf-8')
} catch {
return ''
}
}

// Reconstruct the content the Write/Edit/MultiEdit would produce, so whole-file
// checks (import present, sourceState present) see the real result.
function computeResult(tool, ti, baseline) {
if (tool === 'Write') return ti.content ?? ''
if (tool === 'Edit') {
if (typeof ti.old_string !== 'string') return baseline
return baseline.replace(ti.old_string, ti.new_string ?? '')
}
if (tool === 'MultiEdit') {
let out = baseline
for (const e of ti.edits ?? []) {
if (typeof e.old_string === 'string') out = out.replace(e.old_string, e.new_string ?? '')
}
return out
}
return baseline
}

const toKebab = (name) => name.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase()
const toPascal = (kebab) =>
kebab
.split(/[-/]/)
.filter(Boolean)
.map((p) => p.charAt(0).toUpperCase() + p.slice(1))
.join('')

// PascalCase components imported from the webkit package.
function importedComponents(content) {
const names = new Set()
const re = /import\s+([A-Z][A-Za-z0-9]*)\s+from\s+['"]@aziontech\/webkit\/[^'"]+['"]/g
let m
while ((m = re.exec(content))) names.add(m[1])
return [...names]
}

// Default imports of webkit components whose binding does not match the export
// subpath. `import Chip from '@aziontech/webkit/chips'` is wrong — the binding
// must be PascalCase(last segment of the subpath), i.e. `Chips`. This keeps the
// snippet, the component name, and the export path in lockstep.
function importBindingMismatches(content) {
const out = []
const re = /import\s+([A-Za-z_$][\w$]*)\s+from\s+['"]@aziontech\/webkit\/([^'"]+)['"]/g
let m
while ((m = re.exec(content))) {
const binding = m[1]
const subpath = m[2]
if (subpath.startsWith('utils/')) continue // non-component helpers (e.g. utils/cn)
const expected = toPascal(subpath.split('/').pop())
if (binding !== expected) out.push({ binding, subpath, expected })
}
return out
}

// Lowercase/kebab tags of imported PascalCase components present as real tags.
function lowercaseTagHits(content, components) {
const hits = []
for (const name of components) {
if (name === name.toLowerCase()) continue
for (const tag of new Set([name.toLowerCase(), toKebab(name)])) {
const re = new RegExp(`<\\/?${tag}(?=[\\s/>])`, 'g')
if (re.test(content)) hits.push({ tag, expected: name })
}
}
return hits
}

function checks(result, baseline, isNew) {
const violations = []
const has = (s, str) => s.includes(str)

const usesHelper = has(result, '_shared/story-source')
const usesToSfc = has(result, 'toSfc(')
const hasDocsConfig = has(result, 'autodocs') || /\bdocs\s*:/.test(result)

// ---- anti-patterns (block when newly introduced) ----

// `docs:` set to a function call rather than a plain object literal. Storybook
// then prints the raw CSF story object instead of the runnable snippet.
const docsCall = /\bdocs:\s*[A-Za-z_$][\w$]*\s*\(/
if (docsCall.test(result) && !docsCall.test(baseline)) {
violations.push({
id: 'docs-not-literal',
message: 'parameters.docs is a function call. It must be a plain object literal; build the snippet with source.code: toSfc(...).'
})
}

// Dynamic source transform — we use explicit source.code, never a transform.
const transform = /\btransform:\s*(\(|async|function)/
if (transform.test(result) && !transform.test(baseline)) {
violations.push({
id: 'handrolled-transform',
message: 'docs.source.transform is forbidden. Set an explicit source.code: toSfc(IMPORT, TEMPLATE) instead.'
})
}

// Nested <template>.
if (/<template>\s*<template>/.test(result) && !/<template>\s*<template>/.test(baseline)) {
violations.push({
id: 'nested-template',
message: 'Nested <template> in the snippet. toSfc adds exactly one wrapper — the body must not contain <template>.'
})
}

// Lowercase/kebab tag of an imported component.
const hits = lowercaseTagHits(result, importedComponents(result))
const baseHits = new Set(lowercaseTagHits(baseline, importedComponents(baseline)).map((h) => h.tag))
const newHits = hits.filter((h) => !baseHits.has(h.tag))
if (newHits.length) {
violations.push({
id: 'lowercase-tag',
message: `Lowercase/kebab component tag(s): ${newHits
.map((h) => `<${h.tag}> → <${h.expected}>`)
.join(', ')}. Tags must match the PascalCase import.`
})
}

// Import binding must match the export subpath (PascalCase). Catches
// `import Chip from '@aziontech/webkit/chips'` (binding Chip vs subpath chips).
const bindHits = importBindingMismatches(result)
const baseBinds = new Set(importBindingMismatches(baseline).map((h) => `${h.binding}:${h.subpath}`))
const newBinds = bindHits.filter((h) => !baseBinds.has(`${h.binding}:${h.subpath}`))
if (newBinds.length) {
violations.push({
id: 'import-binding-mismatch',
message: `Import binding(s) do not match the export subpath: ${newBinds
.map((h) => `import ${h.binding} from '@aziontech/webkit/${h.subpath}' → expected '${h.expected}'`)
.join('; ')}. Rename the binding, or the component/export, so file ↔ export ↔ name ↔ binding all agree.`
})
}

// ---- presence (enforced for NEW story files that emit docs) ----
if (isNew && hasDocsConfig) {
if (!usesHelper) {
violations.push({
id: 'missing-helper',
message: 'New story emits docs but does not import the shared helper (_shared/story-source).'
})
}
if (!usesToSfc) {
violations.push({
id: 'missing-source-code',
message: 'New story does not build its Show code with source.code: toSfc(IMPORT, TEMPLATE).'
})
}
if (!has(result, 'sourceState')) {
violations.push({
id: 'missing-sourcestate',
message: "Missing canvas.sourceState: 'shown' in the meta docs block."
})
}
}

return violations
}

async function main() {
const raw = await readStdin()
let input
try {
input = JSON.parse(raw)
} catch {
process.exit(0)
}

const tool = input.tool_name
if (!['Write', 'Edit', 'MultiEdit'].includes(tool)) process.exit(0)

const filePath = input.tool_input?.file_path
if (!filePath || !STORY_RE.test(filePath)) process.exit(0)

const relPath = relative(ROOT, resolve(filePath))
const baseline = readExistingFile(filePath)
const isNew = baseline === ''
const result = computeResult(tool, input.tool_input ?? {}, baseline)

const violations = checks(result, baseline, isNew)
if (violations.length === 0) process.exit(0)

const lines = [`BLOCKED: Storybook "Show code" validation failed on ${relPath}.`, '']
for (const v of violations) lines.push(` [${v.id}] ${v.message}`)
lines.push('')
lines.push('Build the snippet with toSfc from apps/storybook/src/stories/_shared/story-source.js.')
lines.push('Rule: .claude/rules/storybook-source.md')

process.stderr.write(lines.join('\n') + '\n')
process.exit(2)
}

main().catch((err) => {
process.stderr.write(`validate-story-source hook error: ${err?.message ?? err}\n`)
process.exit(0) // fail open
})
29 changes: 29 additions & 0 deletions .claude/rules/naming.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# Rule: one name, everywhere

A component has **one** name. That name appears on six surfaces, and they must all agree. A component called singular in one place and plural in another (`import Chip from '@aziontech/webkit/chips'`) is a bug — and the kind of bug a guardrail must make impossible, not a thing reviewers chase by eye.

## The single source of truth

The **kebab-case name** is canonical. Everything else is derived from it:

| Surface | Form | Example (`chip`) |
|---|---|---|
| spec file + `name:` frontmatter | kebab | `.specs/chip.md`, `name: chip` |
| component folder + `.vue` file | kebab | `inputs/chip/chip.vue` |
| export subpath in `packages/webkit/package.json#exports` | kebab | `"./chip": ".../chip/chip.vue"` |
| `defineOptions({ name })` | **PascalCase** | `name: 'Chip'` |
| `data-testid` fallback | `input-<name>` (inputs) / `<category>-<name>` | `'input-chip'` |
| import binding + tag in a story | **PascalCase** of the export subpath | `import Chip from '@aziontech/webkit/chip'` → `<Chip>` |

PascalCase is the kebab name with each `-`-segment capitalized: `chip` → `Chip`, `empty-state` → `EmptyState`, `mini-button` → `MiniButton`. Plurality is part of the name — pick one (`chip`, singular, for a single token) and use it on every surface. A container for many is a *different* component (`chip-group`), not a plural spelling of the same one.

## What enforces it

- **Component internals** — [`validate-spec-compliance.mjs`](../hooks/validate-spec-compliance.mjs) already blocks a `.vue` whose `defineOptions.name` ≠ `PascalCase(spec name)` or whose `data-testid` fallback ≠ `<category>-<name>` (`input-<name>` for inputs). So spec ↔ file ↔ `defineOptions` ↔ testid cannot drift.
- **Story imports** — [`validate-story-source.mjs`](../hooks/validate-story-source.mjs) blocks any `import <Binding> from '@aziontech/webkit/<subpath>'` whose `<Binding>` ≠ `PascalCase(last segment of <subpath>)`. This is the surface the spec-compliance hook doesn't see, and it is exactly where the `Chip`-from-`chips` mismatch slipped through.

Between the two, all six surfaces are locked together. To rename a component you change the canonical kebab name and propagate to every surface — the hooks reject any partial rename.

## When importing / migrating

Per [`migration.md`](./migration.md), a name inherited from another system is rewritten to ours, not carried over. The kebab name drives the rest; never introduce a binding that disagrees with the export path "to match the source."
Loading
Loading