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
11 changes: 11 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,23 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
# Full history so the tags are present: the version check below needs
# to know which numbers are already spent.
fetch-depth: 0

- uses: actions/setup-node@v6
with:
node-version: 24
cache: npm

# Before the build, and before a tag exists — the only moment a version
# mistake is still cheap. Once a tag is pushed it is installable as a git
# dependency forever, whether or not its Release run went green, so the
# Release workflow's own tag/manifest guard fires too late to help.
- name: Version is ahead of every existing tag
run: npm run verify:version

- run: npm ci

- name: Lint
Expand Down
140 changes: 140 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,146 @@ All notable changes to `@codebar-ag/storybook`.
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and
this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## v1.18.0

A types-only release, prompted by a consuming app that stood up a `vue-tsc`
lane over 152 components for the first time and found out what this package
does and does not let it say. **No component changes behaviour.** Not one
template, class, token, prop default or emitted value differs; every call site
that renders correctly today renders byte-identically after this. Everything
below happens at the type boundary.

There is no v1.17.0 — see the note at the end.

### Added

- **Every component now exports a named `<Name>Props` type.** 73 of them, one
per component, re-exported from the barrel.

The supporting types were all exported already — `Tone`, `Category`,
`SelectOption`, `DataTableColumn`, `BreadcrumbItem`, `TabItem`, `RowKey`,
`SortState`, `IconName` — which is what made the gap conspicuous rather than
merely absent. The props themselves reached `dist/index.d.ts` as **71
anonymous `__VLS_Props` interfaces**, the names `vue-tsc` generates for a
type literal passed inline to `defineProps`. Nothing can import those. A
consuming app wrapping an atom therefore re-declared the unions by hand, and
they drifted the moment either side moved: a `ConfirmDialog` over `Modal`
declaring `variant: String` compiles in the app and fails against
`'danger' | 'primary' | …` at the boundary.

So a wrapper can now say what it means:

```ts
import type { ButtonProps, ModalProps } from '@codebar-ag/storybook';

defineProps<{
size?: ModalProps['size'];
variant?: ButtonProps['variant'];
}>();
```

…or take the whole surface, which `@vue/compiler-sfc` resolves out of the
published `.d.ts` well enough to emit runtime props from:

```ts
defineProps<ButtonProps>();
```

`dist/index.d.ts` now carries 73 named prop interfaces and zero
`__VLS_Props`. A new `verify:props` build step 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 exported from source but dropped from
the rollup is invisible until an app tries to import it.

- **`SelectOption` takes its value type as a parameter**, and the two controls
that hand an option's value back to the caller — `SearchableSelect` via
`update:modelValue`, `Combobox` via `@select` — are generic over it. A caller
whose values are all strings says so once, on the options, and stops
coercing with `String()` at every call site that writes into a string-typed
form field.

Both infer the parameter from `options` and `modelValue` together, so binding
a plain `string` model widens it rather than pinning it to the literal union
of an inline options array.

`Select` is deliberately **not** generic, and the source says why: it is a
native `<select>`, its change event carries `HTMLSelectElement.value`, and
the DOM has already stringified that. A `SelectOption<number>` there emits
`"1"` and not `1` — typing the emit as the parameter would be a lie the
compiler could not catch.

### Changed

- **`BreadcrumbItem.href` accepts `null`.** `Breadcrumbs` has always rendered a
plain `<span>` for a crumb whose `href` is falsy — an ancestor with no page
of its own, a label-only segment. The type just never said so, and every
caller assembling a trail from optional route data paid for the gap: typing
one wrapper's `breadcrumbs` prop as `BreadcrumbItem[]` in a consuming app
produced roughly 64 errors, all of them this one restated. The interior
non-link crumb is now also a story, since it was reachable but undocumented.

- **Every array a component accepts is `readonly`.** `Accordion`,
`Breadcrumbs`, `Chart`, `Combobox`, `DataTable`, `FileInput`, `KindLegend`,
`PageHeading`, `ResourceList`, `SearchableSelect`, `Select`, `Stepper`,
`Tabs`.

Vue props cannot be mutated at runtime, so `options: SelectOption[]` was
never a promise the component kept — it only filtered out callers whose array
happened to be readonly, which generated translation types and `as const`
fixtures routinely are. Declaring the input readonly says what was already
true and accepts strictly more.

Readonly in, mutable out: the headless composables widen their *inputs*
(`useSort`'s rows, `usePagination`'s `sliceOf`, `useSelection`'s keys and
controlled selection) and keep handing back mutable arrays, copying once at
the boundary. `useSort`'s unsorted branch now copies instead of passing the
caller's array straight through, which it should have been doing anyway.
`verify:props` fails the build on a mutable array prop, because a stance like
this is only worth anything if it holds for all of them.

- **`verify:version` runs on every pull request**, asserting that
`package.json` neither matches an existing tag nor sits behind the highest
one, and the README gains a release checklist. See below for what this is
for.

### Upgrade notes

Nothing here changes what a component *does*, so no template needs touching.
Three of the changes can nonetheless fail an app's type-check, all in narrow
positions:

- **`BreadcrumbItem.href` is `string | null | undefined`.** Code that *reads*
a crumb's href into a `string` now needs a fallback. Code that *builds*
crumbs is strictly freer than before.
- **Array props are `readonly T[]`.** Assigning one back out to a mutable array
type — `const steps: Step[] = props.steps` — needs a copy. Passing arrays
*in* is strictly freer.
- **`SearchableSelect` and `Combobox` are generic components.** `typeof
SearchableSelect` is no longer a plain `DefineComponent`, so
`Meta<typeof SearchableSelect>` and similar type-level gymnastics need the
same untyped treatment `DataTable` has always needed. Templates are
unaffected.

`SelectOption`'s parameter defaults to `string | number`, so every existing
`SelectOption[]` annotation — including ones carrying numeric ids, which this
package supports on purpose — means exactly what it did before.

### A note on v1.17.0

**There is no 1.17.0, and there never will be.** The tag `v1.17.0` exists and
resolves, but the tree it points at is 1.16.1: `release/v1.16.1` was bumped
correctly and then tagged by hand under the wrong name. The Release workflow
caught the mismatch and refused to publish, so **1.16.1 never reached the
registry and no GitHub Release was cut** — and none of that mattered, because
this package is documented as a git dependency and `#v1.17.0` installs
straight from the tag. Consuming apps pinned it and got a build whose
`package.json` says 1.16.1. Nothing was broken; nothing said so either.

A tag is a release even when the release failed, and a pushed tag is
permanent. 1.17.0 is therefore spent, and this release skips it. If you are
pinned to `v1.17.0` you are running 1.16.1 and should move to `v1.18.0`.

## v1.16.1

One bug, found in a consuming app, and the two more the sweep for its shape
Expand Down
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,31 @@ git push --follow-tags # Release workflow builds, tests, publishes
The workflow refuses to run if the tag and `package.json` version disagree, and
verifies the version resolves on the registry once published.

### Checklist

1. **Bump with `npm version`, never `git tag` by hand.** `npm version` derives
the tag name from the manifest, so the two cannot disagree. Writing the tag
yourself is the one step where they can.
2. **Add the `## vX.Y.Z` section to [CHANGELOG.md](CHANGELOG.md) first** — the
Release workflow reads the release body out of it, and falls back to
commit-derived notes if the section is missing.
3. **Push with `--follow-tags`**, then watch the Release run to green. A red
run does *not* mean nothing shipped; read it.
4. **A version number is spent the moment its tag exists.** Never re-use or
re-point one. `npm run verify:version` enforces both halves of this and runs
on every PR: the manifest may not match an existing tag, and may not be
behind the highest one.

> **A tag is a release, even when the release failed.** This package is
> documented as a git dependency, so `#v1.17.0` resolves and installs straight
> from the tag — the registry, the Release run and the GitHub Release are not in
> that path at all. That is what made v1.17.0 a trap: `release/v1.16.1` was
> bumped correctly to 1.16.1 and then tagged `v1.17.0` by hand, the Release run
> failed at the mismatch guard so 1.16.1 never reached the registry, and
> consuming apps pinned v1.17.0 and got a build whose `package.json` says
> 1.16.1. Nothing was broken; nothing said so either. The number 1.17.0 is now
> permanently unusable, which is why the next release is 1.18.0.

## Changelog

Release notes, migration notes and upgrade warnings live in
Expand Down
6 changes: 4 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@codebar-ag/storybook",
"version": "1.16.1",
"version": "1.18.0",
"description": "codebar-ag DocuHub — shared Vue 3 + Tailwind v4 design-system atoms and tokens, documented in Storybook.",
"license": "MIT",
"author": "codebar Solutions AG",
Expand All @@ -25,8 +25,10 @@
"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",
"verify:externals": "node scripts/verify-externals.mjs",
"verify:props": "node scripts/verify-props.mjs",
"verify:version": "node scripts/verify-version.mjs",
"build:tokens": "node -e \"require('node:fs').copyFileSync('src/tokens.css','dist/tokens.css')\"",
"build-storybook": "storybook build",
"lint": "eslint \"src/**/*.{ts,vue}\"",
Expand Down
111 changes: 111 additions & 0 deletions scripts/verify-props.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
// 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
// `<Name>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.
//
// The second half of the file guards the array-prop stance: every array a
// component ACCEPTS is `readonly`. Props are readonly at runtime anyway, so a
// mutable array type is not a promise the component keeps — it is only a filter
// on who may call it. One mutable prop is enough to break the rule, because the
// value of the stance is that it holds for ALL of them: a caller holding a
// ReadonlyArray (generated translation types, `as const` fixtures, anything
// frozen) can then pass it to every component rather than remembering which.
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.',
);
}
}

// Array props must be readonly. Line-based on purpose: a props interface that
// needs a multi-line union type is past the point where a component should be
// taking that prop at all.
const MUTABLE_ARRAY = /\w\s*\[\]|\bArray</;
const READONLY = /\breadonly\b|\bReadonlyArray</;

for (const file of components) {
const name = basename(file, '.vue');
const source = readFileSync(file, 'utf8');
const block = new RegExp(`export interface ${name}Props(?:<[^>]*>)? \\{\\n([\\s\\S]*?)\\n\\}`).exec(source);

if (!block) {
continue;
}

for (const line of block[1].split('\n')) {
const declaration = line.replace(/\/\*.*?\*\//g, '').replace(/\/\/.*$/, '');

if (!declaration.includes(':') || !MUTABLE_ARRAY.test(declaration) || READONLY.test(declaration)) {
continue;
}

errors.push(
`${file}: ${name}Props has a mutable array prop —\n ${declaration.trim()}\n` +
'Accept `readonly T[]`. Props cannot be mutated at runtime, so a mutable type only ' +
'rejects callers holding a ReadonlyArray. Copy at the boundary if something inside ' +
'genuinely needs a mutable array.',
);
}
}

if (errors.length > 0) {
console.error(errors.join('\n\n'));
process.exit(1);
}
Loading
Loading