From 15b867a66962f25b58ea557c0a9a0ef25d738556 Mon Sep 17 00:00:00 2001 From: "ai@codebar" Date: Sat, 8 Aug 2026 00:34:01 +0200 Subject: [PATCH] =?UTF-8?q?v1.16.0=20=E2=80=94=20a=20non-colour=20channel?= =?UTF-8?q?=20for=20categorical=20axes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v1.15.0 added three categorical colours and documented a hard ceiling: a fourth drops the worst pair to ΔE ≈ 3.0 under simulated red-green colour blindness. This is the channel that scales past it. The number that settles the fourth-colour question is already inside the tokens. Desaturated, cat-indigo and cat-magenta measure L* 40.7 and 40.6 — the same grey. No colour scheme survives a black-and-white print, so the ceiling was never going to move by adding hues. KindMark splits one distinction across three channels of decreasing coarseness: silhouette (4 values, pre-attentive, geometry), then glyph (separates kinds inside a silhouette), then label (always rendered, cannot be suppressed). Colour is one hue per silhouette and fully redundant — delete it and nothing is lost. That is the test: it must work in greyscale. Three categories plus "no category" is four, and four is exactly what the shape channel holds. The two ceilings coincide rather than fighting. MEASURED, on real Chromium rasters in greyscale (DSSIM = (1-SSIM)x100), with the floor of 30 calibrated on controls rather than asserted — circle vs octagon 13.4, square vs squircle 25.4, eye vs eye-slash 26.3, identical glyph 0: - Silhouettes: worst pair 59.0 at 32px. Rejected octagon (13.4), squircle (25.4), pentagon (53.2 and falling), and triangle — separable at 59.0, but its inscribed square is 30% of the box so it cannot hold a glyph. - Size: 55.2 / 59.0 / 68.4 / 55.5 / 37.8 at 40/32/24/16/12px; gone at 8px. - Glyph, worst within-family pair: 55.2 (lg) / 49.6 (md) / 34.8 (sm). - Minimum stroke weight binds on CONTRAST, not separation: a 0.5px cat-indigo outline antialiases to 2.17:1 on --color-bg, under WCAG 1.4.11's 3:1, and only reaches its nominal 5.87:1 at >=1.25px. Ships at 1.5px silhouette / 1.4px glyph, constant in device px. - No severity token is reachable: nearest distance to severity is 31.2 normal / 16.1 at worst under protan-deutan simulation. HONEST DEGRADATION, reported rather than buried: across all 105 pairs of 15 complete marks in greyscale the median is 82.6 and the WORST is 6.7 — two kinds in the same family differing only by glyph. That is what a family is, and it is why the label is not optional. A prerequisite found on the way: the consuming app's 15 kinds ran on 11 glyphs, four pairs sharing one — DSSIM 0, i.e. identical. Three new icons exist so no two kinds share a glyph. WHERE IT STOPS: four families. No fifth silhouette exists; the rejection table above is the whole search. Inside a family the glyph is roomy (a greedy max-min over the 36-glyph registry holds every pair above 70.0 out to 17 glyphs), but the real limit is what has to be LEARNED — four silhouettes, taught by KindLegend. That legend is four rows for fifteen kinds and stays four for fifty. A legend that grew with the axis would be the admission that the encoding had failed. Gates: typecheck, lint (0 errors; two v-html warnings matching Icon.vue's existing pattern), build incl. verify:externals and verify:dev-warnings, build-storybook, test:ci 237 passed. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 201 +++++++++++++++++ package-lock.json | 4 +- package.json | 2 +- src/components/atoms/KindMark.stories.ts | 211 ++++++++++++++++++ src/components/atoms/KindMark.vue | 211 ++++++++++++++++++ .../molecules/KindLegend.stories.ts | 55 +++++ src/components/molecules/KindLegend.vue | 69 ++++++ src/helpers/tone.ts | 11 +- src/icons.ts | 17 ++ src/index.ts | 9 + src/kindShapes.ts | 106 +++++++++ src/tokens.css | 7 +- 12 files changed, 897 insertions(+), 6 deletions(-) create mode 100644 src/components/atoms/KindMark.stories.ts create mode 100644 src/components/atoms/KindMark.vue create mode 100644 src/components/molecules/KindLegend.stories.ts create mode 100644 src/components/molecules/KindLegend.vue create mode 100644 src/kindShapes.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 933e6f0..1ac13a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,207 @@ 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.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-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 | +| `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. + +### Notes + +- Not changed, but re-confirmed while measuring: `warning` and `danger` remain + ΔE 6.7 apart to normal vision and 2.9 under simulated red-green colour + blindness (v1.15.0's note). Both always carry text, so meaning is never + colour-alone, and separating them is still a major-release decision. +- The consuming app's adoption is a separate change: `KindMark` replaces a + 15-kind, 12-hue map that this release exists to retire. The comment in that + map naming the ceiling should be rewritten, not deleted — the ceiling moved + from three to four, it did not go away. + ## v1.15.0 A categorical (non-severity) colour vocabulary, added as a **parallel** set to diff --git a/package-lock.json b/package-lock.json index d66fba1..05741cd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@codebar-ag/storybook", - "version": "1.15.0", + "version": "1.16.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@codebar-ag/storybook", - "version": "1.15.0", + "version": "1.16.0", "license": "MIT", "dependencies": { "@fontsource/jetbrains-mono": "^5.3.0", diff --git a/package.json b/package.json index 86bc872..92b5470 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@codebar-ag/storybook", - "version": "1.15.0", + "version": "1.16.0", "description": "codebar-ag DocuHub — shared Vue 3 + Tailwind v4 design-system atoms and tokens, documented in Storybook.", "license": "MIT", "author": "codebar Solutions AG", diff --git a/src/components/atoms/KindMark.stories.ts b/src/components/atoms/KindMark.stories.ts new file mode 100644 index 0000000..e92016a --- /dev/null +++ b/src/components/atoms/KindMark.stories.ts @@ -0,0 +1,211 @@ +import type { Meta, StoryObj } from '@storybook/vue3-vite'; +import KindMark from './KindMark.vue'; +import { kindShapes } from '../../kindShapes'; + +const meta: Meta = { + title: 'Atoms/KindMark', + component: KindMark, + argTypes: { + shape: { control: 'select', options: Object.keys(kindShapes) }, + category: { control: 'select', options: [undefined, 'indigo', 'purple', 'magenta'] }, + size: { control: 'select', options: ['sm', 'md', 'lg'] }, + }, + args: { shape: 'hexagon', icon: 'server', label: 'MCP server', category: 'magenta', size: 'md' }, + render: (args) => ({ + components: { KindMark }, + setup: () => ({ args }), + template: '', + }), +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +// The four silhouettes, alone. This is the whole primary channel: everything +// past four has to be said in the glyph or in the label. +export const Silhouettes: Story = { + render: () => ({ + components: { KindMark }, + template: `
+ + + + +
`, + }), +}; + +// The case this was built for: 15 flow-graph node kinds, four families. +// Fifteen distinct glyphs, four silhouettes, four colours — and the type name +// on every one of them. +const GRAPH_KINDS = ` +
+ + + + + + + + + + + + + + + +
`; + +export const FifteenKinds: Story = { + render: () => ({ components: { KindMark }, template: GRAPH_KINDS }), +}; + +/** + * The acceptance test for the whole design, run in the browser rather than + * argued: the same fifteen marks with `grayscale`. If anything here becomes + * unreadable, the colour was carrying the signal after all and the design has + * failed. + * + * It does not, because it cannot: the silhouettes are geometry. Measured, the + * worst cross-family pair scores DSSIM 59.0 at this size — against a floor of + * 30 calibrated on pairs users demonstrably confuse. For contrast, the kit's + * own categorical colours desaturate to L* 40.7 and 40.6: indigo and magenta + * become the same grey. + */ +export const InGreyscale: Story = { + render: () => ({ + components: { KindMark }, + template: `
+

Same marks, grayscale applied.

+
${GRAPH_KINDS}
+
`, + }), +}; + +/** + * Before and after, on the pair that made this necessary. + * + * The "before" row is a real consuming app's flow-graph palette: 15 kinds on + * raw Tailwind ramp hues, several of them landing exactly where the severity + * ramp lives. A data source rendered amber and a schema rendered emerald read + * as "caution" and "healthy" to anyone who has just looked at a status badge — + * the kind was being reported as a status. Desaturate that row and it stops + * saying anything at all. + */ +export const AgainstRawHues: Story = { + render: () => ({ + components: { KindMark }, + template: `
+
+

Before — raw ramp hues, colour is the whole signal

+
+ Data source + Schema + Provider + MCP server +
+
+ Data source + Schema + Provider + MCP server +
+
+
+

After — shape first, colour last

+
+ + + + +
+
+ + + + +
+
+
`, + }), +}; + +/** + * Sizes, with what each one is actually good for. + * + * The silhouette outlives the glyph by a long way — it is still separable at + * 12px, where a glyph is a smudge. `sm` is the point at which the glyph channel + * is at its measured floor (worst within-family pair 34.8 against a floor of + * 30), so at `sm` read the silhouette and the label and treat the glyph as + * decoration. + */ +export const Sizes: Story = { + render: () => ({ + components: { KindMark }, + template: `
+
+ lg · 40px / 15px glyph + +
+
+ md · 32px / 12px glyph + +
+
+ sm · 24px / 9px glyph + +
+
`, + }), +}; + +/** + * Severity and kind side by side — the collision this exists to stop. + * + * The top row is a status: something is being reported as good or bad. The + * bottom row is an identity: this box is one sort of thing rather than another. + * They must never be confusable, which is why no `KindMark` colour comes from + * the severity ramp. Measured, the closest any family colour comes to any + * severity token is `accent` ↔ `danger`, ΔE 28.4 to normal vision and 16.1 + * under simulated red-green colour blindness. + */ +export const NotAStatus: Story = { + render: () => ({ + components: { KindMark }, + template: `
+
+ Severity + Succeeded + Retrying + Failed +
+
+ Kind + + + +
+
`, + }), +}; + +/** + * Silhouette-only marks. Legal, and the state `KindLegend` uses — but note the + * label is still there. There is no way to render a `KindMark` without one, + * and that is the design rather than an oversight: four silhouettes cannot + * name fifteen kinds, so the mark must never be the only thing on screen. + */ +export const WithoutAGlyph: Story = { + render: () => ({ + components: { KindMark }, + template: `
+ + + + +
`, + }), +}; diff --git a/src/components/atoms/KindMark.vue b/src/components/atoms/KindMark.vue new file mode 100644 index 0000000..95981cc --- /dev/null +++ b/src/components/atoms/KindMark.vue @@ -0,0 +1,211 @@ + + + diff --git a/src/components/molecules/KindLegend.stories.ts b/src/components/molecules/KindLegend.stories.ts new file mode 100644 index 0000000..07bea39 --- /dev/null +++ b/src/components/molecules/KindLegend.stories.ts @@ -0,0 +1,55 @@ +import type { Meta, StoryObj } from '@storybook/vue3-vite'; +import KindLegend from './KindLegend.vue'; +import type { KindLegendItem } from './KindLegend.vue'; + +const FAMILIES: KindLegendItem[] = [ + { shape: 'square', label: 'Step' }, + { shape: 'circle', label: 'Entry', category: 'indigo' }, + { shape: 'diamond', label: 'Contract', category: 'purple' }, + { shape: 'hexagon', label: 'System', category: 'magenta' }, +]; + +const meta: Meta = { + title: 'Molecules/KindLegend', + component: KindLegend, + args: { items: FAMILIES, label: 'Kinds' }, + render: (args) => ({ + components: { KindLegend }, + setup: () => ({ args }), + template: '', + }), +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const WithoutACaption: Story = { args: { label: '' } }; + +/** + * The point of the component in one picture: fifteen kinds above, four legend + * rows below. + * + * The legend does not list kinds and must not be made to. Every kind names + * itself on its own mark, so the only thing left to explain is the silhouette — + * a claim about which kinds belong together, which no single mark can make. A + * legend that grew to fifteen rows would be conceding that the encoding had + * stopped working; at that point the reader is just reading labels and the + * marks are decoration. + */ +export const FourRowsForFifteenKinds: Story = { + render: () => ({ + components: { KindLegend }, + setup: () => ({ families: FAMILIES }), + template: `
+

+ A graph of 15 node kinds. Each card carries its own type name, so the + legend explains the four shapes and nothing else. +

+
+ +
+
`, + }), +}; diff --git a/src/components/molecules/KindLegend.vue b/src/components/molecules/KindLegend.vue new file mode 100644 index 0000000..63b6a24 --- /dev/null +++ b/src/components/molecules/KindLegend.vue @@ -0,0 +1,69 @@ + + + diff --git a/src/helpers/tone.ts b/src/helpers/tone.ts index 0d3f0ef..cbd838f 100644 --- a/src/helpers/tone.ts +++ b/src/helpers/tone.ts @@ -111,8 +111,15 @@ export function resolveTone(value: Tone | LegacyTone | undefined, fallback: Tone * red-green colour blindness, which is indistinguishable. * * So: three real categories, and `neutral` for "no category". More kinds than - * that need a channel other than colour — an icon, or the label itself. This is - * the honest ceiling, not a placeholder to grow later. + * that need a channel other than colour. This is the honest ceiling, not a + * placeholder to grow later. + * + * Since v1.16.0 the kit ships that other channel: `KindMark` carries the kind + * in a silhouette and a glyph, with one of these three colours (or neutral) as + * redundant reinforcement. Reach for it rather than for a fourth `Category`. + * Why it had to be geometry is measurable in these very tokens — desaturated, + * `cat-indigo` and `cat-magenta` are L* 40.7 and 40.6, the same grey. Colour + * does not survive greyscale; a silhouette cannot fail it. */ export type Category = 'indigo' | 'purple' | 'magenta'; diff --git a/src/icons.ts b/src/icons.ts index 0b54e07..61c54a3 100644 --- a/src/icons.ts +++ b/src/icons.ts @@ -38,6 +38,23 @@ export const icons = { cloud: '', users: '', user: '', + /* Three glyphs added for `KindMark` (v1.16.0). The gap they close was not + cosmetic. A consuming app labelling 15 graph-node kinds had to put those + 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. + Measured, each of those pairs scores DSSIM 0: the only value that means + "the same picture". A design that makes the glyph the primary channel + has to supply enough glyphs for it to be a channel. + + Measured against the whole registry at 16px, nearest neighbour of each: + database ↔ document 56.7, chat ↔ eye-slash 73.4, chip ↔ info 47.1 — all + far above the 26.3 that eye ↔ eye-slash scores, which is this kit's + calibration point for "a pair that people genuinely do confuse". */ + database: + '', + chat: '', + chip: '', } as const; export type IconName = keyof typeof icons; diff --git a/src/index.ts b/src/index.ts index 83f37eb..769969f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -40,6 +40,8 @@ import IdCell from './components/atoms/IdCell.vue'; import Input from './components/atoms/Input.vue'; import InputNumber from './components/molecules/InputNumber.vue'; import Kbd from './components/atoms/Kbd.vue'; +import KindLegend from './components/molecules/KindLegend.vue'; +import KindMark from './components/atoms/KindMark.vue'; import KnowledgeSchemaPanel from './components/organisms/KnowledgeSchemaPanel.vue'; import Label from './components/atoms/Label.vue'; import LabeledCodeBlock from './components/molecules/LabeledCodeBlock.vue'; @@ -123,6 +125,8 @@ export { Input, InputNumber, Kbd, + KindLegend, + KindMark, KnowledgeSchemaPanel, Label, LabeledCodeBlock, @@ -189,6 +193,8 @@ export { useListNavigation } from './composables/useListNavigation'; export { useFormErrors } from './composables/useFormErrors'; export { icons } from './icons'; export type { IconName } from './icons'; +export { kindShapes } from './kindShapes'; +export type { KindShape } from './kindShapes'; export { cx } from './helpers/cx'; export { resolveTone, resolveCategory } from './helpers/tone'; export type { Tone, LegacyTone, Category } from './helpers/tone'; @@ -203,6 +209,7 @@ export type { SelectOption } from './components/atoms/Select.vue'; export type { Step } from './components/molecules/Stepper.vue'; export type { BreadcrumbItem } from './components/molecules/Breadcrumbs.vue'; export type { TabItem } from './components/molecules/Tabs.vue'; +export type { KindLegendItem } from './components/molecules/KindLegend.vue'; export type { DataTableColumn } from './components/organisms/dataTable.types'; /** @@ -250,6 +257,8 @@ const components: Record = { Input, InputNumber, Kbd, + KindLegend, + KindMark, KnowledgeSchemaPanel, Label, LabeledCodeBlock, diff --git a/src/kindShapes.ts b/src/kindShapes.ts new file mode 100644 index 0000000..375afc6 --- /dev/null +++ b/src/kindShapes.ts @@ -0,0 +1,106 @@ +/** + * Silhouettes for the `KindMark` atom — the kit's third categorical channel, + * after `Tone` (severity) and `Category` (identity, in colour). + * + * ## Why a shape channel exists at all + * + * `Category` ships three colours and v1.15.0 documented that as a **measured + * ceiling**, not a first instalment: 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. A fourth collapses the worst pair to ΔE≈3 under simulated red-green + * colour blindness. + * + * That is a hard stop, and a real consuming app ran into it: a flow-graph + * diagram with **15 node kinds**, drawn on 12 raw Tailwind hues because there + * was nothing else to draw them with. Several of those hues sat on top of the + * severity ramp, so a node whose *kind* happened to be "data source" rendered + * in the same amber the UI uses for "something is wrong". + * + * No fourth colour fixes that, and neither does a fifth. The fix is a channel + * that is not colour. + * + * ## The greyscale test, and the number that settles it + * + * The test a non-colour channel has to pass is: print it in black and white and + * every kind is still distinguishable. That is not rhetorical — measured on the + * kit's own categorical tokens, `--color-cat-indigo` and `--color-cat-magenta` + * are **L\* 40.7 and 40.6**. A difference of 0.2. Desaturated they are the same + * grey, and the whole three-colour vocabulary carries about as much information + * as one grey and one dark grey. + * + * So in `KindMark` the silhouette is the primary channel and the colour is + * redundant reinforcement. Remove the colour and nothing is lost. + * + * ## Why exactly four silhouettes + * + * Measured the same way the colours were, with the metric a shape channel + * admits: DSSIM = (1 − SSIM) × 100 (structural similarity, Wang et al. 2004), + * on real Chromium rasters of the real marks at real pixel sizes, greyscale. + * SSIM's 11×11 Gaussian window is itself a coarse low-pass, which is what + * "distinguishable at a glance" means. + * + * The floor is **DSSIM ≥ 30**, and it is calibrated against controls rather + * than asserted, because a bare number in a new metric means nothing: + * + * - `circle` ↔ a regular octagon — 13.4. An octagon at mark size *is* a + * circle; this is what "indistinguishable" reads as. + * - `square` ↔ the same square with a larger corner radius — 25.4. Visibly + * the same object. + * - `eye` ↔ `eye-slash` in this kit's own icon registry — 26.3. A pair users + * demonstrably confuse. + * + * Anything at or under ~26 is a pair we can independently confirm is + * confusable, so 30 is the first honest floor above them. + * + * The four shipped silhouettes measure **59.0** at their worst pair + * (`square` ↔ `circle`, 32px, 1.5px stroke) — better than twice the floor and + * 4.4× the octagon control. What was rejected, and why: + * + * | rejected | why | + * | ---------- | --- | + * | octagon | 13.4 against `circle` — at mark sizes it is a circle | + * | squircle | 25.4 against `square` — a corner radius is not a shape | + * | pentagon | 53.2 against `hexagon`, and falling with size | + * | triangle | separable (59.0) but **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. | + * + * Four is therefore the ceiling of this channel, and it is a happy one: three + * categories plus "no category" is also exactly four, so every silhouette gets + * a distinct colour and the two ceilings coincide rather than fight. + * + * ## What the numbers say about size + * + * The silhouette is the *coarse* channel and stays readable well below the size + * at which the glyph does. Worst cross-shape pair, outline only, 1.5px stroke: + * + * | mark | worst pair DSSIM | + * | ---- | ---------------- | + * | 40px | 55.2 | + * | 32px | 59.0 | + * | 24px | 68.4 | + * | 16px | 55.5 | + * | 12px | 37.8 | + * | 8px | 25.2 — at the squircle control, i.e. gone | + * + * So the family reads down to 12px; below that it does not. The glyph inside + * needs considerably more room (see `KindMark`), which is the whole point of + * having two channels rather than one. + * + * ## Geometry + * + * Every path is drawn on a 32-unit box, inset 1.5 units so a 1.5px stroke never + * clips at any rendered size. They are outlines: `KindMark` supplies the stroke + * width, and a decorative soft fill. + */ +export const kindShapes = { + /** Work: a step that runs. The default and the most capacious. */ + square: '', + /** An event: the thing that starts or times a run. */ + circle: '', + /** A definition matched against: schema, contract, stored data. */ + diamond: '', + /** Something outside the system: a service, a server, a model. */ + hexagon: '', +} as const; + +export type KindShape = keyof typeof kindShapes; diff --git a/src/tokens.css b/src/tokens.css index 09f59c7..0a55c7c 100644 --- a/src/tokens.css +++ b/src/tokens.css @@ -141,7 +141,12 @@ What survives is a single ~60° arc, blue→magenta, and inside it exactly three mutually-separable steps. A fourth drops the worst pair to ΔE≈3.0 under simulated red-green colour blindness — indistinguishable. Need more - kinds than three? Use a channel that is not colour (an icon, or the label). + kinds than three? Use a channel that is not colour. Since v1.16.0 that + channel is `KindMark`: it carries the distinction in a silhouette and a + glyph and demotes these tokens to redundant reinforcement. One number + settles why it had to exist — desaturated, cat-indigo and cat-magenta + measure L* 40.7 and 40.6. In greyscale they are the same grey, so nothing + decided in this block survives a black-and-white print. Measured, this set (lowest of the three text ratios noted per token): indigo ↔ purple ΔE 17.3 normal / 16.6 CVD