diff --git a/CLAUDE.md b/CLAUDE.md index db397e5..e97240f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -64,6 +64,8 @@ ## Component Catalog + + | Need | Component | Props you will use | |------|-----------|-------------------| | Button | `GnButton` | `variant`, `size`, `icon`, `loading`, `disabled` | @@ -102,12 +104,12 @@ | Skeleton loader | `GnSkeleton` | — | | Key-value list | `GnDescriptionList` | `items` | | Progress bar | `GnProgress` | `value`, `label`, `animated` | -| Usage meter card | `GnUsageMeter` | `value`, `label`, `max` | +| Usage meter card | `GnUsageMeter` | `title`, `value`, `max`, `meta` | | Staged progress | `GnProgressStages` | `items` | -| Wizard steps | `GnSteps` | `items`, `current` | +| Wizard steps | `GnSteps` | `items`, `vertical` | | Chip / tag | `GnChip` | `variant`, `selected`, `removable` | | Chip group | `GnChipGroup` | — | -| Avatar | `GnAvatar` | `initials`, `icon`, `image`, `size`, `status` | +| Avatar | `GnAvatar` | `src`, `alt`, `initials`, `icon`, `size`, `status` | | Identity row | `GnIdentity` | `title`, `meta`, `avatar` props | | Avatar stack | `GnAvatarStack` | `items` | | Timeline | `GnTimeline` | `items` | @@ -116,8 +118,8 @@ | Definition list | `GnDefinitionList` | `items` | | Action list | `GnActionList` | `items` | | Loader / spinner | `GnLoader` | — | -| Status card | `GnStatusCard` | `variant`, `title`, `meta` | -| Metric card | `GnMetricCard` | `title`, `value`, `trend` | +| Status card | `GnStatusCard` | `title`, `text`, `icon`, `variant` | +| Metric card | `GnMetricCard` | `label`, `value`, `icon`, `delta`, `negative`, `meta` | | Action card | `GnActionCard` | `title`, `text`, `actions` (slot) | | Dropdown menu | `GnDropdown` | `items`, `label`, `variant` | | Popover panel | `GnPopover` | `title`, `text`, `label` | @@ -125,6 +127,8 @@ | Navigation list | `GnNavList` | `items` | | App shell | `GnNavigationShell` | `brand`, `items`, `current`, `footerLeft`, `footerRight` | + + ## Variants Use only these variant names: diff --git a/docs/catalog.json b/docs/catalog.json new file mode 100644 index 0000000..1116dbb --- /dev/null +++ b/docs/catalog.json @@ -0,0 +1,60 @@ +[ + { "need": "Button", "component": "GnButton", "props": ["variant", "size", "icon", "loading", "disabled"] }, + { "need": "Icon-only button", "component": "GnIconButton", "props": ["icon", "label", "size"] }, + { "need": "Copy button", "component": "GnCopyButton", "props": ["text", "icon", "successIcon", "duration", "label", "size"] }, + { "need": "Status label", "component": "GnBadge", "props": ["variant", "outline"] }, + { "need": "Message block", "component": "GnAlert", "props": ["variant"] }, + { "need": "Card / panel", "component": "GnCard", "props": ["title (slot)", "footer (slot)"] }, + { "need": "Horizontal card", "component": "GnHorizontalCard", "props": ["image", "title", "titleHref", "icon", "footer (slot)"] }, + { "need": "Login card", "component": "GnLoginCard", "props": ["title", "logoIcon", "usernameLabel", "passwordLabel", "rememberMe", "submitText", "error", "forgotHref", "signupHref"] }, + { "need": "User card", "component": "GnUserCard", "props": ["name", "email", "role", "avatar", "href", "compact", "actions"] }, + { "need": "Page title bar", "component": "GnPageHeader", "props": ["kicker", "title", "subtitle", "meta", "actions (slot)"] }, + { "need": "Text field", "component": "GnInput", "props": ["v-model", "label", "icon", "state", "help"] }, + { "need": "Textarea", "component": "GnTextarea", "props": ["v-model", "label", "state", "help"] }, + { "need": "Select dropdown", "component": "GnSelect", "props": ["v-model", "label", "options"] }, + { "need": "Checkbox", "component": "GnCheckbox", "props": ["v-model", "label", "disabled"] }, + { "need": "Toggle switch", "component": "GnSwitch", "props": ["v-model", "label", "disabled"] }, + { "need": "Radio", "component": "GnRadio / GnRadioGroup", "props": ["v-model", "options"] }, + { "need": "Range slider", "component": "GnRange", "props": ["v-model", "label", "min", "max"] }, + { "need": "File upload", "component": "GnFileUpload", "props": ["v-model", "badge", "multiple", "accept"] }, + { "need": "Searchable select", "component": "GnCombobox", "props": ["v-model", "label", "options", "placeholder"] }, + { "need": "Tag / multi-value input", "component": "GnTagInput", "props": ["v-model", "label", "placeholder", "separator", "unique", "maxItems"] }, + { "need": "Tabs", "component": "GnTabs", "props": ["v-model", "items"] }, + { "need": "Router tabs", "component": "GnRouterTabs", "props": ["`items` (with `to`)", "activeMatch"] }, + { "need": "Accordion", "component": "GnAccordion", "props": ["items", "v-model", "multiple"] }, + { "need": "Modal dialog", "component": "GnModal", "props": ["v-model:open", "title", "closeOnBackdrop"] }, + { "need": "Side drawer", "component": "GnDrawer", "props": ["v-model:open", "title", "position"] }, + { "need": "Toasts", "component": "GnToastProvider + useToast", "propsNote": "Wrap app once; call `toast.success({ title, text })`" }, + { "need": "Confirm dialog", "component": "GnConfirmDialog", "props": ["v-model:open", "title", "message", "confirmVariant"] }, + { "need": "Table", "component": "GnTable", "props": ["columns", "rows", "caption", "emptyText"] }, + { "need": "Toolbar", "component": "GnToolbar", "props": ["title", "meta", "actions (slot)"] }, + { "need": "Input group", "component": "GnInputGroup", "props": ["addon (slot)", "action (slot)"] }, + { "need": "Search field", "component": "GnSearchField", "props": ["v-model"] }, + { "need": "Pagination", "component": "GnPagination", "props": ["page", "total-pages"] }, + { "need": "Empty state", "component": "GnEmptyState", "props": ["title", "text", "icon", "actions (slot)"] }, + { "need": "Skeleton loader", "component": "GnSkeleton", "props": [] }, + { "need": "Key-value list", "component": "GnDescriptionList", "props": ["items"] }, + { "need": "Progress bar", "component": "GnProgress", "props": ["value", "label", "animated"] }, + { "need": "Usage meter card", "component": "GnUsageMeter", "props": ["title", "value", "max", "meta"] }, + { "need": "Staged progress", "component": "GnProgressStages", "props": ["items"] }, + { "need": "Wizard steps", "component": "GnSteps", "props": ["items", "vertical"] }, + { "need": "Chip / tag", "component": "GnChip", "props": ["variant", "selected", "removable"] }, + { "need": "Chip group", "component": "GnChipGroup", "props": [] }, + { "need": "Avatar", "component": "GnAvatar", "props": ["src", "alt", "initials", "icon", "size", "status"] }, + { "need": "Identity row", "component": "GnIdentity", "propsNote": "`title`, `meta`, `avatar` props" }, + { "need": "Avatar stack", "component": "GnAvatarStack", "props": ["items"] }, + { "need": "Timeline", "component": "GnTimeline", "props": ["items"] }, + { "need": "Activity log", "component": "GnActivityLog", "props": ["items"] }, + { "need": "Basic list", "component": "GnList", "props": ["items"] }, + { "need": "Definition list", "component": "GnDefinitionList", "props": ["items"] }, + { "need": "Action list", "component": "GnActionList", "props": ["items"] }, + { "need": "Loader / spinner", "component": "GnLoader", "props": [] }, + { "need": "Status card", "component": "GnStatusCard", "props": ["title", "text", "icon", "variant"] }, + { "need": "Metric card", "component": "GnMetricCard", "props": ["label", "value", "icon", "delta", "negative", "meta"] }, + { "need": "Action card", "component": "GnActionCard", "props": ["title", "text", "actions (slot)"] }, + { "need": "Dropdown menu", "component": "GnDropdown", "props": ["items", "label", "variant"] }, + { "need": "Popover panel", "component": "GnPopover", "props": ["title", "text", "label"] }, + { "need": "Tooltip", "component": "GnTooltip", "props": ["text"] }, + { "need": "Navigation list", "component": "GnNavList", "props": ["items"] }, + { "need": "App shell", "component": "GnNavigationShell", "props": ["brand", "items", "current", "footerLeft", "footerRight"] } +] \ No newline at end of file diff --git a/package.json b/package.json index 847213e..28d6603 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,9 @@ "build:example:vue": "npm --prefix examples/vue run build", "build:vue": "gulp vue", "dev": "gulp serve", - "release:check": "npm run test:unit && npm run test:vue-adapter && npm run test:vue-package && npm run test:demo-sync", + "gen:catalog": "node scripts/generate-catalog.mjs", + "release:check": "npm run test:unit && npm run test:catalog && npm run test:vue-adapter && npm run test:vue-package && npm run test:demo-sync", + "test:catalog": "node scripts/generate-catalog.mjs --check", "test:demo-sync": "node compare-heights.js", "test:unit": "vitest run", "start": "gulp serve", diff --git a/scripts/generate-catalog.mjs b/scripts/generate-catalog.mjs new file mode 100644 index 0000000..e7099fd --- /dev/null +++ b/scripts/generate-catalog.mjs @@ -0,0 +1,147 @@ +#!/usr/bin/env node +// Regenerates the Component Catalog table in CLAUDE.md from +// docs/catalog.json (the single source of truth) and cross-checks every +// listed prop/slot against the actual props and slot usages declared in +// src/vue/components/*.js. +// +// node scripts/generate-catalog.mjs regenerate + validate +// node scripts/generate-catalog.mjs --check fail if CLAUDE.md is stale + +import { readFileSync, readdirSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = join(dirname(fileURLToPath(import.meta.url)), ".."); +const catalogPath = join(root, "docs/catalog.json"); +const claudePath = join(root, "CLAUDE.md"); +const START = ""; +const END = ""; + +const rows = JSON.parse(readFileSync(catalogPath, "utf8")); + +const escapeRegExp = text => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + +// --- collect declared props and slot usages per component ----------------- +const componentDir = join(root, "src/vue/components"); +const declared = {}; +for(const file of readdirSync(componentDir).filter(name => name.endsWith(".js"))) { + const source = readFileSync(join(componentDir, file), "utf8"); + const name = file.replace(/\.js$/, ""); + const props = new Set(); + const propsBlock = source.match(/props:\s*\{([\s\S]*?)\n\t\}/); + if(propsBlock) { + for(const match of propsBlock[1].matchAll(/^\s*([a-zA-Z]+):/gm)) { + props.add(match[1]); + } + } + const slots = new Set( + [...source.matchAll(/slots\.([a-zA-Z]+)/g)].map(match => match[1]) + ); + declared[name] = { props, slots }; +} + +// --- validate the catalog rows against the declarations ------------------- +let warnings = 0; +const warn = message => { + warnings++; + console.warn(`[catalog] ${message}`); +}; + +const checkToken = (component, token, kind) => { + const names = component.split("/").map(name => name.trim()); + const known = names.some(name => { + const entry = declared[name]; + if(!entry) { + warn(`component ${name} (row "${component}") has no source file`); + return true; // already reported, don't double-report the token + } + return kind === "slot" ? entry.slots.has(token) || entry.props.has(token) : entry.props.has(token) || entry.slots.has(token); + }); + if(!known) { + warn(`"${component}" does not declare ${kind} "${token}"`); + } +}; + +for(const row of rows) { + if(row.propsNote) { + continue; // free-form note, nothing to validate + } + + for(const item of row.props) { + // "items (with `to`)" -> prop "items"; "title (slot)" -> slot "title" + const slotSuffix = item.endsWith("(slot)"); + const raw = item.replace(/\s*\(slot\)$/, "").replace(/`/g, "").trim(); + // drop an explanatory parenthetical: "items (with to)" -> "items" + const head = raw.includes(" (") ? raw.slice(0, raw.indexOf(" (")) : raw; + + let kind = slotSuffix ? "slot" : "prop"; + let token = head; + if(token === "v-model") { + token = "modelValue"; + } else if(token.startsWith("v-model:")) { + token = token.slice("v-model:".length); + } + // template-side kebab-case ("total-pages") = camelCase prop ("totalPages") + token = token.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase()); + + if(!/^[a-zA-Z][a-zA-Z0-9]*$/.test(token)) { + warn(`uncheckable entry "${item}" on "${row.component}"`); + continue; + } + checkToken(row.component, token, kind); + } +} + +// --- render the table ------------------------------------------------------ +const renderProps = row => { + if(row.propsNote) { + return row.propsNote; + } + if(!row.props.length) { + return "—"; + } + return row.props + .map(item => { + // items carrying their own backticks are rendered verbatim + if(item.includes("`")) { + return item; + } + const slotSuffix = item.endsWith("(slot)"); + const name = item.replace(/\s*\(slot\)$/, "").trim(); + return `\`${name}\`${slotSuffix ? " (slot)" : ""}`; + }) + .join(", "); +}; + +const renderComponent = name => name.replace(/([A-Za-z][A-Za-z0-9]*)/g, "`$1`"); + +const table = [ + "| Need | Component | Props you will use |", + "|------|-----------|-------------------|", + ...rows.map(row => `| ${row.need} | ${renderComponent(row.component)} | ${renderProps(row)} |`) +].join("\n"); + +const claude = readFileSync(claudePath, "utf8"); +const pattern = new RegExp(`${escapeRegExp(START)}[\\s\\S]*${escapeRegExp(END)}`); +if(!pattern.test(claude)) { + console.error(`[catalog] markers not found in CLAUDE.md; wrap the table with:\n${START}\n...\n${END}`); + process.exit(1); +} + +const updated = claude.replace(pattern, `${START}\n\n${table}\n\n${END}`); + +if(process.argv.includes("--check")) { + if(updated !== claude) { + console.error("[catalog] CLAUDE.md is stale — run `npm run gen:catalog`"); + process.exit(1); + } + console.log("[catalog] CLAUDE.md is up to date"); +} else { + writeFileSync(claudePath, updated); + console.log(`[catalog] CLAUDE.md updated (${rows.length} rows)`); +} + +if(warnings > 0) { + console.warn(`[catalog] ${warnings} validation warning(s)`); +} +process.exit(0); \ No newline at end of file