From c96ead6926d30b15ad99ada4a36ff55b88c543ba Mon Sep 17 00:00:00 2001 From: "ai@codebar" Date: Sat, 8 Aug 2026 06:21:03 +0200 Subject: [PATCH 1/5] Give every component a named, exported props type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A consuming app that wraps an atom could not name the wrapped component's prop types. The supporting types were all exported — Tone, Category, SelectOption, DataTableColumn, BreadcrumbItem, TabItem, RowKey, SortState, IconName — but the props themselves reached dist as 71 anonymous __VLS_Props interfaces, which nothing can import. So a wrapper re-declared the unions by hand: `variant: String` compiles in the app and then fails against `'danger' | 'primary' | …` at the boundary. Every `defineProps<{ … }>()` type literal becomes an `export interface Props`, re-exported from the barrel. dist/index.d.ts now carries 73 named prop interfaces and zero __VLS_Props. verify:props-exports keeps the three parts in step: the SFC declares the interface, the barrel re-exports it, and api-extractor carries it into the bundled declarations. Only the last is observable to a consumer, and a type dropped from the rollup is invisible until an app tries to import it. Verified against a consumer fixture resolving the package through its exports map: vue-tsc accepts `ModalProps['size']` and `PageHeadingProps & { … }`, and @vue/compiler-sfc resolves `defineProps()` out of the published .d.ts into correct runtime props. --- package.json | 3 +- scripts/verify-props-exports.mjs | 72 ++++++++++++++++ src/components/atoms/Avatar.vue | 12 +-- src/components/atoms/Badge.vue | 44 +++++----- src/components/atoms/Button.vue | 26 +++--- src/components/atoms/Checkbox.vue | 18 ++-- src/components/atoms/Divider.vue | 10 ++- src/components/atoms/Icon.vue | 10 ++- src/components/atoms/IconBadge.vue | 14 ++-- src/components/atoms/IdCell.vue | 10 ++- src/components/atoms/Input.vue | 14 ++-- src/components/atoms/KindMark.vue | 42 +++++----- src/components/atoms/Label.vue | 10 ++- src/components/atoms/Link.vue | 14 ++-- src/components/atoms/ListIcon.vue | 4 +- src/components/atoms/Metric.vue | 6 +- src/components/atoms/NumericCell.vue | 4 +- src/components/atoms/PrimarySubtitleCell.vue | 12 +-- src/components/atoms/Progress.vue | 12 +-- src/components/atoms/Radio.vue | 20 +++-- src/components/atoms/Select.vue | 16 ++-- src/components/atoms/Spinner.vue | 10 ++- src/components/atoms/StatusBadge.vue | 26 +++--- src/components/atoms/Tab.vue | 4 +- src/components/atoms/Textarea.vue | 14 ++-- src/components/atoms/Th.vue | 12 +-- src/components/atoms/Toggle.vue | 16 ++-- src/components/layouts/AuthLayout.vue | 12 +-- src/components/layouts/ErrorLayout.vue | 12 +-- src/components/molecules/Accordion.vue | 10 ++- src/components/molecules/AccordionItem.vue | 12 +-- src/components/molecules/Alert.vue | 12 +-- src/components/molecules/Breadcrumbs.vue | 10 ++- src/components/molecules/Card.vue | 26 +++--- src/components/molecules/CodeLine.vue | 10 ++- src/components/molecules/Combobox.vue | 18 ++-- src/components/molecules/CopyButton.vue | 14 ++-- src/components/molecules/DescriptionItem.vue | 10 ++- src/components/molecules/DescriptionList.vue | 8 +- src/components/molecules/Dropdown.vue | 16 ++-- src/components/molecules/DropdownItem.vue | 18 ++-- src/components/molecules/EmptyState.vue | 14 ++-- src/components/molecules/Field.vue | 20 +++-- src/components/molecules/FileInput.vue | 16 ++-- src/components/molecules/FormActions.vue | 4 +- src/components/molecules/FormGrid.vue | 8 +- src/components/molecules/InputNumber.vue | 20 +++-- src/components/molecules/KindLegend.vue | 12 +-- src/components/molecules/LabeledCodeBlock.vue | 12 +-- src/components/molecules/ListRow.vue | 14 ++-- src/components/molecules/PageHeading.vue | 12 +-- src/components/molecules/Pagination.vue | 16 ++-- src/components/molecules/PasswordInput.vue | 12 +-- src/components/molecules/PinInput.vue | 18 ++-- src/components/molecules/Popover.vue | 16 ++-- src/components/molecules/RadioGroup.vue | 4 +- src/components/molecules/SearchableSelect.vue | 16 ++-- src/components/molecules/Stepper.vue | 10 ++- src/components/molecules/Tabs.vue | 10 ++- src/components/molecules/Tooltip.vue | 10 ++- src/components/organisms/Chart.vue | 14 ++-- src/components/organisms/CodeEditor.vue | 28 ++++--- src/components/organisms/CodePreview.vue | 12 +-- src/components/organisms/ConfigValue.vue | 6 +- src/components/organisms/DataTable.vue | 44 +++++----- src/components/organisms/Drawer.vue | 20 +++-- .../organisms/KnowledgeSchemaPanel.vue | 16 ++-- src/components/organisms/MetricGrid.vue | 6 +- src/components/organisms/Modal.vue | 40 ++++----- src/components/organisms/Navbar.vue | 10 ++- src/components/organisms/ResourceList.vue | 26 +++--- src/components/organisms/Sidebar.vue | 8 +- src/components/organisms/SidebarGroup.vue | 4 +- src/components/organisms/SidebarItem.vue | 14 ++-- src/components/organisms/Toaster.vue | 12 +-- src/index.ts | 82 +++++++++++++++++++ 76 files changed, 765 insertions(+), 464 deletions(-) create mode 100644 scripts/verify-props-exports.mjs diff --git a/package.json b/package.json index 288b0bf..6fdaa32 100644 --- a/package.json +++ b/package.json @@ -25,8 +25,9 @@ "scripts": { "prepare": "npm run build", "dev": "storybook dev -p 6006", - "build": "vite build && npm run build:tokens && npm run verify:externals && npm run verify:dev-warnings", + "build": "vite build && npm run build:tokens && npm run verify:externals && npm run verify:dev-warnings && npm run verify:props-exports", "verify:externals": "node scripts/verify-externals.mjs", + "verify:props-exports": "node scripts/verify-props-exports.mjs", "build:tokens": "node -e \"require('node:fs').copyFileSync('src/tokens.css','dist/tokens.css')\"", "build-storybook": "storybook build", "lint": "eslint \"src/**/*.{ts,vue}\"", diff --git a/scripts/verify-props-exports.mjs b/scripts/verify-props-exports.mjs new file mode 100644 index 0000000..7e04cf7 --- /dev/null +++ b/scripts/verify-props-exports.mjs @@ -0,0 +1,72 @@ +// Guards the per-component prop types that consuming apps wrap components with. +// +// A consuming app that wraps an atom — its own ConfirmDialog over Modal, its own +// SectionShell over PageHeading — has to name the wrapped component's prop types +// or it re-declares the unions by hand and they drift. `variant: String` passes +// the app's own build and then fails against `'danger' | 'primary' | …` at the +// boundary. So every component's props are declared as a named, exported +// `Props` interface and re-exported from `src/index.ts`. +// +// Three things have to line up, and only the third is observable from outside: +// the SFC declares the interface, the barrel re-exports it, and api-extractor +// carries it into the bundled `dist/index.d.ts`. The last one is the one that +// actually matters to a consumer and the one nothing else checks — a type that +// is exported from source but dropped from the rollup is invisible until an app +// tries to import it. +import { existsSync, globSync, readFileSync } from 'node:fs'; +import { basename } from 'node:path'; + +const components = globSync('src/components/*/*.vue').sort(); +const index = readFileSync('src/index.ts', 'utf8'); +const errors = []; + +/** Components whose props are exported, for the dist check below. */ +const exported = []; + +for (const file of components) { + const name = basename(file, '.vue'); + const source = readFileSync(file, 'utf8'); + + if (!source.includes('defineProps')) { + continue; + } + + if (!source.includes(`export interface ${name}Props`)) { + errors.push( + `${file} declares props but no \`export interface ${name}Props\`. ` + + 'Name the props interface and export it, rather than passing a type literal to defineProps.', + ); + continue; + } + + if (!index.includes(`export type { ${name}Props }`)) { + errors.push( + `src/index.ts does not re-export ${name}Props. ` + + `Add: export type { ${name}Props } from './${file.slice(4)}';`, + ); + continue; + } + + exported.push(`${name}Props`); +} + +// Only meaningful after a build; `npm run build` runs this last, on purpose. +if (existsSync('dist/index.d.ts')) { + const declarations = readFileSync('dist/index.d.ts', 'utf8'); + const missing = exported.filter( + (type) => !new RegExp(`\\binterface ${type}\\b`).test(declarations), + ); + + if (missing.length > 0) { + errors.push( + `dist/index.d.ts is missing: ${missing.join(', ')}.\n` + + 'The barrel exports them but api-extractor did not carry them into the bundled ' + + 'declarations, so no consumer can import them.', + ); + } +} + +if (errors.length > 0) { + console.error(errors.join('\n\n')); + process.exit(1); +} diff --git a/src/components/atoms/Avatar.vue b/src/components/atoms/Avatar.vue index 96dd951..a3f99d1 100644 --- a/src/components/atoms/Avatar.vue +++ b/src/components/atoms/Avatar.vue @@ -8,12 +8,14 @@ import { useRootAttrs } from '../../composables/useRootAttrs'; defineOptions({ inheritAttrs: false }); +export interface AvatarProps { + name?: string; + src?: string | null; + size?: 'sm' | 'md' | 'lg'; +} + const props = withDefaults( - defineProps<{ - name?: string; - src?: string | null; - size?: 'sm' | 'md' | 'lg'; - }>(), + defineProps(), { name: '', src: null, size: 'md' }, ); diff --git a/src/components/atoms/Badge.vue b/src/components/atoms/Badge.vue index b7e1034..3c58b44 100644 --- a/src/components/atoms/Badge.vue +++ b/src/components/atoms/Badge.vue @@ -11,28 +11,30 @@ import { warnOnce } from '../../helpers/dev'; mutual-exclusivity check below has to tell those apart. The `neutral` fallback is applied one level down by `resolveTone()`, so the rendered result is unchanged. */ +export interface BadgeProps { + /** + * SEVERITY tone — how bad is this. The color names + * (gray/blue/green/amber/red) are deprecated aliases and will be + * removed in the next major release. + * + * Mutually exclusive with `category`. Defaults to `neutral` when + * neither is given. + */ + variant?: Tone | LegacyTone; + /** + * CATEGORICAL identity — which kind is this, with no severity implied. + * For state that is not a status: on/off, inherited/overridden, + * first/duplicate, draft/published, or a kind-of-thing label. + * + * Mutually exclusive with `variant`; takes precedence if both are + * passed. Use `variant="neutral"` for "no category". + */ + category?: Category; + size?: 'sm' | 'md'; +} + const props = withDefaults( - defineProps<{ - /** - * SEVERITY tone — how bad is this. The color names - * (gray/blue/green/amber/red) are deprecated aliases and will be - * removed in the next major release. - * - * Mutually exclusive with `category`. Defaults to `neutral` when - * neither is given. - */ - variant?: Tone | LegacyTone; - /** - * CATEGORICAL identity — which kind is this, with no severity implied. - * For state that is not a status: on/off, inherited/overridden, - * first/duplicate, draft/published, or a kind-of-thing label. - * - * Mutually exclusive with `variant`; takes precedence if both are - * passed. Use `variant="neutral"` for "no category". - */ - category?: Category; - size?: 'sm' | 'md'; - }>(), + defineProps(), { size: 'md' }, ); /* eslint-enable vue/require-default-prop */ diff --git a/src/components/atoms/Button.vue b/src/components/atoms/Button.vue index a2bfd63..fd888c5 100644 --- a/src/components/atoms/Button.vue +++ b/src/components/atoms/Button.vue @@ -8,19 +8,21 @@ import Spinner from './Spinner.vue'; defineOptions({ inheritAttrs: false }); +export interface ButtonProps { + variant?: 'primary' | 'secondary' | 'ghost' | 'danger' | 'subtle' | 'cta'; + size?: 'sm' | 'md' | 'lg'; + type?: 'button' | 'submit' | 'reset'; + // Pass a component (e.g. Inertia's Link) to keep navigation client-side + // while staying framework-agnostic — the package never imports Inertia. + as?: 'button' | 'a' | Component; + href?: string | null; + // Shows a centered spinner and blocks interaction; the label stays in + // the layout (hidden, not removed) so the button keeps its width. + loading?: boolean; +} + const props = withDefaults( - defineProps<{ - variant?: 'primary' | 'secondary' | 'ghost' | 'danger' | 'subtle' | 'cta'; - size?: 'sm' | 'md' | 'lg'; - type?: 'button' | 'submit' | 'reset'; - // Pass a component (e.g. Inertia's Link) to keep navigation client-side - // while staying framework-agnostic — the package never imports Inertia. - as?: 'button' | 'a' | Component; - href?: string | null; - // Shows a centered spinner and blocks interaction; the label stays in - // the layout (hidden, not removed) so the button keeps its width. - loading?: boolean; - }>(), + defineProps(), { variant: 'primary', size: 'md', type: 'button', as: 'button', href: null, loading: false }, ); diff --git a/src/components/atoms/Checkbox.vue b/src/components/atoms/Checkbox.vue index 21f19c9..b718f17 100644 --- a/src/components/atoms/Checkbox.vue +++ b/src/components/atoms/Checkbox.vue @@ -2,15 +2,17 @@ import { computed } from 'vue'; import { useFieldA11y } from '../../composables/useFieldA11y'; +export interface CheckboxProps { + modelValue?: boolean; + name?: string | null; + value?: string; + invalid?: boolean; + description?: string | null; + disabled?: boolean; +} + const props = withDefaults( - defineProps<{ - modelValue?: boolean; - name?: string | null; - value?: string; - invalid?: boolean; - description?: string | null; - disabled?: boolean; - }>(), + defineProps(), { modelValue: false, name: null, value: '1', invalid: false, description: null, disabled: false }, ); diff --git a/src/components/atoms/Divider.vue b/src/components/atoms/Divider.vue index 86e3208..8f7f63b 100644 --- a/src/components/atoms/Divider.vue +++ b/src/components/atoms/Divider.vue @@ -6,11 +6,13 @@ import { useRootAttrs } from '../../composables/useRootAttrs'; defineOptions({ inheritAttrs: false }); +export interface DividerProps { + orientation?: 'horizontal' | 'vertical'; + label?: string | null; +} + withDefaults( - defineProps<{ - orientation?: 'horizontal' | 'vertical'; - label?: string | null; - }>(), + defineProps(), { orientation: 'horizontal', label: null }, ); diff --git a/src/components/atoms/Icon.vue b/src/components/atoms/Icon.vue index 37d9c04..eff11ed 100644 --- a/src/components/atoms/Icon.vue +++ b/src/components/atoms/Icon.vue @@ -3,11 +3,13 @@ import { computed } from 'vue'; import { icons, type IconName } from '../../icons'; import { pick } from '../../helpers/pick'; +export interface IconProps { + name?: IconName; + size?: 'sm' | 'md' | 'lg'; +} + const props = withDefaults( - defineProps<{ - name?: IconName; - size?: 'sm' | 'md' | 'lg'; - }>(), + defineProps(), { name: 'plus', size: 'md' }, ); diff --git a/src/components/atoms/IconBadge.vue b/src/components/atoms/IconBadge.vue index ead6fdd..1f5a567 100644 --- a/src/components/atoms/IconBadge.vue +++ b/src/components/atoms/IconBadge.vue @@ -4,14 +4,16 @@ import Icon from './Icon.vue'; import type { IconName } from '../../icons'; import { pick } from '../../helpers/pick'; +export interface IconBadgeProps { + icon?: IconName; + variant?: 'accent' | 'danger' | 'success' | 'warning' | 'neutral'; + size?: 'sm' | 'md'; + shape?: 'circle' | 'surface'; +} + // Icon inside a tinted, bordered badge. size: sm = 40px tile, md = 48px disc. const props = withDefaults( - defineProps<{ - icon?: IconName; - variant?: 'accent' | 'danger' | 'success' | 'warning' | 'neutral'; - size?: 'sm' | 'md'; - shape?: 'circle' | 'surface'; - }>(), + defineProps(), { icon: 'check', variant: 'accent', size: 'md', shape: 'circle' }, ); diff --git a/src/components/atoms/IdCell.vue b/src/components/atoms/IdCell.vue index 4496776..b17c021 100644 --- a/src/components/atoms/IdCell.vue +++ b/src/components/atoms/IdCell.vue @@ -1,11 +1,13 @@ diff --git a/src/components/atoms/Link.vue b/src/components/atoms/Link.vue index 932eeca..fe52c9d 100644 --- a/src/components/atoms/Link.vue +++ b/src/components/atoms/Link.vue @@ -7,13 +7,15 @@ import { useRootAttrs } from '../../composables/useRootAttrs'; defineOptions({ inheritAttrs: false }); +export interface LinkProps { + href?: string; + tone?: 'default' | 'muted' | 'accent'; + // Link component (e.g. Inertia's Link) used instead of . + as?: Component | null; +} + const props = withDefaults( - defineProps<{ - href?: string; - tone?: 'default' | 'muted' | 'accent'; - // Link component (e.g. Inertia's Link) used instead of . - as?: Component | null; - }>(), + defineProps(), { href: '#', tone: 'default', as: null }, ); diff --git a/src/components/atoms/ListIcon.vue b/src/components/atoms/ListIcon.vue index 9d690d9..5a7580a 100644 --- a/src/components/atoms/ListIcon.vue +++ b/src/components/atoms/ListIcon.vue @@ -2,7 +2,9 @@ import Icon from './Icon.vue'; import type { IconName } from '../../icons'; -defineProps<{ name: IconName }>(); +export interface ListIconProps { name: IconName } + +defineProps();