Skip to content

Releases: codebar-ag/storybook.codebar.ch

v1.21.0

Choose a tag to compare

@StanBarrows StanBarrows released this 11 Aug 18:50
d0e183d

Added

  • SearchableSelect accepts clearable (plus clear-label for the ✕'s
    accessible name, default "Clear selection"). While something is selected, an
    ✕ appears between the label and the chevron; pressing it emits
    update:modelValue with '' 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 emits option.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, ?? null on 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 native Select renders a
    selectable placeholder <option value="">, and Combobox is free text that
    can be erased. This closes the gap for the third sibling; required fields
    simply don't pass the prop.

    Two deliberate choices, so call sites don't pay for what they don't use:

    • Clearing emits '', not null — the same "nothing chosen" payload
      the native Select produces when its placeholder is picked. Emitting
      null would widen the payload type to T | null for every consumer,
      forcing null-handling onto the majority of call sites whose select is not
      clearable and can never receive it. (For a T = string site, T | ''
      collapses to string: 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
      modelValue whose options have not arrived yet shows the placeholder, and
      there is nothing visible to clear).

    The Clearable story pins the loop: clear → model '', placeholder back,
    menu closed, focus on the trigger, ✕ gone; re-pick → ✕ back.

v1.20.0

Choose a tag to compare

@aicodebar aicodebar released this 10 Aug 03:03
615c74d
Merge pull request #28 from codebar-ag/fix/combobox-opens-before-remo…

v1.19.0

Choose a tag to compare

@aicodebar aicodebar released this 08 Aug 06:31
87c4c75

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

  • CodeEditor and CodePreview accept language="yaml", parsed by
    @codemirror/lang-yaml and 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. The Yaml story 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.

    CodePreview gains 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 language now renders unhighlighted and says so, instead of
    silently falling back to JSON. Both components warn once per unknown value in
    development, through the same warnOnce path 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.

    language is 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.

  • DataTable warns in development when rowKey is 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 keyed undefined and 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
    with isProd: true, and @vue/compiler-sfc emits type and required only
    for a development build — the published bundle declares the prop as the bare
    rowKey: {}. Rendering <DataTable> with no rowKey against a development
    build of Vue produces no Vue warning at all. This is the same shape as the
    import.meta.env.DEV problem documented in src/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 against src.

    Every other required prop in this kit is unvalidated at runtime for the same
    reason. Only rowKey is covered here, because only rowKey has evidence
    attached; a general fix means compiling the library in development mode, and
    that is its own release.

Changed

  • CodeEditorProps.modelValue is string | null. The runtime has always
    accepted null — ?? '' guards every read of it, and an empty document is
    what it renders — but the type said string, 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 as BreadcrumbItem.href in 1.18.0: the type was narrower
    than the behaviour.

    Deliberately not widened to string | number | null, which is what
    Input takes. Input is a native <input> whose value the DOM stringifies
    anyway; CodeEditor holds 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 — is string | null for 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-yaml

It 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 language no 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'] includes null. Reading it out into a
    string needs 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

Choose a tag to compare

@aicodebar aicodebar released this 08 Aug 05:40
0260c5f

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:

    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:

    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.17.0

Choose a tag to compare

@aicodebar aicodebar released this 08 Aug 00:23
9cb550c
Merge pull request #25 from codebar-ag/release/v1.16.1

v1.16.1 — three components cached a slot check Vue never invalidates

v1.16.0

Choose a tag to compare

@StanBarrows StanBarrows released this 07 Aug 23:03
3014102

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-indigo and
    --color-cat-magenta measure 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.

    KindMark runs 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: shape and label required, icon and category optional, size of
    sm | md | lg. label cannot be suppressed. Four silhouettes cannot name
    fifteen kinds and the component is not allowed to imply otherwise; an omitted
    icon renders 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 — code for both node
    and schema, document for both data source and prompt, cloud for both
    provider and AI provider, link for 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
eyeeye-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-upchevron-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 squarecircle, 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_modelmcp_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.

#...

Read more

v1.15.0

Choose a tag to compare

@aicodebar aicodebar released this 07 Aug 17:19
2a317b0

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.
    Tone is 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" as info, "viewing an older version"
    as warning. The UI then reports a problem where there is none.

    The deprecated purple alias was the same story in miniature — it meant "seen
    this before", was never a severity, and rode info only because there was
    nowhere else to put it. There is somewhere now. variant="purple" still
    resolves to info and still warns; category="purple" is what it meant.

    Category is deliberately not more members on Tone. Widening Tone
    would break every consumer holding its own exhaustive Record<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".

  • category prop on Badge and StatusBadge, a sibling of variant and
    mutually exclusive with it. Pass one or the other; passing both warns once in
    development (through warnOnce) and renders the category. Use
    variant="neutral" for "no category". resolveCategory() is exported
    alongside resolveTone() and warns once on an unknown value, for the same
    reason pick() exists: consuming apps write plain-JS SFCs where the typed
    union is erased, so a typo otherwise indexes the palette to undefined and
    renders a badge with no colour classes at all.

    Neither variant nor category carries 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. The neutral fallback 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-purple and --color-cat-magenta, each
    with -soft and -line. They are namespaced cat- rather than named
    indigo/purple/magenta so 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 -soft tint; 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.4
    orange / amber / yellow collapse into warning — ΔE 9.6 / 8.5 / 9.1
    lime / green / emerald collapse into success — ΔE 10.0 / 8.4 / 6.6
    teal / 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.5

    The 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-bg on own -soft
    --color-cat-indigo #4f46e5 6.29:1 5.87:1 5.62:1
    --color-cat-purple #581c87 10.88:1 10.16:1 10.14:1
    --color-cat-magenta #a21caf 6.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). soft and line stay 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 in tokenCatalog.ts,
    so Foundations → Colors documents them rather than silently omitting them.

Notes

  • Not changed here, but found while measuring: the existing warning and
    danger bases 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

Choose a tag to compare

@StanBarrows StanBarrows released this 07 Aug 16:32
80a5f73

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

  • Card gained a #title slot. It defaults to the title prop, so a card
    that passes title renders 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 a title,
    description, #title or #actions is present.

  • MAX_WIDTHS is exported from AuthLayout (with the matching
    AuthLayoutMaxWidth type), re-exported from the package root. The map and
    the maxWidth prop already existed; only the export was 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.

  • SidebarItem forwards fallthrough attributes to the link
    (inheritAttrs: false + useRootAttrs(), the same pattern Link uses),
    on both the as-component and plain <a> branches. Previously they landed
    on the wrapping <li>, where they are inert: passing prefetch for a
    client-side router, or a data-*/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's class now merges through cx() 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 beside soft and line.

    The existing --color-success/-warning/-danger are 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 raw text-red-600 for 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-bg and on its own -soft tint. 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 and line tokens 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

Choose a tag to compare

@github-actions github-actions released this 06 Aug 13:14
8fa25f7

Added

  • A map icon (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
    . chart is 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 IconName widens by one.

v1.12.0

Choose a tag to compare

@StanBarrows StanBarrows released this 06 Aug 11:06
8b3f515

Added

  • DescriptionList gained a layout prop (gutter | rows, default
    gutter). gutter is the shape it has always had: the label sits in a fixed
    w-36 column 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. rows gives 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. DescriptionItem picks 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.