@ponchia/ui 0.4.1 → 0.6.0

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.
Files changed (153) hide show
  1. package/CHANGELOG.md +552 -8
  2. package/MIGRATIONS.json +106 -0
  3. package/README.md +34 -8
  4. package/annotations/index.d.ts +402 -0
  5. package/annotations/index.d.ts.map +1 -0
  6. package/annotations/index.js +792 -0
  7. package/behaviors/carousel.js +198 -0
  8. package/behaviors/combobox.js +226 -0
  9. package/behaviors/command.js +190 -0
  10. package/behaviors/connectors.js +95 -0
  11. package/behaviors/crosshair.js +57 -0
  12. package/behaviors/dialog.js +74 -0
  13. package/behaviors/disclosure.js +26 -0
  14. package/behaviors/dismissible.js +25 -0
  15. package/behaviors/forms.js +186 -0
  16. package/behaviors/glyph.js +108 -0
  17. package/behaviors/index.d.ts +79 -0
  18. package/behaviors/index.js +18 -1409
  19. package/behaviors/internal.js +97 -0
  20. package/behaviors/legend.js +67 -0
  21. package/behaviors/menu.js +47 -0
  22. package/behaviors/popover.js +179 -0
  23. package/behaviors/spotlight.js +52 -0
  24. package/behaviors/table.js +136 -0
  25. package/behaviors/tabs.js +103 -0
  26. package/behaviors/theme.js +84 -0
  27. package/behaviors/toast.js +164 -0
  28. package/classes/classes.json +1857 -0
  29. package/classes/index.d.ts +306 -13
  30. package/classes/index.js +339 -12
  31. package/classes/vscode.css-custom-data.json +12 -0
  32. package/connectors/index.d.ts +191 -0
  33. package/connectors/index.d.ts.map +1 -0
  34. package/connectors/index.js +275 -0
  35. package/css/analytical.css +21 -0
  36. package/css/annotations.css +292 -0
  37. package/css/app.css +43 -13
  38. package/css/base.css +15 -10
  39. package/css/command.css +97 -0
  40. package/css/connectors.css +110 -0
  41. package/css/content.css +7 -1
  42. package/css/crosshair.css +100 -0
  43. package/css/dataviz.css +5 -1
  44. package/css/disclosure.css +38 -6
  45. package/css/dots.css +57 -0
  46. package/css/feedback.css +111 -2
  47. package/css/fonts.css +11 -7
  48. package/css/forms.css +42 -1
  49. package/css/generated.css +117 -0
  50. package/css/legend.css +272 -0
  51. package/css/marks.css +174 -0
  52. package/css/motion.css +24 -44
  53. package/css/navigation.css +7 -0
  54. package/css/overlay.css +31 -1
  55. package/css/primitives.css +109 -5
  56. package/css/report.css +39 -81
  57. package/css/selection.css +46 -0
  58. package/css/site.css +16 -2
  59. package/css/sources.css +221 -0
  60. package/css/spotlight.css +104 -0
  61. package/css/state.css +121 -0
  62. package/css/tokens.css +60 -37
  63. package/css/workbench.css +83 -0
  64. package/dist/bronto.css +1 -1
  65. package/dist/css/analytical.css +1 -0
  66. package/dist/css/annotations.css +1 -0
  67. package/dist/css/app.css +1 -1
  68. package/dist/css/base.css +1 -1
  69. package/dist/css/command.css +1 -0
  70. package/dist/css/connectors.css +1 -0
  71. package/dist/css/content.css +1 -1
  72. package/dist/css/crosshair.css +1 -0
  73. package/dist/css/disclosure.css +1 -1
  74. package/dist/css/dots.css +1 -1
  75. package/dist/css/feedback.css +1 -1
  76. package/dist/css/fonts.css +1 -1
  77. package/dist/css/forms.css +1 -1
  78. package/dist/css/generated.css +1 -0
  79. package/dist/css/legend.css +1 -0
  80. package/dist/css/marks.css +1 -0
  81. package/dist/css/motion.css +1 -1
  82. package/dist/css/navigation.css +1 -1
  83. package/dist/css/overlay.css +1 -1
  84. package/dist/css/primitives.css +1 -1
  85. package/dist/css/report.css +1 -1
  86. package/dist/css/selection.css +1 -0
  87. package/dist/css/site.css +1 -1
  88. package/dist/css/sources.css +1 -0
  89. package/dist/css/spotlight.css +1 -0
  90. package/dist/css/state.css +1 -0
  91. package/dist/css/tokens.css +1 -1
  92. package/dist/css/workbench.css +1 -0
  93. package/docs/adr/0003-theme-model.md +7 -4
  94. package/docs/annotations.md +425 -0
  95. package/docs/architecture.md +246 -0
  96. package/docs/command.md +95 -0
  97. package/docs/connectors.md +91 -0
  98. package/docs/contrast.md +116 -92
  99. package/docs/crosshair.md +63 -0
  100. package/docs/d2.md +195 -0
  101. package/docs/generated.md +91 -0
  102. package/docs/legends.md +184 -0
  103. package/docs/marks.md +93 -0
  104. package/docs/mermaid.md +152 -0
  105. package/docs/reference.md +385 -23
  106. package/docs/reporting.md +436 -63
  107. package/docs/selection.md +40 -0
  108. package/docs/sources.md +137 -0
  109. package/docs/spotlight.md +78 -0
  110. package/docs/stability.md +24 -2
  111. package/docs/state.md +85 -0
  112. package/docs/usage.md +123 -4
  113. package/docs/vega.md +225 -0
  114. package/docs/workbench.md +78 -0
  115. package/fonts/doto-400.woff2 +0 -0
  116. package/fonts/doto-500.woff2 +0 -0
  117. package/fonts/doto-600.woff2 +0 -0
  118. package/fonts/doto-700.woff2 +0 -0
  119. package/fonts/doto-800.woff2 +0 -0
  120. package/fonts/doto-900.woff2 +0 -0
  121. package/glyphs/glyphs.js +6 -4
  122. package/llms.txt +362 -14
  123. package/package.json +115 -12
  124. package/qwik/index.d.ts +42 -54
  125. package/qwik/index.d.ts.map +1 -0
  126. package/qwik/index.js +75 -3
  127. package/react/index.d.ts +39 -56
  128. package/react/index.d.ts.map +1 -0
  129. package/react/index.js +67 -3
  130. package/solid/index.d.ts +64 -56
  131. package/solid/index.d.ts.map +1 -0
  132. package/solid/index.js +70 -3
  133. package/tokens/d2.d.ts +38 -0
  134. package/tokens/d2.js +71 -0
  135. package/tokens/d2.json +43 -0
  136. package/tokens/index.d.ts +5 -5
  137. package/tokens/index.js +23 -5
  138. package/tokens/index.json +9 -0
  139. package/tokens/mermaid.d.ts +23 -0
  140. package/tokens/mermaid.js +181 -0
  141. package/tokens/mermaid.json +163 -0
  142. package/tokens/resolved.json +45 -1
  143. package/tokens/skins.js +3 -2
  144. package/tokens/tokens.dtcg.json +26 -0
  145. package/tokens/vega.d.ts +34 -0
  146. package/tokens/vega.js +155 -0
  147. package/tokens/vega.json +179 -0
  148. package/fonts/doto-400.ttf +0 -0
  149. package/fonts/doto-500.ttf +0 -0
  150. package/fonts/doto-600.ttf +0 -0
  151. package/fonts/doto-700.ttf +0 -0
  152. package/fonts/doto-800.ttf +0 -0
  153. package/fonts/doto-900.ttf +0 -0
@@ -0,0 +1,425 @@
1
+ # SVG annotations
2
+
3
+ `@ponchia/ui/css/annotations.css` is an opt-in SVG annotation layer for charts,
4
+ reports, and analytical figures. It follows the same grammar as
5
+ d3-annotation: a **subject** marks the thing being discussed, a **connector**
6
+ points away from it, and a **note** carries the visible explanation.
7
+
8
+ ```css
9
+ @import '@ponchia/ui';
10
+ @import '@ponchia/ui/css/annotations.css';
11
+ ```
12
+
13
+ Use it with any SVG renderer. Bronto supplies classes and tiny geometry helpers;
14
+ it does not own chart scales, mutate the DOM, or provide draggable edit mode.
15
+
16
+ ```js
17
+ import {
18
+ annotationParts,
19
+ annotationTransform,
20
+ axisThresholdPath,
21
+ bracketSubjectPath,
22
+ circleSubjectPath,
23
+ connectorLine,
24
+ evidenceMarkerPath,
25
+ notePlacement,
26
+ noteTransform,
27
+ } from '@ponchia/ui/annotations';
28
+
29
+ const transform = annotationTransform({ x: 180, y: 72 });
30
+ const subject = circleSubjectPath({ radius: 18 });
31
+ const connector = connectorLine({
32
+ dx: 88,
33
+ dy: -42,
34
+ subject: { type: 'circle', radius: 18, radiusPadding: 4 },
35
+ });
36
+ const parts = annotationParts({
37
+ x: 180,
38
+ y: 72,
39
+ dx: 88,
40
+ dy: -42,
41
+ subject: { type: 'circle', radius: 18, radiusPadding: 4 },
42
+ });
43
+ const note = notePlacement({
44
+ x: 180,
45
+ y: 72,
46
+ width: 96,
47
+ height: 44,
48
+ bounds: { x: 0, y: 0, width: 360, height: 180 },
49
+ preferred: 'right',
50
+ });
51
+ ```
52
+
53
+ ## Markup model
54
+
55
+ Author annotation groups at the subject anchor. Use `dx` / `dy` as the note
56
+ offset, matching d3-annotation's mental model.
57
+
58
+ ```html
59
+ <svg viewBox="0 0 360 180" role="img" aria-labelledby="chart-title chart-desc">
60
+ <title id="chart-title">Annotated delivery chart</title>
61
+ <desc id="chart-desc">A callout marks the delivery peak.</desc>
62
+
63
+ <g
64
+ class="ui-annotation ui-annotation--circle ui-annotation--accent"
65
+ transform="translate(180, 72)"
66
+ >
67
+ <path class="ui-annotation__subject" d="M0,-18A18,18 0 1 1 0,18A18,18 0 1 1 0,-18Z" />
68
+ <path class="ui-annotation__connector" d="M15.556,-7.424L88,-42" />
69
+ <g class="ui-annotation__note" transform="translate(88, -42)">
70
+ <path class="ui-annotation__note-line" d="M0,0H76" />
71
+ <text class="ui-annotation__title" y="-8">Peak</text>
72
+ <text class="ui-annotation__label" y="12">Delivery spike</text>
73
+ </g>
74
+ </g>
75
+ </svg>
76
+ ```
77
+
78
+ The visible note text should also be represented in the figure caption,
79
+ `<desc>`, fallback table, or surrounding prose when the figure is complex.
80
+
81
+ Match the accessibility treatment to the annotation's job, in both directions:
82
+ a **data** annotation (a peak, a threshold, a watched region — it says something
83
+ the reader needs) must stay readable, so represent its text as above and do
84
+ **not** `aria-hidden` it; a purely **decorative** mark (a cover flourish, a
85
+ margin doodle that carries no data) should be `aria-hidden="true"` and
86
+ `focusable="false"` on the whole SVG so a screen reader skips the decoration.
87
+ The one thing to avoid is the middle: a meaningful callout hidden from assistive
88
+ tech, or decoration announced as if it were data.
89
+
90
+ ## Variants and motion
91
+
92
+ Use one variant class per annotation group. Variants describe the visual
93
+ grammar, not data semantics:
94
+
95
+ | Variant | Use |
96
+ | --- | --- |
97
+ | `ui-annotation--label` | Direct label with no connector. |
98
+ | `ui-annotation--callout` | Plain point-to-note callout. |
99
+ | `ui-annotation--elbow` | Dogleg connector around dense marks. |
100
+ | `ui-annotation--curve` | Softer connector for paths or flows. |
101
+ | `ui-annotation--circle` | Circular subject around a point or local cluster. |
102
+ | `ui-annotation--rect` | Rectangular subject around a bar, block, or region. |
103
+ | `ui-annotation--threshold` | Horizontal or vertical limit rule. |
104
+ | `ui-annotation--badge` | Compact numbered or categorical mark. |
105
+ | `ui-annotation--bracket` | Range span on one axis. |
106
+ | `ui-annotation--band` | Interval, confidence band, or risk window. |
107
+ | `ui-annotation--slope` | Trend or slope segment. |
108
+ | `ui-annotation--compare` | Before/after or A/B grouping. |
109
+ | `ui-annotation--cluster` | Several nearby outliers. |
110
+ | `ui-annotation--axis` | Axis milestone or reference tick. |
111
+ | `ui-annotation--timeline` | Event pin on a timeline. |
112
+ | `ui-annotation--evidence` | Proof, source, or evidence marker. |
113
+
114
+ Tones are `accent`, `muted`, `success`, `warning`, `danger`, and `info`.
115
+ Status tones (`success`, `warning`, `danger`, `info`) are only for annotations
116
+ that carry that status meaning.
117
+
118
+ Motion is opt-in and respects `prefers-reduced-motion`:
119
+
120
+ | Motion class | Effect |
121
+ | --- | --- |
122
+ | `ui-annotation--draw` | Connectors draw in once; subjects reveal without losing dashed variant styling. |
123
+ | `ui-annotation--reveal` | Note fades into place. |
124
+ | `ui-annotation--pulse` | Subject or badge pulses gently. |
125
+ | `ui-annotation--focus` | Static emphasis with a stronger subject stroke. |
126
+
127
+ The class recipe mirrors this surface:
128
+
129
+ ```js
130
+ ui.annotation({ variant: 'bracket', tone: 'info', motion: 'draw' });
131
+ // "ui-annotation ui-annotation--bracket ui-annotation--info ui-annotation--draw"
132
+ ```
133
+
134
+ ## Geometry helpers
135
+
136
+ The helper module returns SVG strings only. It does not know about scales,
137
+ selections, DOM nodes, or frameworks.
138
+
139
+ | Helper | Returns |
140
+ | --- | --- |
141
+ | `annotationTransform({ x, y })` | Group transform for the subject anchor. |
142
+ | `noteTransform({ dx, dy, align, valign, width, height })` | Note transform from the subject anchor, with optional alignment. |
143
+ | `notePlacement({ x, y, width, height, bounds, preferred, inset })` | Bounded note offset, alignment and transform for one annotation. `inset` reserves an extra margin (e.g. the title stroke-halo, ~3) so a placement that "fits" doesn't clip. |
144
+ | `declutterLabels(items, { gap, min, max })` | Adjusted centres for `items` (`[{ pos, size }]`) so labels don't overlap along one axis (order-preserving). |
145
+ | `directLabels(items, { axis, cross, gap, min, max, shape })` | Decluttered label points **and** a leader path per item: `[{ x, y, anchor, key, d }]`. |
146
+ | `circleSubjectPath({ radius })` | Circle subject path. |
147
+ | `rectSubjectPath({ x, y, width, height, padding })` | Rect subject path. |
148
+ | `thresholdPath({ x1, y1, x2, y2 })` | Arbitrary threshold/rule path. |
149
+ | `axisThresholdPath({ orientation, value, start, end })` | Horizontal or vertical axis-aligned threshold. |
150
+ | `bracketSubjectPath({ x1, y1, x2, y2, depth })` | Dogleg bracket path. |
151
+ | `bandSubjectPath({ x, y, width, height, padding })` | Band or interval path. |
152
+ | `slopeSubjectPath({ x1, y1, x2, y2 })` | Trend segment path. |
153
+ | `comparisonBracePath({ x1, y1, x2, y2, depth })` | Comparison brace path. |
154
+ | `outlierClusterPath({ points, radius })` | Repeated circle subjects for a cluster. |
155
+ | `timelineEventPath({ size, direction })` | Event pin marker path. |
156
+ | `evidenceMarkerPath({ x, y, width, height, padding })` | Centered square/rect evidence marker path. |
157
+ | `connectorLine({ dx, dy, subject })` | Straight connector, trimmed against circle/rect subjects. |
158
+ | `connectorElbow({ dx, dy, subject, mid })` | Right-angle dogleg connector (H/V/H); `mid` (0..1, default 0.5) sets the turn position along the dominant axis. |
159
+ | `connectorCurve({ dx, dy, subject })` | Deterministic cubic connector. |
160
+ | `connectorEndDot({ x, y, radius })` | Dot marker path. |
161
+ | `connectorEndArrow({ x1, y1, x2, y2, size, spread })` | Arrow marker path. `x1,y1`→`x2,y2` sets the direction (the head sits at `x2,y2`); `spread` is the half-angle (default 0.32 ≈ a crisp 37° head). |
162
+ | `annotationParts(options)` | Convenience object with `transform`, `subject`, `connector`, and `note`. |
163
+
164
+ `declutterLabels` is a deliberately small, deterministic **1-D** declutter for
165
+ direct labels or axis ticks — sort, push overlaps apart by `size + gap`, slide
166
+ to fit under `max`. It is **not** a general 2-D collision solver: if more labels
167
+ are requested than the axis can hold, the overflow is yours to resolve (fewer
168
+ labels, a longer axis, or rotation). It returns numbers; you own the scale and
169
+ the DOM.
170
+
171
+ `directLabels` is the **direct-labeling** companion: it declutters labels along
172
+ one axis _and_ draws the leader from each true anchor to its placed label,
173
+ reusing the connector kernel. Each `items[i]` is `{ anchor: {x, y}, size, key? }`
174
+ in figure coordinates; labels declutter along `axis` (`'y'` = a vertical column,
175
+ the default) and sit at the fixed `cross` coordinate. It returns, in input
176
+ order, the placed label point `{ x, y }`, the echoed `anchor`/`key`, and the
177
+ leader path `d` (`shape`: `straight` · `elbow` · `curve`). Like everything here
178
+ it owns no scales, no DOM, and no 2-D placement — map data → figure coordinates
179
+ first, then drop each `d` into a `<path class="ui-annotation__connector">` and
180
+ position the label at `{ x, y }`:
181
+
182
+ ```js
183
+ import { directLabels } from '@ponchia/ui/annotations';
184
+
185
+ // anchors are data points already projected into the figure's SVG coords
186
+ const labels = directLabels(
187
+ points.map((p) => ({ anchor: p, size: 18, key: p.id })),
188
+ { axis: 'y', cross: width - 8, gap: 6, min: 12, max: height - 12 },
189
+ );
190
+ // labels[i] → { x, y, anchor, key, d }
191
+ ```
192
+
193
+ All numeric inputs must be finite. Negative radius, width, height, padding, and
194
+ marker size throw `RangeError`; non-finite values throw `TypeError`. Path
195
+ numbers are rounded to three decimals with trailing zeros removed so snapshots
196
+ and unit tests stay stable.
197
+
198
+ `notePlacement()` is intentionally small: it places one note inside explicit SVG
199
+ bounds using a preferred side (`right`, `left`, `top`, or `bottom`) and falls
200
+ back to another side or a clamped note transform. It is not a collision solver
201
+ for a whole chart. For dense annotation sets, pre-compute positions or author a
202
+ mobile-specific SVG.
203
+
204
+ ### Using the helpers in a static, no-JS report
205
+
206
+ The [report layer](./reporting.md) is static and ships no behavior JS, but these
207
+ helpers are JS — so in a hand- or LLM-authored report you can't call them at
208
+ render time. Bridge the gap by running them **once, at author/build time**, and
209
+ pasting the returned strings straight into the SVG. The output is deterministic
210
+ (path numbers are rounded to three decimals), so the strings are stable and
211
+ diff-friendly.
212
+
213
+ ```js
214
+ // author-time only — copy the logged strings into the static HTML
215
+ import { circleSubjectPath, connectorLine } from '@ponchia/ui/annotations';
216
+
217
+ circleSubjectPath({ radius: 15 });
218
+ // "M0,-15A15,15 0 1 1 0,15A15,15 0 1 1 0,-15Z"
219
+ connectorLine({ dx: 78, dy: -38, subject: { type: 'circle', radius: 15, radiusPadding: 0 } });
220
+ // "M13.485,-6.57L78,-38"
221
+ ```
222
+
223
+ The static markup then carries only the resolved strings — no runtime, no
224
+ import:
225
+
226
+ ```html
227
+ <g class="ui-annotation ui-annotation--circle ui-annotation--accent" transform="translate(34, 58)">
228
+ <path class="ui-annotation__subject" d="M0,-15A15,15 0 1 1 0,15A15,15 0 1 1 0,-15Z" />
229
+ <path class="ui-annotation__connector" d="M13.485,-6.57L78,-38" />
230
+ <g class="ui-annotation__note" transform="translate(78, -38)">…</g>
231
+ </g>
232
+ ```
233
+
234
+ ## Using annotations off-chart
235
+
236
+ Annotations are not only for charts. Two report uses worth calling out:
237
+
238
+ - **A decorative margin mark.** A small `ui-annotation` group — a circled point
239
+ with a short note — adds a hand-annotated feel to a report cover or section
240
+ opener. It carries no data, so mark the whole SVG `aria-hidden="true"` and
241
+ `focusable="false"`: a screen reader should not read decoration.
242
+
243
+ ```html
244
+ <svg width="440" height="92" viewBox="0 0 440 92" aria-hidden="true" focusable="false">
245
+ <g class="ui-annotation ui-annotation--circle ui-annotation--accent" transform="translate(34, 58)">
246
+ <path class="ui-annotation__subject" d="M0,-15A15,15 0 1 1 0,15A15,15 0 1 1 0,-15Z" />
247
+ <circle r="3.5" fill="var(--accent)" />
248
+ <path class="ui-annotation__connector" d="M13.485,-6.57L78,-38" />
249
+ <g class="ui-annotation__note" transform="translate(78, -38)">
250
+ <path class="ui-annotation__note-line" d="M0,0H188" />
251
+ <text class="ui-annotation__title" y="-8">You are here</text>
252
+ <text class="ui-annotation__label" y="12">a short, terse label</text>
253
+ </g>
254
+ </g>
255
+ </svg>
256
+ ```
257
+
258
+ - **Bracketing a passage of prose belongs to marks, not here.** To bracket a
259
+ sentence or paragraph in running text, use `.ui-bracket-note` from the
260
+ [marks layer](./marks.md) — it is the prose analogue of
261
+ `ui-annotation--bracket`. SVG annotations are for SVG figures.
262
+
263
+ ## Sizing: the user-unit trap
264
+
265
+ Annotation text (`__title`, `__label`) is sized in **SVG user units**, so it
266
+ scales with the figure. A 360-unit-wide chart stretched across a full report
267
+ column is scaled roughly 2.5–3×, and the callout text scales with it — long
268
+ notes turn huge and overflow the `viewBox` (SVG text is clipped, not wrapped).
269
+ Two rules keep callouts readable:
270
+
271
+ - **Keep note text terse** — a title and a few words, like the recipe examples
272
+ (`Peak`, `Limit`, `80 kB cap`). Push the full sentence into the figure caption,
273
+ the `<desc>`, or the fallback table.
274
+ - **Constrain the figure width** so the user-unit → pixel scale stays near
275
+ 1–1.5×: set a `max-inline-size` on the SVG instead of letting it stretch to the
276
+ whole column, or author the `viewBox` at roughly the rendered pixel size.
277
+
278
+ ## Density and responsive rules
279
+
280
+ Annotations are strongest when they explain the few things a reader would miss.
281
+ As a default, keep a single chart to three to five visible callouts. Use direct
282
+ labels for stable context, one accent callout for the main insight, and status
283
+ tones only for genuine status.
284
+
285
+ Dense SVGs should not shrink until the notes become unreadable. Use one of
286
+ these patterns:
287
+
288
+ - Keep the chart wide in a horizontally scrollable figure and provide fallback
289
+ table text.
290
+ - Author a simpler mobile SVG with fewer annotations.
291
+ - Move low-priority annotation text into the caption or fallback table on small
292
+ screens.
293
+
294
+ ## Recipes
295
+
296
+ ### Label
297
+
298
+ Use `ui-annotation--label` for direct labels when the subject is already clear.
299
+
300
+ ```html
301
+ <g class="ui-annotation ui-annotation--label ui-annotation--muted" transform="translate(112, 48)">
302
+ <g class="ui-annotation__note">
303
+ <text class="ui-annotation__title">Baseline</text>
304
+ <text class="ui-annotation__label" y="16">Previous quarter</text>
305
+ </g>
306
+ </g>
307
+ ```
308
+
309
+ ### Circle callout
310
+
311
+ Use a circle subject when the referenced point or local cluster matters.
312
+
313
+ ```html
314
+ <g class="ui-annotation ui-annotation--circle ui-annotation--accent" transform="translate(180, 72)">
315
+ <path class="ui-annotation__subject" d="M0,-18A18,18 0 1 1 0,18A18,18 0 1 1 0,-18Z" />
316
+ <path class="ui-annotation__connector" d="M15.556,-7.424L88,-42" />
317
+ <g class="ui-annotation__note" transform="translate(88, -42)">
318
+ <path class="ui-annotation__note-line" d="M0,0H84" />
319
+ <text class="ui-annotation__title" y="-8">Spike</text>
320
+ <text class="ui-annotation__label" y="12">Investigate change</text>
321
+ </g>
322
+ </g>
323
+ ```
324
+
325
+ ### Rect callout
326
+
327
+ Use a rect subject for a band, bar, table region, or evidence block inside an
328
+ SVG figure.
329
+
330
+ ```html
331
+ <g class="ui-annotation ui-annotation--rect ui-annotation--warning" transform="translate(206, 92)">
332
+ <path class="ui-annotation__subject" d="M-34,-16H34V16H-34Z" />
333
+ <path class="ui-annotation__connector" d="M34,-16L72,-46" />
334
+ <g class="ui-annotation__note" transform="translate(72, -46)">
335
+ <path class="ui-annotation__note-line" d="M0,0H96" />
336
+ <text class="ui-annotation__title" y="-8">Watch</text>
337
+ <text class="ui-annotation__label" y="12">Lower confidence</text>
338
+ </g>
339
+ </g>
340
+ ```
341
+
342
+ ### Threshold
343
+
344
+ Use `ui-annotation--threshold` when a horizontal or vertical rule is the
345
+ subject.
346
+
347
+ ```html
348
+ <g class="ui-annotation ui-annotation--threshold ui-annotation--danger" transform="translate(0, 96)">
349
+ <path class="ui-annotation__subject" d="M36,0L324,0" />
350
+ <path class="ui-annotation__connector" d="M240,0L282,-32" />
351
+ <g class="ui-annotation__note" transform="translate(282, -32)">
352
+ <text class="ui-annotation__title">Limit</text>
353
+ <text class="ui-annotation__label" y="16">Do not exceed</text>
354
+ </g>
355
+ </g>
356
+ ```
357
+
358
+ ### Badge
359
+
360
+ Use badges for compact numbered or categorical markers. Do not rely on the badge
361
+ color alone; pair it with visible text, a caption, or a table row.
362
+
363
+ ```html
364
+ <g class="ui-annotation ui-annotation--badge ui-annotation--info" transform="translate(72, 84)">
365
+ <circle class="ui-annotation__badge" r="12" />
366
+ <text class="ui-annotation__title" text-anchor="middle" dominant-baseline="central">1</text>
367
+ </g>
368
+ ```
369
+
370
+ ## Chart figure recipe
371
+
372
+ Inside a report, keep the existing chart structure: caption, legend or direct
373
+ labels, annotated SVG, and fallback data. A useful annotated figure should show
374
+ more than one annotation family when the story needs it: direct labels for
375
+ stable references, threshold annotations for limits, circle/rect subjects for
376
+ specific data, and badge markers for compact index points.
377
+
378
+ ```html
379
+ <figure class="ui-report__figure ui-print-exact" role="group" aria-labelledby="annotated-chart">
380
+ <figcaption id="annotated-chart" class="ui-report__caption">
381
+ Fig 2 - Weekly throughput, annotated at the peak
382
+ </figcaption>
383
+ <svg viewBox="0 0 360 160" role="img" aria-labelledby="throughput-title throughput-desc">
384
+ <title id="throughput-title">Weekly throughput with a peak callout</title>
385
+ <desc id="throughput-desc">Annotations mark the baseline, limit and highest research week.</desc>
386
+ <line x1="36" y1="112" x2="324" y2="112" stroke="var(--line)" />
387
+ <rect x="88" y="42" width="72" height="70" fill="var(--chart-1)" />
388
+ <rect x="188" y="70" width="72" height="42" fill="var(--chart-2)" />
389
+ <g class="ui-annotation ui-annotation--label ui-annotation--muted" transform="translate(36, 132)">
390
+ <g class="ui-annotation__note">
391
+ <text class="ui-annotation__title">Baseline</text>
392
+ <text class="ui-annotation__label" y="16">Previous quarter</text>
393
+ </g>
394
+ </g>
395
+ <g class="ui-annotation ui-annotation--threshold ui-annotation--danger" transform="translate(0, 66)">
396
+ <path class="ui-annotation__subject" d="M36,0L324,0" />
397
+ <path class="ui-annotation__connector" d="M272,0L304,-28" />
398
+ <g class="ui-annotation__note" transform="translate(234, -52)">
399
+ <text class="ui-annotation__title">Limit</text>
400
+ <text class="ui-annotation__label" y="16">Watch capacity</text>
401
+ </g>
402
+ </g>
403
+ <g class="ui-annotation ui-annotation--circle ui-annotation--accent" transform="translate(124, 42)">
404
+ <path class="ui-annotation__subject" d="M0,-18A18,18 0 1 1 0,18A18,18 0 1 1 0,-18Z" />
405
+ <path class="ui-annotation__connector" d="M16,-8L76,-36" />
406
+ <g class="ui-annotation__note" transform="translate(76, -36)">
407
+ <path class="ui-annotation__note-line" d="M0,0H80" />
408
+ <text class="ui-annotation__title" y="-8">Peak</text>
409
+ <text class="ui-annotation__label" y="12">Research high</text>
410
+ </g>
411
+ </g>
412
+ </svg>
413
+ <div class="ui-table-wrap">
414
+ <table class="ui-table ui-table--dense">
415
+ <caption>Annotated chart source data</caption>
416
+ <thead><tr><th>Week</th><th class="is-num">Hours</th></tr></thead>
417
+ <tbody><tr><td>Week 4</td><td class="is-num">18</td></tr></tbody>
418
+ </table>
419
+ </div>
420
+ </figure>
421
+ ```
422
+
423
+ Status tones (`success`, `warning`, `danger`, `info`) are only for annotations
424
+ that carry that status meaning. Use `accent` for the primary insight and
425
+ `muted` for secondary callouts.
@@ -0,0 +1,246 @@
1
+ # Architecture & Decisions
2
+
3
+ Status: accepted · 2026-05-15 · applies from v0.2.0
4
+
5
+ > **Separate ADRs.** Larger, self-contained decisions live under
6
+ > [`docs/adr/`](./adr/):
7
+ >
8
+ > - [ADR-0001 — Color system: governed evolution beyond monochrome](./adr/0001-color-system.md)
9
+ > (accepted; steps 1–8 implemented in 0.4.0) — the five-tier color
10
+ > constitution, the `check:color-policy`/`check:skins`/`check:charts`
11
+ > gates, opt-in colorways, data-viz, APCA advisory reporting, and the
12
+ > OKLCH core accent ramp.
13
+
14
+ ## Context
15
+
16
+ `@ponchia/ui` is the shared design layer for several projects on
17
+ different stacks: Astro, SvelteKit, and an
18
+ open-ended set of future apps (React, Solid, Qwik, plain HTML, server-rendered
19
+ templates). The question driving this document: is plain CSS the right
20
+ universal substrate, or should the framework ship per-framework components?
21
+
22
+ ## Decision
23
+
24
+ **Plain, class-based CSS is the canonical and only universal layer.** It is
25
+ the single artifact every target consumes natively with zero adapter. A
26
+ per-framework component library would make every non-chosen framework a
27
+ second-class citizen and multiply the maintenance surface for the same button.
28
+
29
+ The known gaps of a pure-CSS framework — contract visibility, a home for
30
+ unavoidable JS, and distribution — are addressed as **thin, optional layers
31
+ on top of the CSS, none of which require a framework commitment**:
32
+
33
+ ```
34
+ @ponchia/ui
35
+ ├── css/ canonical universal layer (the framework) [required]
36
+ ├── tokens/ design tokens as JS/JSON, for JS/canvas/tooling [optional]
37
+ ├── classes/ typed class-name contract + recipe builders [optional]
38
+ ├── behaviors/ vanilla, SSR-safe JS for stateful widgets [optional]
39
+ ├── connectors/ pure SVG leader-line geometry kernel (no DOM) [optional]
40
+ ├── annotations/ pure SVG callout geometry (builds on connectors) [optional]
41
+ ├── glyphs/ dot-matrix glyph registry/renderers [optional]
42
+ ├── react/ thin React hooks over behaviors [optional peer]
43
+ ├── solid/ thin Solid primitives over behaviors [optional peer]
44
+ └── qwik/ thin Qwik hooks over behaviors (useVisibleTask$) [optional peer]
45
+ ```
46
+
47
+ ### Consequences of each layer
48
+
49
+ - **css/** — wrapped in a single `@layer bronto`. Any un-layered CSS in a
50
+ consumer wins the cascade without specificity wars or `!important`. This is
51
+ a deliberate behavioural change vs. unlayered v0.1.0; consumers pin a tag
52
+ so it ships only on the next version bump.
53
+ - **Fonts** — `@font-face` moved out of `tokens.css` into `css/fonts.css`
54
+ with URLs relative to the package (`../fonts/*`), so font hosting is
55
+ decoupled from the token layer and resolves through bundlers or static
56
+ serving without an absolute `/fonts` assumption.
57
+ - **tokens/** — `index.js` (`cssVars`) is the single source of truth for token
58
+ values. The four `:root` palette blocks of `css/tokens.css` are **generated**
59
+ from it (`scripts/gen-tokens-css.mjs`), as are the JSON artifacts (`index.json`,
60
+ `tokens.dtcg.json`, `resolved.json`). So the dark palette is authored once,
61
+ not in three places (the two CSS dark blocks are now identical by
62
+ construction), resolving the duplication ADR-0003 flagged. The CSS-only
63
+ presets (density / contrast / OLED) stay hand-authored below a marker and are
64
+ preserved across regeneration. `scripts/check-fresh.mjs` fails CI if
65
+ `css/tokens.css` drifts from the model.
66
+ - **classes/** — `cls` is the flat registry; recipes only emit from it;
67
+ `scripts/check-classes.mjs` enforces a bidirectional match with the
68
+ stylesheet's `.ui-*` selectors. The class contract cannot silently rot.
69
+ - **behaviors/** — vanilla, dependency-free, side-effect-free on import,
70
+ SSR-safe. Chosen over Web Components (SSR/hydration friction with Astro
71
+ islands and SvelteKit) and over per-framework packages (maintenance
72
+ multiplier). Revisit Web Components only if stateful widgets accumulate.
73
+ `index.js` is a barrel; each behavior lives in its own module
74
+ (`dialog.js`, `combobox.js`, …) over a shared `internal.js` of DOM helpers,
75
+ so the public import surface is unchanged.
76
+ - **glyphs/** — static bitmap data and SSR-safe render helpers. The
77
+ 256-cell DOM renderers are for display and solid inline icons; the `.ui-icon`
78
+ mask renderer is for dense icon-at-scale use.
79
+ - **react/** / **solid/** / **qwik/** — optional lifecycle adapters over `behaviors/`.
80
+ They do not define markup, own state, or fork behavior logic; they only run
81
+ the vanilla initializers on mount and cleanup on unmount/dispose.
82
+ - **`css/analytical.css` — the analytical roll-up.** This convenience file
83
+ `@import`s exactly **seven** analytical-figure leaves: `annotations`,
84
+ `legend`, `marks`, `connectors`, `spotlight`, `crosshair`, and `selection`.
85
+ The adjacent opt-in leaves — `sources`, `state`, `generated`, `workbench`,
86
+ and `command` — are report/tooling/trust surfaces that are intentionally
87
+ **not** part of the analytical roll-up and must be imported individually.
88
+ Importing `analytical.css` does not pull in any of those five.
89
+ - **Root export (`.`) is CSS-only.** `exports["."]` resolves to the CSS
90
+ bundle (`dist/bronto.css`). It is a CSS side-effect import for CSS-aware
91
+ bundlers (`@import '@ponchia/ui'` in CSS, or a side-effect
92
+ `import '@ponchia/ui'` in Vite/Astro/SvelteKit). There is no runtime JS at
93
+ the package root — Node/runtime JS imports of `.` are not supported. All JS
94
+ entrypoints are explicit subpaths (`/behaviors`, `/classes`, `/tokens`,
95
+ `/glyphs`, `/react`, `/solid`, `/qwik`, `/skins`, `/charts`). This is a
96
+ permanent, intentional contract.
97
+
98
+ ## Repository layout
99
+
100
+ The repo root mixes five kinds of directory that look alike but follow very
101
+ different rules. Two distinctions matter most: several are **path-frozen
102
+ published subpaths** — the directory name _is_ the public import specifier
103
+ (`@ponchia/ui/react` resolves to `./react/`), so they cannot be moved or
104
+ renamed — and several are **generated** and must never be hand-edited (a
105
+ generator overwrites them and a drift gate fails CI).
106
+
107
+ | Path | Kind | Edit here? | Notes |
108
+ | --- | --- | --- | --- |
109
+ | `css/` | source | yes | The framework. Hand-authored `@layer bronto` CSS. (`css/tokens.css` palette blocks and `css/generated.css` are generated — see below.) |
110
+ | `tokens/index.js` | source | yes | The single source of truth for token **values** (`cssVars`). |
111
+ | `classes/index.js`, `behaviors/`, `annotations/`, `connectors/`, `react/`, `solid/`, `qwik/`, `glyphs/`, `shiki/` | source · published-subpath (path-frozen) | yes — but **do not move** | Authored ESM shipped as-is; the dir name is the public import path. The `.d.ts` beside them are generated/drift-checked: `connectors`/`annotations`/`react`/`solid`/`qwik` are emitted from JSDoc by `tsc` (`npm run dts:emit`), `classes`/`tokens`/`glyphs` from the runtime; only `behaviors/index.d.ts` is still hand-maintained (its barrel + destructured-param shape emit poorly), guarded by `check-behaviors`. |
112
+ | `dist/` | generated | no | Build of `css/` (`npm run dist:build`); byte-checked by `check:dist`. |
113
+ | `tokens/index.json`, `tokens/resolved.json`, `tokens/tokens.dtcg.json`, `tokens/charts.json`, `classes/index.d.ts`, `tokens/index.d.ts`, `tokens/{skins,charts}.d.ts`, `glyphs/glyphs.d.ts`, `classes/vscode.css-custom-data.json`, `docs/reference.md` | generated | no | Committed build artifacts; regenerate with `npm run prepack`, never hand-edit. Drift-checked in `npm run check`. |
114
+ | `fonts/` | vendored | — | The Doto webfont (woff2) + its OFL license. |
115
+ | `scripts/` | tooling | yes | `gen-*` regenerate artifacts, `check-*` are the drift/contract gates wired into `npm run check`, plus `build-dist`, `serve`, `size-report`. |
116
+ | `docs/` | source (mostly) | yes | Hand-authored docs + ADRs; the curated subset in `package.json` `files` ships in the tarball. `docs/reference.md` is generated. |
117
+ | `demo/`, `test/`, `examples/` | fixtures | yes | The self-driving demo/showcase, the unit + Playwright e2e suite, and consumer example apps built against the packed tarball. |
118
+ | `.github/`, `*.config.mjs`, `.prettierrc`, `.stylelintrc.json`, `tsconfig.json`, `.editorconfig` | config | yes | CI workflows and tool config. |
119
+ | `package.json`, `llms.txt`, `CHANGELOG.md`, `MIGRATIONS.json`, `README.md`, `CONTRIBUTING.md`, `ROADMAP.md`, `LICENSE` | meta | yes | Manifest, the agent entrypoint, the curated changelog, the rename map, and project docs. |
120
+
121
+ The **path-frozen** dirs are the cost of zero-build, path-stable publishing:
122
+ `files` map 1:1 to published paths and the consumer's own bundler tree-shakes
123
+ the ESM, so there is no `src/` indirection (and no JS bundler — see the
124
+ distribution decision below). **Generated** files are regenerated from their
125
+ source and policed by a drift gate — edit the source, run the generator, commit
126
+ the result.
127
+
128
+ ## Drift control
129
+
130
+ Every data mirror is backed by a check wired into `npm run check`, run by CI
131
+ on every push/PR and again by `release.yml` before publish (see "Release
132
+ gating" below), so a version that fails any invariant never reaches npm.
133
+
134
+ | Invariant | Enforced by |
135
+ | ----------------------------------------------- | ------------------- |
136
+ | exports / import graph / `files` consistent | `check-exports.mjs` |
137
+ | pure generated mirrors fresh — `tokens.css`/`index.json`, `dtcg.json`, `resolved.json`, `classes`/`tokens` `.d.ts`, `reference.md`, vscode data — each byte-equal to its generator (registry: `scripts/lib/artifacts.mjs`) | `check-fresh.mjs` |
138
+ | `classes` `cls` ⇄ `.ui-*` selectors | `check-classes.mjs` |
139
+ | `connectors`/`annotations`/`react`/`solid`/`qwik` `.d.ts` (+ maps) == fresh `tsc` emit of their JSDoc | `check-dts-emit.mjs` |
140
+ | `behaviors/index.d.ts` ⇄ `behaviors/*` exports (the one hand-maintained leaf `.d.ts`) | `check-behaviors.mjs` |
141
+ | legend swatch colours ⊆ `charts.js` · opt-in | `check-legend.mjs` |
142
+ | color tokens tiered · no raw chromatic color in components | `check-color-policy.mjs` |
143
+ | `css/skins.css` ⇄ `tokens/skins.js` · colorways opt-in | `check-skins.mjs` |
144
+ | every shipped colorway accent meets its WCAG floor | `check-contrast.mjs` |
145
+ | `dataviz.css`/`charts.json`/`charts.d.ts` ⇄ `tokens/charts.js` · CVD-distinguishable · opt-in | `check-charts.mjs` |
146
+ | `shiki/nothing.json` valid + on rationed palette | `check-shiki.mjs` |
147
+ | `dist/*.css` == fresh build of `css/` + budget | `check-dist.mjs` |
148
+ | published tarball == intended `files` only | `check-pack.mjs` |
149
+ | published `.d.ts` compile + reject typos | `tsc` (`check:types`) |
150
+ | CSS style/correctness | Stylelint |
151
+ | non-CSS source style | Prettier (`check:format`) |
152
+
153
+ `check-dist` is the most supply-chain-critical row: `dist/bronto.css` is
154
+ the default `exports["."]` consumers actually load, so its byte-equality
155
+ to a fresh build of `css/` is what makes the committed bundle trustworthy.
156
+ The `check-dist` size ceiling (`BUDGET` in `build-dist.mjs`) is calibrated
157
+ to the current bundle with deliberate headroom — it is the consumer-facing
158
+ payload contract, raised only intentionally with a CHANGELOG note.
159
+ `check:types` compiles the published declarations against
160
+ `test/types.test-d.ts`, whose `@ts-expect-error`s would fail to compile
161
+ if the generated literal `cls`/token types stopped rejecting typos —
162
+ so the *value* of the generated `.d.ts` is itself gated, not just their
163
+ freshness (`check-fresh`).
164
+
165
+ ## Release gating
166
+
167
+ `release.yml` (on a pushed `v*` tag) is a five-job DAG, serialized by a
168
+ `concurrency: release-publish` group so two tags can't race the dist-tag
169
+ pointer:
170
+
171
+ - `validate` — read-only: `npm run check` + tag↔version match. `check`
172
+ includes `check:release`; for a prerelease tag the base version's
173
+ CHANGELOG section need only exist (`## Unreleased — x.y.z` is fine) —
174
+ only a stable release must carry a dated heading.
175
+ - `e2e` — Playwright (visual + axe a11y, both themes, cross-engine) in
176
+ the pinned `mcr.microsoft.com/playwright` container.
177
+ - `examples` — `needs: validate`: builds the downstream example
178
+ apps against the **packed tarball**, mirroring CI. Catches a broken
179
+ published surface (exports map / missing file / unresolved subpath)
180
+ that `check:pack`'s file-allowlist inspection cannot — so the release
181
+ path runs the same consumer smoke as merge-to-main.
182
+ - `publish-npm` — `needs: [validate, e2e, examples]`: `npm publish` with
183
+ provenance. Runs in the `npm-publish` **Environment** (required-reviewer
184
+ protection), so after the gates pass the run pauses for a manual approval
185
+ in the Actions UI before anything reaches npm — a guard against an
186
+ accidental tag push publishing. Dist-tag is derived from the tag: stable
187
+ (`v0.4.0`) → `latest`; SemVer prerelease (`v0.4.0-rc.1`, any hyphenated
188
+ identifier) → `next`, so the default `npm i @ponchia/ui` never moves onto
189
+ an unstable build (opt in with `@ponchia/ui@next`).
190
+ - `release-notes` — `needs: publish-npm`: a GitHub Release for visibility
191
+ (transitively gated on a successful publish, hence on the gates above);
192
+ prerelease tags are flagged so they aren't surfaced as "Latest". The Release
193
+ **body is the curated `CHANGELOG.md` section** for the tag
194
+ (`scripts/changelog-section.mjs`), not GitHub's auto-generated PR list — one
195
+ source of truth, surfaced where readers look.
196
+
197
+ Because the documented install path is the npm package, **the npm publish
198
+ is a real gate**: if `validate`, `e2e`, *or* `examples` fails,
199
+ `publish-npm` never runs, the version never reaches the registry, and
200
+ consumers never resolve it.
201
+ (Corollary: a flaky `e2e` blocks releases — that is deliberate; fix the
202
+ flake, don't bypass the gate.) Permissions are least-privilege per job
203
+ (only `release-notes` gets `contents: write`; only `publish-npm` gets
204
+ `id-token: write` for provenance).
205
+
206
+ GitHub still serves the raw tag tarball `archive/refs/tags/vX.Y.Z.tar.gz`
207
+ for any tag, ungated — that path is legacy/fallback, deliberately *not* the
208
+ documented install, so it is no longer the safeguard-critical surface.
209
+ Process still applies: bump `package.json`, land on `main`, go green, tag.
210
+
211
+ ## Decision — distribution: npm public `@ponchia/ui`
212
+
213
+ Decided 2026-05-15. The framework is consumed by a growing set of
214
+ heterogeneous web frontends (Astro, SvelteKit, React, Solid, Qwik, vanilla),
215
+ several deploying via third-party CI. The only option where onboarding a new
216
+ frontend is `npm i @ponchia/ui` with zero per-consumer config is **npm
217
+ public**, and it uniquely also closes the release-gating gap (publish *is*
218
+ the gate). GitHub Packages was rejected: it requires auth to install even
219
+ public packages, i.e. an `.npmrc` + token on every frontend and CI runner —
220
+ the exact friction to avoid. The raw tag tarball is kept as an ungated
221
+ legacy/fallback only.
222
+
223
+ The npm scope `@bronto` is not ownable, so the package name is
224
+ **`@ponchia/ui`**. Naming layers, intentionally distinct:
225
+
226
+ - **npm package**: `@ponchia/ui` (registry identity).
227
+ - **CSS cascade layer**: `@layer bronto` and `data-bronto-*` behavior
228
+ attributes (the design-system namespace — unchanged; renaming gains
229
+ nothing and risks consumer overrides).
230
+ - **Workspace / brand**: "Bronto" (repo `Ponchia/bronto-ui`) — unchanged.
231
+
232
+ This split is deliberate; the README states it so the apparent mismatch is
233
+ explained, not surprising.
234
+
235
+ ### Post-publish checklist
236
+
237
+ - Confirm npm `latest` points at the tagged version and the package page shows
238
+ provenance.
239
+ - Run `npm pack --dry-run --json` locally or from CI logs and confirm the
240
+ intended file count/payload.
241
+ - Build the packed examples matrix (vanilla, Astro, SvelteKit, React, Solid, Qwik)
242
+ from the tarball, not a workspace link.
243
+ - Confirm the GitHub Release body matches the curated changelog section.
244
+ - If a bad package is published, deprecate that exact version on npm, publish a
245
+ patched version, and link the deprecation note to the changelog/security
246
+ advisory as appropriate.