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
104 changes: 104 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,110 @@ 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.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.**
`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

Four additive changes, each one deleting a workaround a consuming app had to
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@codebar-ag/storybook",
"version": "1.14.0",
"version": "1.15.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 Down
93 changes: 93 additions & 0 deletions src/components/atoms/Badge.stories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ const meta: Meta<typeof Badge> = {
component: Badge,
argTypes: {
variant: { control: 'select', options: ['neutral', 'info', 'success', 'warning', 'danger'] },
category: { control: 'select', options: [undefined, 'indigo', 'purple', 'magenta'] },
size: { control: 'inline-radio', options: ['sm', 'md'] },
},
args: { variant: 'neutral', size: 'md' },
Expand Down Expand Up @@ -33,6 +34,83 @@ export const All: Story = {
}),
};

// `category` is a SIBLING vocabulary to `variant`, not an extension of it.
// `variant` answers "how bad is this"; `category` answers "which kind is this".
// Three is the measured ceiling on the categorical set — see src/tokens.css for
// the contrast and colour-blindness figures that fix it there.
export const Categorical: Story = {
render: () => ({
components: { Badge },
template: `
<div class="flex flex-wrap gap-2">
<Badge category="indigo">indigo</Badge>
<Badge category="purple">purple</Badge>
<Badge category="magenta">magenta</Badge>
<Badge variant="neutral">neutral (no category)</Badge>
</div>`,
}),
};

// The point of the whole vocabulary, in one picture: the same four states, once
// borrowing the severity ramp and once labelled as identities. On the top row a
// user reads "something needs attention" from every colour — nothing here is a
// problem. On the bottom row the colour carries identity and nothing else.
export const SeverityVersusIdentity: Story = {
render: () => ({
components: { Badge },
template: `
<div class="space-y-4">
<div>
<p class="text-2xs text-dim mb-1.5">Before — non-severity state on the severity ramp</p>
<div class="flex flex-wrap gap-2">
<Badge variant="success">enabled</Badge>
<Badge variant="info">overridden</Badge>
<Badge variant="info">duplicate</Badge>
<Badge variant="warning">older version</Badge>
</div>
</div>
<div>
<p class="text-2xs text-dim mb-1.5">After — the categorical vocabulary</p>
<div class="flex flex-wrap gap-2">
<Badge category="indigo">enabled</Badge>
<Badge category="purple">overridden</Badge>
<Badge category="magenta">duplicate</Badge>
<Badge category="indigo">older version</Badge>
</div>
</div>
<div>
<p class="text-2xs text-dim mb-1.5">Still severity — these are genuinely statuses</p>
<div class="flex flex-wrap gap-2">
<Badge variant="success">connected</Badge>
<Badge variant="warning">degraded</Badge>
<Badge variant="danger">failed</Badge>
</div>
</div>
</div>`,
}),
};

// Every category beside every severity, which is the pairing the tokens are
// actually measured against: no categorical badge may read as a status badge.
// Worst measured separation across this grid is ΔE 21.3 (normal vision) /
// 10.8 (protanopia/deuteranopia), between magenta and neutral.
export const AgainstSeverity: Story = {
render: () => ({
components: { Badge },
template: `
<div class="flex flex-wrap gap-2">
<Badge variant="neutral">neutral</Badge>
<Badge variant="info">info</Badge>
<Badge variant="success">success</Badge>
<Badge variant="warning">warning</Badge>
<Badge variant="danger">danger</Badge>
<Badge category="indigo">indigo</Badge>
<Badge category="purple">purple</Badge>
<Badge category="magenta">magenta</Badge>
</div>`,
}),
};

// The pre-1.3 color names keep rendering (mapped in helpers/tone.ts) until the
// next major release; this story is the living proof + migration reference.
export const LegacyAliases: Story = {
Expand All @@ -48,3 +126,18 @@ export const LegacyAliases: Story = {
</div>`,
}),
};

// `purple` is the one legacy alias that was never a severity: it meant "seen
// this before" (a duplicate delivery) and rode `info` only because there was no
// non-severity vocabulary to put it in. There is now, and the two spellings do
// visibly different things — which is the migration.
export const LegacyPurpleVersusCategory: Story = {
render: () => ({
components: { Badge },
template: `
<div class="flex flex-wrap gap-2">
<Badge variant="purple">variant="purple" → info (deprecated)</Badge>
<Badge category="purple">category="purple" → a real purple</Badge>
</div>`,
}),
};
70 changes: 60 additions & 10 deletions src/components/atoms/Badge.vue
Original file line number Diff line number Diff line change
@@ -1,21 +1,46 @@
<script setup lang="ts">
import { computed } from 'vue';
import { resolveTone } from '../../helpers/tone';
import type { LegacyTone, Tone } from '../../helpers/tone';
import { resolveCategory, resolveTone } from '../../helpers/tone';
import type { Category, LegacyTone, Tone } from '../../helpers/tone';
import { pick } from '../../helpers/pick';
import { warnOnce } from '../../helpers/dev';

/* eslint-disable vue/require-default-prop --
`variant` and `category` intentionally have no prop default. A default makes
an unset prop indistinguishable from an explicitly passed one, and the
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. */
const props = withDefaults(
defineProps<{
/**
* Semantic tone. The color names (gray/blue/green/amber/red) are
* deprecated aliases and will be removed in the next major release.
* 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';
}>(),
{ variant: 'neutral', size: 'md' },
{ size: 'md' },
);
/* eslint-enable vue/require-default-prop */

// Two parallel maps rather than one widened one. Merging them would make the
// type `Record<Tone | Category, string>`, and every consuming app holding its
// own exhaustive `Record<Tone, X>` status map would stop compiling — a
// semver-major break to ship what is an additive feature.
const palette: Record<Tone, string> = {
neutral: 'bg-surface-2 text-muted border-line',
info: 'bg-accent/10 text-accent border-accent/20',
Expand All @@ -24,16 +49,41 @@ const palette: Record<Tone, string> = {
danger: 'bg-danger-soft text-danger border-danger-line',
};

const categoryPalette: Record<Category, string> = {
indigo: 'bg-cat-indigo-soft text-cat-indigo border-cat-indigo-line',
purple: 'bg-cat-purple-soft text-cat-purple border-cat-purple-line',
magenta: 'bg-cat-magenta-soft text-cat-magenta border-cat-magenta-line',
};

const sizes: Record<string, string> = {
sm: 'text-2xs px-1.5 py-0.5',
md: 'text-2xs px-2 py-0.5',
};

const classes = computed(
() =>
'inline-flex items-center gap-1 font-medium rounded-control border ' +
`${palette[resolveTone(props.variant, 'neutral')]} ${pick(sizes, props.size, 'md', 'Badge.size')}`,
);
// `variant` deliberately has no prop default: with one, an unset `variant`
// and an explicit `variant="neutral"` are indistinguishable here, so passing
// both props could not be detected. `resolveTone(undefined, 'neutral')`
// applies the same default one level down, so behaviour is unchanged.
const classes = computed(() => {
const base = 'inline-flex items-center gap-1 font-medium rounded-control border ';
const size = pick(sizes, props.size, 'md', 'Badge.size');

if (props.category !== undefined) {
if (props.variant !== undefined) {
warnOnce(
'Badge:variant+category',
'[flows] Badge received both `variant` and `category`. They are parallel, '
+ 'mutually exclusive vocabularies — `variant` reports SEVERITY '
+ '(neutral | info | success | warning | danger), `category` labels a '
+ 'non-severity IDENTITY. Rendering `category` and ignoring `variant`.',
);
}

return `${base}${categoryPalette[resolveCategory(props.category, 'indigo')]} ${size}`;
}

return `${base}${palette[resolveTone(props.variant, 'neutral')]} ${size}`;
});
</script>

<template>
Expand Down
Loading
Loading