@pithyjs/pidl-catalog 0.1.0-beta.0 → 0.1.0-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,74 @@
1
+ # @pithyjs/pidl-catalog
2
+
3
+ Machine-readable component contracts for [PithyJS](https://github.com/PithyJS/pithyjs).
4
+
5
+ A **PIDL spec** is a JSON document declaring a component's props, types,
6
+ defaults, slots, events and accessibility guarantees. The catalog is what lets
7
+ tooling reason about a component without reading its source: the props panels,
8
+ the AI content projector and the registry generator all read these same files.
9
+
10
+ Coverage is partial and growing — `IconButton`, `Tooltip`, `Popover`, `NavLink`
11
+ and `ErrorBoundary` have no spec yet.
12
+
13
+ > **Beta.** Published under the `beta` tag alongside the rest of the PithyJS
14
+ > packages. The spec shape is still moving; pin an exact version.
15
+
16
+ ## Install
17
+
18
+ ```bash
19
+ npm install @pithyjs/pidl-catalog@beta
20
+ ```
21
+
22
+ ## What is in it
23
+
24
+ Specs ship at `dist/specs/`, organised by atomic level (`atoms/`, `layout/`,
25
+ `organisms/`, `sections/`). Enumerate them from `ALL_PIDL_SPECS` rather than
26
+ relying on a count in prose — this file cannot recount itself when a spec
27
+ lands.
28
+
29
+ A spec's top level looks like this:
30
+
31
+ ```jsonc
32
+ {
33
+ "kind": "component",
34
+ "id": "ui.button",
35
+ "name": "Button",
36
+ "level": "atom",
37
+ "domain": "actions",
38
+ "description": "...",
39
+ "props": { /* name -> type, default, constraints */ },
40
+ "events": { /* ... */ },
41
+ "slots": { /* ... */ }
42
+ }
43
+ ```
44
+
45
+ ## Usage
46
+
47
+ ```ts
48
+ import { ALL_PIDL_SPECS } from '@pithyjs/pidl-catalog';
49
+ ```
50
+
51
+ Design-intelligence tables are separate entry points, so importing one does not
52
+ pull the others:
53
+
54
+ ```ts
55
+ import { COLOR_PSYCHOLOGY_GUIDANCE } from '@pithyjs/pidl-catalog/color-psychology';
56
+ import { MUTATION_VOCABULARY } from '@pithyjs/pidl-catalog/mutation-vocabulary';
57
+ import { DESIGN_INTELLIGENCE_RULES } from '@pithyjs/pidl-catalog/design-intelligence';
58
+ ```
59
+
60
+ Available subpaths: `design-intelligence`, `mutation-vocabulary`, and the
61
+ `*-psychology` tables for `color`, `typography`, `spacing`, `radius`,
62
+ `shadows`, `motion` and `borders`.
63
+
64
+ ## Why it exists
65
+
66
+ A component library documented only in prose forces every tool that consumes it
67
+ to guess — at which props exist, which values are legal, and which
68
+ accessibility rules are non-negotiable. PIDL makes those machine-checkable, so
69
+ "the model never writes CSS" can be enforced by the build rather than asked for
70
+ in a prompt.
71
+
72
+ ## License
73
+
74
+ MIT
@@ -0,0 +1,315 @@
1
+ {
2
+ "kind": "component",
3
+ "id": "ui.bars",
4
+ "name": "Bars",
5
+ "displayName": "Bar Chart",
6
+ "version": "0.1.0",
7
+ "level": "atom",
8
+ "domain": "data",
9
+ "bidi": true,
10
+ "description": "Horizontal bar chart for comparing categories against one shared scale. Each row is a category name, a track with a proportional fill, and the value — rows are independent, so no bar is affected by its neighbours. Rendered in DOM rather than SVG so names can ellipsis, numbers can be tabular, and text rescales with the reader's own font size. The drawing is aria-hidden; a visually-hidden data table carries every exact value and is the chart's accessible representation. There is no colour key: every row already carries its own name and number. Loading, empty (no rows at all — an all-zero chart stays ready, because its rows still name themselves), and error states are first-class.",
11
+ "props": [
12
+ {
13
+ "name": "label",
14
+ "label": "Accessible name",
15
+ "type": "string",
16
+ "required": true,
17
+ "placeholder": "Traffic by source",
18
+ "description": "Captions the visually-hidden data table screen readers read instead of the graphic. Required — it is the chart's accessible name."
19
+ },
20
+ {
21
+ "name": "bars",
22
+ "label": "Rows",
23
+ "type": "BarDatum[] | Signal<BarDatum[]>",
24
+ "required": true,
25
+ "reactive": true,
26
+ "placeholder": [
27
+ { "label": "Search", "value": 420 },
28
+ { "label": "Direct", "value": 260 },
29
+ { "label": "Referral", "value": 130 }
30
+ ],
31
+ "description": "Rows in display order. A value must be a finite number >= 0; anything else is counted as zero, warned about in DEV, and still listed in the accessible table.",
32
+ "itemSchema": {
33
+ "label": {
34
+ "type": "string",
35
+ "required": true,
36
+ "description": "Category name. Shown beside the bar, truncated with an ellipsis when the column is too narrow, and used as the row header in the accessible table."
37
+ },
38
+ "value": {
39
+ "type": "number",
40
+ "required": true,
41
+ "description": "The quantity. Finite and >= 0."
42
+ },
43
+ "color": {
44
+ "type": "string",
45
+ "editor": { "type": "token-color" },
46
+ "enum": [
47
+ "primary",
48
+ "secondary",
49
+ "tertiary",
50
+ "success",
51
+ "info",
52
+ "warning",
53
+ "danger",
54
+ "categorical-1",
55
+ "categorical-2",
56
+ "categorical-3",
57
+ "categorical-4",
58
+ "categorical-5",
59
+ "categorical-6"
60
+ ],
61
+ "description": "Design-token name for this bar. Omit and every row takes the same default slot. Unlike a donut, a bar chart reads perfectly with every row the same colour — the names are already there — so reach for this when a row has a genuine semantic ('danger' for an error count), not to decorate."
62
+ }
63
+ }
64
+ },
65
+ {
66
+ "name": "max",
67
+ "label": "Scale maximum",
68
+ "type": "number | Signal<number>",
69
+ "reactive": true,
70
+ "placeholder": 500,
71
+ "description": "The value a full-width bar represents. Defaults to the greatest value present, which reads well for one chart and lies across several — a chart of single digits and a chart of millions then look identical. Pass the same max to a row of charts and their bars mean the same thing. A value that is not a finite number greater than zero is not a scale: it is DEV-warned and the derived ceiling is used instead."
72
+ },
73
+ {
74
+ "name": "scale",
75
+ "label": "Value axis",
76
+ "editor": { "tab": "layout" },
77
+ "type": "enum",
78
+ "enum": ["axis", "none"],
79
+ "default": "axis",
80
+ "description": "Whether to draw the value axis under the bars. The axis is what STATES the scale: without it a full-width track means 'the ceiling' and the ceiling appears nowhere on screen, so a reader sees that 40 beats 25 but not that 40 is 40% of a budget of 100 — which is the entire point of passing `max`. Ticks land on nice numbers (a power of ten times 1, 2 or 5) so a reader can interpolate between them, and the last tick is the largest that fits AT OR BELOW the ceiling — the ceiling itself only when it is one of them. A max of 100 ends on 100; a derived ceiling of 520 ends on 500, and the full bar runs past that mark, which is itself the reading. Extending the scale past the ceiling instead would mislabel every mark, because the track still ends at the ceiling. Prefer a round max; where it is not round the exact ceiling is still stated in the table's <tfoot>. 'none' drops it: right for a stat tile, where the numbers beside the bars are the reading and the bars are decoration, and for a row of charts under one shared axis. The accessible table is unaffected either way."
81
+ },
82
+ {
83
+ "name": "size",
84
+ "label": "Size",
85
+ "editor": { "tab": "layout" },
86
+ "type": "enum",
87
+ "enum": ["sm", "md", "lg"],
88
+ "default": "md",
89
+ "description": "Sets the track thickness and the type scale. It does NOT cap the chart's width, unlike the circular charts: a bar's inline extent is its SCALE rather than its size, and capping it at md's 12rem would leave about 3rem of track after the name and the number — not a smaller chart, an unreadable one. The chart fills its column and stays intrinsically fluid, with no breakpoints."
90
+ },
91
+ {
92
+ "name": "loading",
93
+ "label": "Loading",
94
+ "editor": { "tab": "settings" },
95
+ "type": "boolean | Signal<boolean>",
96
+ "default": false,
97
+ "reactive": true,
98
+ "description": "Hides the drawing and the table, shows a pulsing skeleton in the drawing's slot, and sets aria-busy on the drawing's wrapper. Outranks every other state."
99
+ },
100
+ {
101
+ "name": "error",
102
+ "label": "Error",
103
+ "editor": { "tab": "settings" },
104
+ "type": "boolean | Signal<boolean>",
105
+ "default": false,
106
+ "reactive": true,
107
+ "description": "Hides the drawing and the table and shows the error message. Outranks the empty state — a failed fetch usually leaves an empty array behind it."
108
+ },
109
+ {
110
+ "name": "errorMessage",
111
+ "label": "Error message",
112
+ "editor": { "tab": "settings" },
113
+ "type": "string | Signal<string>",
114
+ "default": "Chart data unavailable",
115
+ "reactive": true,
116
+ "description": "Copy shown in the error state."
117
+ },
118
+ {
119
+ "name": "emptyMessage",
120
+ "label": "Empty message",
121
+ "editor": { "tab": "settings" },
122
+ "type": "string",
123
+ "default": "No data to display",
124
+ "description": "Copy shown when there are no rows. NOT shown for an all-zero chart, which stays `ready` and draws its named rows at zero width — see the constraint on the empty state."
125
+ },
126
+ {
127
+ "name": "loadingLabel",
128
+ "label": "Loading message",
129
+ "editor": { "tab": "settings" },
130
+ "type": "string",
131
+ "default": "Loading chart…",
132
+ "description": "Copy shown in the loading state."
133
+ },
134
+ {
135
+ "name": "id",
136
+ "label": "ID",
137
+ "editor": { "tab": "settings" },
138
+ "type": "string",
139
+ "description": "Explicit element ID on the <figure> root."
140
+ }
141
+ ],
142
+ "events": [],
143
+ "slots": [],
144
+ "tokens": {
145
+ "track.background": "--color-surface-variant",
146
+ "fill.background": "--color-categorical-1 … --color-categorical-6, or any semantic colour",
147
+ "fill.background.fallback": "--color-text-muted (only a row whose explicit colour is not a recognised token)",
148
+ "track.radius": "--radius-xs",
149
+ "label.color": "--color-text-muted",
150
+ "value.color": "--color-text",
151
+ "value.font": "--font-family-mono",
152
+ "type.size": "--font-size-sm (--font-size-xs at sm, --font-size-body at lg)",
153
+ "fill.motion.duration": "--motion-duration-slow",
154
+ "fill.motion.easing": "--motion-easing-emphasized",
155
+ "status.color": "--color-text-muted",
156
+ "status.error.color": "--color-danger",
157
+ "placeholder.background": "--color-surface-variant",
158
+ "placeholder.radius": "--radius-md",
159
+ "forced-colors.border": "--border-width-thin"
160
+ },
161
+ "spacing": {
162
+ "internal": {
163
+ "label-to-track-to-value": {
164
+ "relationship": "close_friends",
165
+ "token": "sm",
166
+ "note": "Three parts of one reading, scanned across as a unit."
167
+ },
168
+ "row-to-row": {
169
+ "relationship": "friends",
170
+ "token": "md",
171
+ "note": "Separate categories, compared rather than read in sequence. Deliberately looser than the column gap, so the eye groups each row across before it groups the chart down."
172
+ },
173
+ "graphic-to-status": { "relationship": "close_friends", "token": "sm" }
174
+ }
175
+ },
176
+ "constraints": [
177
+ "Use logical properties only — no left/right, no width/height where inline-size/block-size applies",
178
+ "Colour is a design-token NAME, never a CSS value — there is no prop through which a hex code can reach a stylesheet",
179
+ "No function-valued props. Every prop has to be editable and serialisable through PIDL, so there is no formatValue callback; numbers are formatted by the kit's own formatter, which the bar's own value label and the data table both go through so the two cannot disagree",
180
+ "Rendered in DOM, not SVG. A bar row is text, a rectangle and a numeral — DOM gives text-overflow: ellipsis on a long category name, font-variant-numeric: tabular-nums so the value column does not jitter, and text that rescales with the reader's font setting (WCAG 1.4.4) rather than with a viewBox transform. SVG <text> offers none of the three",
181
+ "The graphic is aria-hidden — the visually-hidden data table is the accessible representation, so `label` is required",
182
+ "The chart is width-agnostic: it fills its column and `size` sets thickness and type instead. Capping a bar chart's width caps its scale, which does not shrink it, it breaks it",
183
+ "The scale maximum is stated as a <tfoot> row in the data table. A bar's length is a ratio and nothing on screen states the denominator, so this is the only route by which a screen reader learns what a full bar means",
184
+ "A bar past the ceiling is drawn full and the table still reports its true value — the clamp is a drawing decision and may not reach the data",
185
+ "A non-zero value too small to see is widened to a minimum visible width rather than vanishing. Rows are independent, so widening one moves nothing else; the only number misreported is its own, and only for the category a reader could not otherwise see exists. This is the OPPOSITE of the donut's rule, and deliberately so — an arc that is widened pushes every later arc round the ring",
186
+ "A value of zero is drawn at no width at all. Nothing is not a sliver, and a zero row still appears in the table",
187
+ "A chart with NO ROWS renders the empty state; the geometry never divides by zero. A chart whose rows all hold ZERO does NOT: it stays `ready`, because its rows label themselves — an all-zero bar chart still draws a named track and a numeral per category, and going empty would hide a chart a sighted reader can see while stripping those names out of the accessible tree. `<Donut>` and `<StackedBar>` do go empty on the same input, and should: their parts are named only by the key, so a key of zeroes beside a blank track states nothing the status line does not",
188
+ "The drawing and the data table are hidden together in every non-ready state so the two cannot disagree about whether there is data",
189
+ "An empty or whitespace-only row label is a MISSING label and becomes 'Series N' — a blank row header is a category a reader can neither name nor look up",
190
+ "Row colour defaults to ONE slot for every row, not a slot per index. A bar names itself, so a different hue per row encodes nothing — and cycling ran out: ten uncoloured rows, which this spec recommends, produced four palette-exhaustion warnings and drew rows 1-6 categorical with 7-10 neutral. An explicit per-row colour is still honoured, and an unrecognised token still falls back to the neutral mark colour",
191
+ "No colour key. Every row carries its own name and its own number permanently, so colour here is never the sole carrier of meaning (WCAG 1.4.1) and a key would only restate what is already on screen",
192
+ "The fill's width transition is inside a motion-safe block, and its width custom property defaults to 0% — an unset fill must read as an empty bar, never as a full one"
193
+ ],
194
+ "a11y": [
195
+ "The drawing carries aria-hidden='true' — it is a picture of the table beside it",
196
+ "A <table class='u-sr-only'> carries every exact value, captioned with the required `label` prop",
197
+ "Table headers are scoped: <th scope='col'> in the head, <th scope='row'> per row",
198
+ "The scale maximum is a <tfoot> row, because a bar length means nothing without the ceiling it is measured against and nothing on screen states it",
199
+ "Values clamped at the ceiling and widths raised by the visibility floor both appear in the table at their true values — the table is the reading of record wherever the drawing is an approximation",
200
+ "Zero-value rows appear in the table even though they are drawn at no width",
201
+ "A chart whose rows ALL hold zero stays 'ready' rather than going 'empty'. The empty state hides the table, which would take those named categories out of the accessible tree and announce 'No data to display' for a chart a sighted reader can see as named tracks at zero. Only genuinely no rows is empty — this deliberately differs from Donut and StackedBar, whose all-zero case really has nothing to draw and no per-part label anywhere",
202
+ "Colour is never the only encoding: each row carries its name and its number beside the bar, which is why this chart needs no key",
203
+ "Status messages live in a role='status' region present from first render — a live region inserted when it gains content is often not announced",
204
+ "aria-busy='true' while loading goes on the drawing's wrapper (.ui-chart-body), NEVER on the root. The role='status' region is a child of the root, and a live region inside an aria-busy subtree has its changes DEFERRED until busy clears — which happens in the same call that blanks the region, so the announcement an AT ends up processing is 'empty'",
205
+ "forced-color-adjust: none on the fills and the track, restated here because the shared partial's rule selects '.ui-chart-graphic > svg' and this chart has no SVG. In a chart the colour IS the data, so flattening the slots to one system colour would delete the encoding rather than raise contrast; the track gains a CanvasText border so the unfilled remainder stays visible",
206
+ "Text rescales with the reader's font-size setting rather than with the drawing, which SVG text cannot do (WCAG 1.4.4)",
207
+ "The fill's growth animation is inside a prefers-reduced-motion: no-preference block"
208
+ ],
209
+ "ai": {
210
+ "synonyms": [
211
+ "bar chart",
212
+ "horizontal bar chart",
213
+ "bar graph",
214
+ "ranked list",
215
+ "top categories",
216
+ "comparison chart",
217
+ "leaderboard chart",
218
+ "progress bars",
219
+ "hbar"
220
+ ],
221
+ "avoid": [
222
+ "Passing a CSS colour — the color field takes a design-token name",
223
+ "Using it for parts of one whole — that is StackedBar or Donut. These bars are independent quantities, and nothing here sums to anything",
224
+ "Omitting `max` when two charts are meant to be compared: without it each chart scales to its own largest value and the two are silently incomparable",
225
+ "Expecting a colour key — there is none, and none is needed: every row is labelled",
226
+ "Encoding meaning with the semantic palette for arbitrary categories (danger for 'category 3' says category 3 is bad)",
227
+ "Wrapping it in a fixed narrow column: the chart fills its column and a bar needs width to be a scale",
228
+ "Relying on the graphic alone for accessibility — the table is the accessible representation and `label` captions it"
229
+ ],
230
+ "patterns": [
231
+ "Basic: <Bars label=\"'Traffic by source'\" bars=\"sources\" />",
232
+ "Comparable pair: <Bars label=\"'This week'\" bars=\"thisWeek\" max=\"500\" /> beside <Bars label=\"'Last week'\" bars=\"lastWeek\" max=\"500\" />",
233
+ "Semantic colour: bars=[{ label: 'Failed', value: 12, color: 'danger' }]",
234
+ "Live: <Bars label=\"'Traffic'\" bars=\"() => sources()\" loading=\"isFetching\" />",
235
+ "Compact: <Bars label=\"'Traffic by source'\" bars=\"sources\" size=\"'sm'\" />"
236
+ ]
237
+ },
238
+ "examples": [
239
+ {
240
+ "id": "basic",
241
+ "title": "Three categories on a derived scale",
242
+ "pithy": "<Bars label=\"'Traffic by source'\" bars=\"sources\" />",
243
+ "assertions": [
244
+ "figure.ui-chart.ui-bars with data-status='ready'",
245
+ "one .ui-bars-label, .ui-bars-track and .ui-bars-value per row",
246
+ "the largest row's fill is 100% wide",
247
+ "no <svg> anywhere",
248
+ "no axe violations"
249
+ ]
250
+ },
251
+ {
252
+ "id": "shared-scale",
253
+ "title": "An explicit ceiling makes two charts comparable",
254
+ "pithy": "<Bars label=\"'Traffic by source'\" bars=\"sources\" max=\"100\" />",
255
+ "assertions": [
256
+ "a row worth 40 renders a fill of 40%, not 100%",
257
+ "the <tfoot> reads 'Scale maximum, 100'"
258
+ ]
259
+ },
260
+ {
261
+ "id": "over-ceiling",
262
+ "title": "A value past the ceiling",
263
+ "pithy": "<Bars label=\"'Budget use'\" bars=\"[{label:'Over',value:150}]\" max=\"100\" />",
264
+ "assertions": [
265
+ "the fill is clamped to 100%",
266
+ "the table and the row's own value label both still read 150"
267
+ ]
268
+ },
269
+ {
270
+ "id": "long-tail",
271
+ "title": "A category too small to see",
272
+ "pithy": "<Bars label=\"'Long tail'\" bars=\"[{label:'Bulk',value:100000},{label:'Speck',value:1}]\" />",
273
+ "assertions": [
274
+ "the tiny row is widened to the minimum visible width rather than vanishing",
275
+ "the table carries its exact value of 1"
276
+ ]
277
+ },
278
+ {
279
+ "id": "zero-scale",
280
+ "title": "Named categories that all hold zero",
281
+ "pithy": "<Bars label=\"'Traffic by source'\" bars=\"allZero\" />",
282
+ "assertions": [
283
+ "data-status='ready', NOT 'empty' — a bar row labels itself, so the chart still says something",
284
+ "every row is drawn at 0% and every row is still a <th> in the table",
285
+ "no NaN or Infinity anywhere in the markup"
286
+ ]
287
+ },
288
+ {
289
+ "id": "no-rows",
290
+ "title": "No rows at all",
291
+ "pithy": "<Bars label=\"'Traffic by source'\" bars=\"[]\" />",
292
+ "assertions": [
293
+ "data-status='empty'",
294
+ "graphic and table both hidden, role='status' region reads 'No data to display'"
295
+ ]
296
+ },
297
+ {
298
+ "id": "loading",
299
+ "title": "Loading",
300
+ "pithy": "<Bars label=\"'Traffic by source'\" bars=\"sources\" loading=\"true\" />",
301
+ "assertions": [
302
+ "aria-busy='true' on .ui-chart-body, and absent from the root",
303
+ "graphic and table both hidden",
304
+ "role='status' region reads 'Loading chart…'"
305
+ ]
306
+ }
307
+ ],
308
+ "designGuidance": {
309
+ "surface": "No background of its own — the rows sit on whatever surface hosts them. Each track uses --color-surface-variant so the unfilled remainder reads as a groove the bar runs in rather than as empty page.",
310
+ "depth": "Flat. No shadow on the track or the fill: a bar's only job is to encode a length, and a raised edge adds a pixel or two of apparent length that is not data.",
311
+ "borders": "None in normal rendering — the track's own fill is the boundary. In forced-colors the track gains a CanvasText border and both the fill and the track opt out of colour substitution, because the colour is the data.",
312
+ "interactivity": "None. There is no hover treatment and nothing is focusable: every row already states its category and its value permanently, so there is no detail left for an interaction to reveal — and anything it did reveal would be unreachable by keyboard and touch. Contrast this with StackedBar and Donut, where hover pairs a mark with its key because the mark alone cannot be named.",
313
+ "bestPractices": "Use it to compare categories, never to divide a whole — reach for StackedBar or Donut for that. Sort rows by value unless the order itself means something (a timeline, a fixed lifecycle): a bar chart in arbitrary order makes the reader do the ranking the chart exists to do. Pass `max` whenever two charts sit near each other, and whenever the scale should stay still as data changes — without it every chart re-scales on every poll and a growing value can look flat. Keep to a dozen or so rows; past that the chart becomes a list and a table serves better. Leave colour unset unless a row has a genuine semantic — identity encoding matters much less here than in a donut, because the names are already on screen. Always give a specific `label`: it is the caption a screen-reader user hears. Prefer an explicit `emptyMessage` that says what is missing ('No traffic recorded yet') over the generic default."
314
+ }
315
+ }