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