Releases: codebar-ag/storybook.codebar.ch
Release list
v1.21.0
Added
-
SearchableSelectacceptsclearable(plusclear-labelfor the ✕'s
accessible name, default "Clear selection"). While something is selected, an
✕ appears between the label and the chevron; pressing it emits
update:modelValuewith''and hands focus back to the trigger.Without it the control is a trap for OPTIONAL values: it picks from a closed
set and only ever emitsoption.value, so once anything is picked there is
no gesture that returns to "nothing chosen". The consuming app hit this on
its data-source form — the store-dialog field is nullable all the way down
('nullable'in the FormRequest,?? nullon submit), but a user who
selected a dialog could never unselect it short of reloading the page. Its
sibling controls both already had an exit: the nativeSelectrenders a
selectable placeholder<option value="">, andComboboxis free text that
can be erased. This closes the gap for the third sibling;requiredfields
simply don't pass the prop.Two deliberate choices, so call sites don't pay for what they don't use:
- Clearing emits
'', notnull— the same "nothing chosen" payload
the nativeSelectproduces when its placeholder is picked. Emitting
nullwould widen the payload type toT | nullfor every consumer,
forcing null-handling onto the majority of call sites whose select is not
clearable and can never receive it. (For aT = stringsite,T | ''
collapses tostring: existing handlers type-check unchanged.) - The ✕ is a sibling of the trigger, not a child — the trigger is
itself a<button>, and a button inside a button is invalid HTML that
browsers "repair" by splitting the elements apart. It is absolutely
positioned into the trigger's right end at the control's full height, and
only rendered while a selected option's label is actually on screen (a
modelValuewhose options have not arrived yet shows the placeholder, and
there is nothing visible to clear).
The
Clearablestory pins the loop: clear → model'', placeholder back,
menu closed, focus on the trigger, ✕ gone; re-pick → ✕ back. - Clearing emits
v1.20.0
v1.19.0
Four findings from the app that adopted 1.18.0, three of them acted on and one
declined. One is a rendering bug that had been shipping for the life of the
component: CodeEditor had no yaml mode and highlighted YAML as JSON without
saying so.
This release adds a peer dependency. @codemirror/lang-yaml — see
Upgrade notes. Nothing else here requires a change to a
call site.
Added
-
CodeEditorandCodePreviewacceptlanguage="yaml", parsed by
@codemirror/lang-yamland highlighted through the same shared theme as
every other code surface in the kit.The editor previously loaded the JSON grammar for anything that was not
markdown, so a consuming app's compiled-flow-definition page had been
highlighting YAML through a JSON parser since the day it was written. The
reason nobody noticed is worth recording, because it is what makes this class
of bug expensive: the wrong grammar does not fail loudly on YAML. Run the
JSON parser over a typical flow definition and it still paints nine tokens —
every:as punctuation, every bare integer as a number — which reads as
syntax highlighting from across the room. What it cannot paint is#, a
comment in YAML and nothing at all in JSON. TheYamlstory asserts on that
line specifically, and fails if the document is served the JSON grammar; a
"some token is coloured" assertion passes on the broken version.CodePreviewgains the mode too. A kit that can edit YAML but only preview
it as plain text is a difference no caller can explain. -
An unknown
languagenow renders unhighlighted and says so, instead of
silently falling back to JSON. Both components warn once per unknown value in
development, through the samewarnOncepath as every other dev diagnostic
here. A component that quietly picks the wrong grammar is worse than one that
refuses the mode: the first is indistinguishable from working.languageis a typed union, so the only callers who can reach that arm are
untyped ones — which is exactly who has no compiler to tell them. -
DataTablewarns in development whenrowKeyis missing. It has always
been a required prop with no default, and until now nothing said so at
runtime: nine call sites in one consuming app shipped without it, so every
row in those tables was keyedundefinedand Vue could not tell any row from
any other.Vue's own "Missing required prop" warning cannot fire for any prop in
this package, which is why the years passed quietly. The library is compiled
withisProd: true, and@vue/compiler-sfcemitstypeandrequiredonly
for a development build — the published bundle declares the prop as the bare
rowKey: {}. Rendering<DataTable>with norowKeyagainst a development
build of Vue produces no Vue warning at all. This is the same shape as the
import.meta.env.DEVproblem documented insrc/helpers/dev.ts: a
diagnostic that reads as working and is compiled away before it reaches
anyone. The check is therefore written out by hand, like the tone
deprecation, and verified against the built bundle rather than againstsrc.Every other required prop in this kit is unvalidated at runtime for the same
reason. OnlyrowKeyis covered here, because onlyrowKeyhas evidence
attached; a general fix means compiling the library in development mode, and
that is its own release.
Changed
-
CodeEditorProps.modelValueisstring | null. The runtime has always
accepted null —?? ''guards every read of it, and an empty document is
what it renders — but the type saidstring, so callers holding a nullable
column (a definition that has not been compiled yet, an optional description)
coerced with?? ''at the call site for a default the component already
applies. Same shape asBreadcrumbItem.hrefin 1.18.0: the type was narrower
than the behaviour.Deliberately not widened to
string | number | null, which is what
Inputtakes.Inputis a native<input>whosevaluethe DOM stringifies
anyway;CodeEditorholds a document, and a number would have to be
silently stringified into one on the way in and handed back as a string on
the way out.Textarea— the multi-line text sibling, and the closer
analogue — isstring | nullfor the same reason.
Not changed: DataTableColumn.key
The fourth finding asked for key: keyof T & string, so the #cell-<key> slot
could type value as T[K] instead of unknown. Declined, on three grounds.
It would forbid a column that is not a row property. An action or computed
column — { key: 'actions', label: '' } rendered through #cell-actions — is
a real and supported pattern, and the kit ships no other way to put one
anywhere but the trailing #row-actions cell.
SortState.key is a free string on purpose. It is emitted to the caller
and, in server mode, straight on to an API. Sort keys naming a joined or
computed column that is not in the row DTO are ordinary. Narrowing the column
key without narrowing that leaves the two disagreeing.
And for most row types it would buy nothing at all. DataTable is
<T extends Record<string, unknown>>. An interface only satisfies that
constraint by declaring the index signature — which is what this package's own
DataTable story does, and what a consuming app's row types have to do — and
once [key: string]: unknown is present, keyof T & string is string and
T[K] is unknown. The proposed narrowing is the identity function on
exactly the row shapes it was proposed for. It would only bite for rows
declared as type aliases, which get an implicit index signature and keep their
exact keys.
That last point locates the real obstacle: it is the Record<string, unknown>
constraint, not key. Relaxing it (to T extends object, with the internal
indexing and the useSort signature adjusted to match) is what would make an
exact value possible, and it is strictly more permissive, so it would break
nobody. It is also a much larger change than a type narrowing, and it is not in
this release.
Eleven consumer bindings moved from { value } to { row } in the meantime,
which is better code regardless: row.name is exact today, under both row
shapes, with no change to this package.
Upgrade notes
Install @codemirror/lang-yaml.
npm install --save-dev @codemirror/lang-yamlIt is declared optional in peerDependenciesMeta, in step with every other
@codemirror/* grammar here, but "optional" describes the manifest and not
what a bundler does. dist/flows.js is a single file that dynamic-imports
every grammar by bare specifier, so a consuming build resolves all of them
whether or not the app ever renders an editor — which is already true of
lang-json, lang-markdown, commands, language, state and view today,
and is why every existing consumer already has that set installed. This release
adds one more package to it. An app that upgrades without installing it will
fail to resolve the import at build time, not at runtime.
Two smaller things can change behaviour, both only for code that was already
outside the declared types:
- An unknown
languageno longer highlights as JSON. If an untyped call
site was passing something the union does not contain and was, by accident,
getting the JSON grammar, it now gets no grammar and a development warning.
A call site that was passing"yaml"and getting JSON gets YAML. CodeEditorProps['modelValue']includesnull. Reading it out into a
stringneeds a fallback. Passing values in is strictly freer.
DataTable's new warning is development-only and fires once per page, but an
app with tables missing rowKey will start seeing it immediately. It is
reporting a real defect in those tables — the rows have no distinct keys — and
not a new requirement.
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>Propstype. 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 reacheddist/index.d.tsas 71
anonymous__VLS_Propsinterfaces, the namesvue-tscgenerates for a
type literal passed inline todefineProps. 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: aConfirmDialogoverModal
declaringvariant: Stringcompiles in the app and fails against
'danger' | 'primary' | …at the boundary.So a wrapper can now say what it means:
import type { ButtonProps, ModalProps } from '@codebar-ag/storybook'; defineProps<{ size?: ModalProps['size']; variant?: ButtonProps['variant']; }>();
…or take the whole surface, which
@vue/compiler-sfcresolves out of the
published.d.tswell enough to emit runtime props from:defineProps<ButtonProps>();
dist/index.d.tsnow carries 73 named prop interfaces and zero
__VLS_Props. A newverify:propsbuild 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. -
SelectOptiontakes its value type as a parameter, and the two controls
that hand an option's value back to the caller —SearchableSelectvia
update:modelValue,Comboboxvia@select— are generic over it. A caller
whose values are all strings says so once, on the options, and stops
coercing withString()at every call site that writes into a string-typed
form field.Both infer the parameter from
optionsandmodelValuetogether, so binding
a plainstringmodel widens it rather than pinning it to the literal union
of an inline options array.Selectis deliberately not generic, and the source says why: it is a
native<select>, its change event carriesHTMLSelectElement.value, and
the DOM has already stringified that. ASelectOption<number>there emits
"1"and not1— typing the emit as the parameter would be a lie the
compiler could not catch.
Changed
-
BreadcrumbItem.hrefacceptsnull.Breadcrumbshas always rendered a
plain<span>for a crumb whosehrefis 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'sbreadcrumbsprop asBreadcrumbItem[]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 andas 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'ssliceOf,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:propsfails the build on a mutable array prop, because a stance like
this is only worth anything if it holds for all of them. -
verify:versionruns on every pull request, asserting that
package.jsonneither 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.hrefisstring | null | undefined. Code that reads
a crumb's href into astringnow 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. SearchableSelectandComboboxare generic components.typeof SearchableSelectis no longer a plainDefineComponent, so
Meta<typeof SearchableSelect>and similar type-level gymnastics need the
same untyped treatmentDataTablehas 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.17.0
v1.16.0
The non-colour channel v1.15.0 said would be needed. Additive and non-breaking:
nothing existing changes shape, colour or markup — Tone is still five values,
Category is still three, and no token moved.
Added
-
KindMark— a kind label whose primary channel is geometry, not colour.v1.15.0 shipped three categorical colours and documented three as a measured
ceiling: inside the one arc of the hue wheel that clears AA-as-text on three
grounds, holds enough chroma to read as a hue, and stays clear of every
severity token, three is the largest mutually separable set. That conclusion
has held up. What it left open was what to do about an axis with more than
three members, and there was a real one waiting: a consuming app's flow-graph
diagram with 15 node kinds, drawn on 12 raw Tailwind ramp hues because
there was nothing else to draw them with. Several of those hues sat on the
severity ramp — a node whose kind was "data source" rendered in the amber
the UI uses for "something is wrong". That was reported as a bug and it was
one.It is not fixed by a fourth colour, and the proof is inside this kit's own
tokens rather than in an argument. Desaturated,--color-cat-indigoand
--color-cat-magentameasure L* 40.7 and 40.6. They are the same grey.
Print the graph in black and white and the entire categorical vocabulary
collapses to one grey and one dark grey — so any design where colour is the
distinction has already failed a test nobody was running.KindMarkruns that test on purpose. It splits the distinction across three
channels of decreasing coarseness and increasing certainty:channel values needs survives greyscale silhouette 4 ≥12px yes — it is geometry glyph the icon registry ≥12px of glyph, so ≥32px of mark yes label unbounded reading yes Colour rides on top of the silhouette — one colour per shape, so it is purely
redundant. Delete it and nothing is lost. That is the difference between a
reinforcement channel and a signalling one, and it is the whole design.Props:
shapeandlabelrequired,iconandcategoryoptional,sizeof
sm | md | lg.labelcannot be suppressed. Four silhouettes cannot name
fifteen kinds and the component is not allowed to imply otherwise; an omitted
iconrenders the silhouette alone, which is a real state the legend uses. -
KindLegend— the four-row key, and only ever four rows.A silhouette means "this box is the same sort of thing as that one", which is
a claim about the set, not about any one mark — so no individual card can
convey it and it needs a legend. The glyph does not: every mark carries its
kind's name, so a reader can always just read it. The legend therefore lists
families, not kinds: four rows for fifteen kinds, and still four for
fifty. A legend that grew with the axis would be the concession that the
encoding had stopped working. -
Three icons —
database,chat,chip. Not decoration: the app above
had 15 kinds on 11 glyphs, so four pairs shared one —codefor both node
and schema,documentfor both data source and prompt,cloudfor both
provider and AI provider,linkfor both gateway and gateway endpoint. Each
of those pairs measures DSSIM 0, the only score that means "the same
picture". A design that promotes the glyph to a primary channel has to supply
enough glyphs for it to be one.
How the shape channel was measured
Colour has ΔE. A shape channel needs its own measured equivalent or "it is
distinguishable" is just an assertion, so: DSSIM = (1 − SSIM) × 100
(structural similarity, Wang et al. 2004), greyscale, computed on real Chromium
rasters of the real marks at real pixel sizes — antialiasing, stroke joins and
all. SSIM's 11×11 Gaussian window is itself a coarse low-pass, which is a fair
model of what "at a glance" means.
The floor is DSSIM ≥ 30, and it is calibrated against controls rather than
picked, because a number in a new metric means nothing on its own:
| control | DSSIM | what it establishes |
|---|---|---|
circle ↔ regular octagon |
13.4 | at mark size an octagon is a circle |
square ↔ same square, bigger radius |
25.4 | a corner radius is not a shape |
eye ↔ eye-slash (this kit's registry) |
26.3 | a pair users demonstrably confuse |
| any glyph ↔ itself | 0 | identity |
Everything at or under ~26 is a pair we can independently confirm is confusable,
so 30 is the first honest floor above them.
One limitation, stated rather than hidden: DSSIM is structural, so it over-scores
pairs that differ only by rotation — chevron-up ↔ chevron-down scores 81.2
though people confuse them constantly. It is therefore a rejection gate, not a
certificate: below the floor is proof of confusability, above it is not proof of
distinctness, and glyph choices still need a human to look at them.
The four silhouettes — worst pair square ↔ circle, 59.0 at 32px with
a 1.5px stroke: better than twice the floor and 4.4× the octagon control. What
was rejected and why:
| rejected | DSSIM | why |
|---|---|---|
| octagon | 13.4 vs circle |
at mark sizes it is a circle |
| squircle | 25.4 vs square |
a corner radius is not a shape |
| pentagon | 53.2 vs hexagon |
clears the floor, but falls with size |
| triangle | 59.0 | separable, and still unusable — its largest centred inscribed square is 30% of the mark box, so it cannot hold a glyph. A silhouette that cannot host the second channel is not a member of this set. |
Interior fit is the constraint nobody expects: square 77%, circle 63%, hexagon
54%, diamond 45%, triangle 30%. The diamond is the binding one and it is why the
glyph is drawn at 12 of the mark's 32 units rather than larger.
Size. The silhouette is the coarse channel and long outlives the glyph.
Worst cross-shape pair, outline only:
| mark | 40px | 32px | 24px | 16px | 12px | 8px |
|---|---|---|---|---|---|---|
| DSSIM | 55.2 | 59.0 | 68.4 | 55.5 | 37.8 | 25.2 |
So the family reads down to 12px and is gone by 8px, where it lands exactly on
the squircle control. The glyph needs far more room, which is the point of
having two channels: at low zoom a graph keeps telling you what sort of node
each box is long after it has stopped telling you which one.
Glyph, for the 15-kind set, at the glyph size each mark size produces —
worst pair within one family, which is the binding case since the silhouette
separates the rest:
| size | mark | glyph | worst within-family pair | all 15 glyphs |
|---|---|---|---|---|
lg |
40px | 15px | 55.2 | 49.9 |
md |
32px | 12px | 49.6 | 47.9 |
sm |
24px | 9px | 34.8 | 34.8 |
sm sits just above the floor; it is documented for dense rows where the label
is carrying the load anyway.
Stroke weight is a shape channel's version of "is this token dark enough",
and it binds on contrast rather than on separation — separation barely moves
between 0.75px and 2.5px. Measured on --color-bg, taking the darkest pixel the
renderer actually paints:
| stroke | cat-indigo |
accent |
|---|---|---|
| 0.5px | 2.17:1 — under WCAG 1.4.11's 3:1 non-text floor | 3.18:1 |
| 0.75px | 3.46:1 | 6.94:1 |
| 1px | 5.58:1 | 15.53:1 |
| ≥1.25px | 5.87:1 — its nominal contrast | 16.55:1 |
A sub-pixel stroke is rendered as partial coverage and composites toward the
background, so it never reaches the colour it was specified in. 1.25px is the
minimum; the silhouette ships at 1.5px and the glyph at 1.4px, both held
constant in device pixels across sizes rather than scaled, because the
constraint is absolute rather than relative.
Colour, held to v1.15.0's gates. All four family colours as text:
| family colour | on white | on bg |
on surface-2 |
on own soft |
|---|---|---|---|---|
cat-indigo #4f46e5 |
6.29:1 | 5.87:1 | 5.67:1 | 5.62:1 |
cat-purple #581c87 |
10.88:1 | 10.16:1 | 9.81:1 | 10.14:1 |
cat-magenta #a21caf |
6.32:1 | 5.91:1 | 5.71:1 | 5.89:1 |
accent #18181b |
17.72:1 | 16.55:1 | 15.99:1 | 15.99:1 |
Distance to the severity ramp, OKLab ΔE×100, normal / worst under simulated
protanopia and deuteranopia (Machado-Oliveira-Fernandes 2009, severity 1.0):
indigo↔success 31.2 / 27.3, purple↔success 27.6 / 19.3, magenta↔success
34.1 / 17.9, accent↔danger 28.4 / 16.1. Every pair clears ≥15 normal and ≥8
CVD, so no kind mark can be read as a status. The accent family is not a
fourth category — it is the absence of one, which is how four families fit
inside a three-category vocabulary, and why the two ceilings coincide instead of
fighting.
Where this stops scaling, stated rather than discovered later
Four families. There is no fifth silhouette: the table above is the whole
search, not a sample of it. A fifth family cannot be encoded, only spelled out
in the label.
Inside a family the glyph is the channel, and it is roomier than any real axis:
a greedy max-min sweep of the whole 36-glyph registry at 16px keeps every pair
above 70.0 out to a set of 17 glyphs, and is still at 59.8 at 20. But that
is not the honest limit either. The honest limit is what a reader has to learn
— four silhouettes, taught by KindLegend — because the glyph never has to be
learned at all: the kind's name is printed on the mark.
And the number that keeps the design honest rather than flattering it: taking
the complete marks for all 15 kinds in greyscale, the worst of the 105 pairs
scores 6.7 (ai_model ↔ mcp_server) against a median of 82.6. Two kinds in
the same family, differing only by their glyph, genuinely do look alike at a
glance — that is what a family is. The mark alone does not separate fifteen
kinds and never claimed to. The label is why it does not have to, and that is
why label is required and cannot be turned off.
#...
v1.15.0
A categorical (non-severity) colour vocabulary, added as a parallel set to
Tone rather than as more members on it. Additive and non-breaking: no existing
prop, token or rendered output changes, and Tone is still exactly five values.
Added
-
Category— a second colour vocabulary, for identity instead of severity.
Toneis five points on a severity ramp: how bad is this. But a lot of state
has no severity at all — a feature is on or off, a setting is inherited or
overridden, a delivery is the first or a duplicate, a record is draft or
published, a graph node is one kind of thing rather than another. With only a
severity ramp available, all of those borrowed one: "enabled" rendered as
success, "duplicate" and "overridden" asinfo, "viewing an older version"
aswarning. The UI then reports a problem where there is none.The deprecated
purplealias was the same story in miniature — it meant "seen
this before", was never a severity, and rodeinfoonly because there was
nowhere else to put it. There is somewhere now.variant="purple"still
resolves toinfoand still warns;category="purple"is what it meant.Categoryis deliberately not more members onTone. WideningTone
would break every consumer holding its own exhaustiveRecord<Tone, X>map —
a semver-major change to ship an additive feature — and it would also be
wrong: severity is ordered and categories are not. A category means only
"different from the other one". -
categoryprop onBadgeandStatusBadge, a sibling ofvariantand
mutually exclusive with it. Pass one or the other; passing both warns once in
development (throughwarnOnce) and renders the category. Use
variant="neutral"for "no category".resolveCategory()is exported
alongsideresolveTone()and warns once on an unknown value, for the same
reasonpick()exists: consuming apps write plain-JS SFCs where the typed
union is erased, so a typo otherwise indexes the palette toundefinedand
renders a badge with no colour classes at all.Neither
variantnorcategorycarries a prop default any more. That is
load-bearing rather than cosmetic: with a default, an unset prop is
indistinguishable from an explicitly passed one, and the exclusivity check
cannot be made at all. Theneutralfallback moved one level down into
resolveTone(), which already applied it — so rendered output is unchanged
for every existing call site. -
Nine categorical tokens, in the same base/soft/line shape as the status
block so the two are interchangeable at the call site:
--color-cat-indigo,--color-cat-purpleand--color-cat-magenta, each
with-softand-line. They are namespacedcat-rather than named
indigo/purple/magentaso they cannot be confused with, or shadow,
Tailwind's own colour scales in a consuming app.Three is a measured ceiling, not a starting point. A categorical hue has
to clear four gates at once: AA as text (≥4.5:1) on white, on--color-bg
and on its own-softtint; OKLCH chroma ≥0.10, below which a hue reads as
gray and stops doing identity work; and OKLab ΔE separation from both the
other categories and every severity token — ≥15 to normal vision and ≥8 under
protanopia/deuteranopia (Machado-Oliveira-Fernandes 2009, severity 1.0).Sweeping the whole Tailwind ramp against those gates rejects most of the
wheel, for reasons that are not guessable and are worth recording:rejected why red / rose / pink collapse into danger— ΔE 6.8 / 8.5 / 11.4orange / amber / yellow collapse into warning— ΔE 9.6 / 8.5 / 9.1lime / green / emerald collapse into success— ΔE 10.0 / 8.4 / 6.6teal / cyan not a colour at text weight — dark enough for AA, they measure chroma 0.059 / 0.066, under the 0.10 floor, i.e. they render gray sky collapses into --color-muted— ΔE 12.5The teal/cyan result is the same trap as the bright amber that could not reach
3:1 on white at any lightness (v1.14.0): some hues simply do not exist at the
weight the contrast rule demands. What survives is a single ~60° blue→magenta
arc, with room for exactly three mutually separable steps. A fourth drops the
worst pair to ΔE≈3.0 under simulated red-green colour blindness — that is
indistinguishable, not "close". More kinds than three need a channel that is
not colour: an icon, or the label itself.Measured for the shipped set:
token on white on --color-bgon own -soft--color-cat-indigo#4f46e56.29:1 5.87:1 5.62:1 --color-cat-purple#581c8710.88:1 10.16:1 10.14:1 --color-cat-magenta#a21caf6.32:1 5.91:1 5.89:1 Separation — indigo↔purple ΔE 17.3 normal / 16.6 CVD; indigo↔magenta 18.2 /
9.5; purple↔magenta 16.3 / 9.6; nearest severity pair 21.3 / 10.8
(magenta↔muted).softandlinestay decorative and are never the sole
carrier of meaning — the badge always carries its text label. -
Stories for both components, including a before/after that puts the same
four non-severity states on the severity ramp and then on the categorical one,
and a grid of all three categories beside all five tones (the pairing the
tokens are measured against). The tokens are registered intokenCatalog.ts,
so Foundations → Colors documents them rather than silently omitting them.
Notes
- Not changed here, but found while measuring: the existing
warningand
dangerbases are themselves close — ΔE 6.7 to normal vision and 2.9 under
simulated red-green colour blindness. Both always carry a text label, so
meaning is never colour-alone, but separating them would change existing
rendered output and is a decision for a major release.
v1.14.0
Four additive changes, each one deleting a workaround a consuming app had to
grow because the kit did not offer the seam. Nothing here changes existing
rendered output: every new surface is opt-in and every default is what it was.
Added
-
Cardgained a#titleslot. It defaults to thetitleprop, so a card
that passestitlerenders exactly the markup it did before. The title line
is now a wrapping flex row (flex flex-wrap items-center gap-x-3 gap-y-1),
which is the point: a status badge belongs beside the thing it describes.
Until now the only place to put one was#actions— the far side of the
header, next to the buttons, where a badge reads as a control you can press.
Eight call sites had independently arrived at that workaround, each with
slightly different spacing. The header renders whenever atitle,
description,#titleor#actionsis present. -
MAX_WIDTHSis exported fromAuthLayout(with the matching
AuthLayoutMaxWidthtype), re-exported from the package root. The map and
themaxWidthprop already existed; only theexportwas missing, so an app
wrapping this layout had to re-declare the same four class strings to type
its own prop — a private copy that goes stale the first time a step is added
here. It lives in a plain<script>block because<script setup>can
export types but not values. -
SidebarItemforwards fallthrough attributes to the link
(inheritAttrs: false+useRootAttrs(), the same patternLinkuses),
on both theas-component and plain<a>branches. Previously they landed
on the wrapping<li>, where they are inert: passingprefetchfor a
client-side router, or adata-*/listener meant for the anchor, produced no
error and no warning — just a dead attribute in the DOM and a feature that
silently never engaged. A caller'sclassnow merges throughcx()instead
of colliding with the component's own classes. -
Dot-scale status tokens:
--color-success-dot,--color-warning-dot,
--color-danger-dot, a fourth tier besidesoftandline.The existing
--color-success/-warning/-dangerare tuned for text
(WCAG 1.4.3, ≥4.5:1; they land near 7:1). At 8px there is no text — a status
dot is a non-text graphical object, so the governing rule is WCAG 1.4.11
Non-text Contrast at ≥3:1, not 4.5:1. Held to the text threshold, an 8px
dot has to be dark enough that it stops carrying its hue: success reads as
near-black, warning as dark brown, danger as wine. Colour is the entire
signal a dot has, and it was being spent on contrast nobody reads. This is
why consumers had started escaping to rawtext-red-600for small
indicators.These are decorative-only and deliberately lighter than the text ramp — not
a contrast bug, do not darken them. Each clears 3:1 on white, on
--color-bgand on its own-softtint. They are never the sole carrier of
meaning (a dot always accompanies a label), and they are not for text, icons
read as text, or borders — use the base andlinetokens there. Documented
as their own group in Foundations/Colors, rendered at 8px as well as at
swatch size, because a 40px swatch flatters a colour picked for 8px.StatusBadge's own dot is unchanged — switching it would restyle every
existing badge, which is a visual change and not this release's business.
v1.13.0
Added
-
A
mapicon (Heroicons 24 outline, a folded three-panel map), bringing
the registry to 29 names.The gap it fills: nothing in the set stood for a diagram of a whole
structure.chartis bars — quantities, not topology — and the nearest
alternatives (document,code,link) each name a piece rather than the
picture. A consumer opening an overview of how its parts connect had no
honest glyph to hang it on, and a bar chart on a button that opens a node
graph misdescribes what is behind it.Purely additive: no existing name changes, and
IconNamewidens by one.
v1.12.0
Added
-
DescriptionListgained alayoutprop (gutter|rows, default
gutter).gutteris the shape it has always had: the label sits in a fixed
w-36column so every value starts on the same x, which is what a
sidebar-width list wants. That gutter is also a wrap machine — any label
longer than 9rem ("Workspace-wide rate limit multiplier") breaks over two or
three lines beside a value of "4", which is how a full-width card of short
numbers ends up ragged.rowsgives the label as much width as it needs
(whitespace-nowrap, so it never wraps), pushes the value to the far edge,
and rules a line under each pair: one entry per line, however long the label
runs.The prop is on the list, not the item — a list whose rows disagree about
where the value sits is not a list.DescriptionItempicks the shape up
through provide/inject, and an item rendered outside a list falls back to
gutter.Consumers need no change. The prop defaults to today's behaviour, and a
list that passes nothing renders exactly the markup it did before.