@junoput01/junoui 0.6.0 → 0.8.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 (42) hide show
  1. package/CHANGELOG.md +209 -0
  2. package/README.md +17 -16
  3. package/dist/android/dimens.xml +2 -0
  4. package/dist/classes.json +1774 -0
  5. package/dist/css/juno-tokens.css +10 -0
  6. package/dist/css/juno.css +1057 -56
  7. package/dist/flutter/juno_tokens.dart +10 -0
  8. package/dist/ios/JunoTokens.swift +10 -0
  9. package/dist/js/tokens.js +16 -0
  10. package/dist/json/tokens.json +51 -0
  11. package/dist/rust/juno_tokens.rs +383 -0
  12. package/dist/scss/_juno-tokens.scss +10 -0
  13. package/docs/accessibility.md +6 -0
  14. package/docs/browser-support.md +3 -0
  15. package/docs/components/button.md +11 -2
  16. package/docs/components/canvas-ink.md +71 -0
  17. package/docs/components/dock.md +34 -0
  18. package/docs/components/fold-slot.md +26 -1
  19. package/docs/components/gizmo.md +114 -0
  20. package/docs/components/swatch.md +95 -0
  21. package/docs/components/tree.md +112 -0
  22. package/docs/conformance-kit.md +255 -0
  23. package/docs/getting-started.md +14 -0
  24. package/docs/integration.md +52 -6
  25. package/docs/ios-conformance.md +200 -3
  26. package/docs/ios-pwa.md +273 -0
  27. package/docs/native.md +38 -1
  28. package/docs/tokens-reference.md +15 -0
  29. package/package.json +8 -2
  30. package/src/css/base.css +35 -43
  31. package/src/css/components/button.css +42 -2
  32. package/src/css/components/canvas-ink.css +97 -0
  33. package/src/css/components/dock.css +65 -6
  34. package/src/css/components/fold-slot.css +49 -3
  35. package/src/css/components/gizmo.css +238 -0
  36. package/src/css/components/segmented.css +15 -2
  37. package/src/css/components/swatch.css +187 -0
  38. package/src/css/components/tree.css +259 -0
  39. package/src/css/touch-surfaces.mjs +95 -0
  40. package/tools/gizmo.mjs +144 -0
  41. package/tools/testing.mjs +177 -0
  42. package/tools/tree.mjs +178 -0
@@ -0,0 +1,259 @@
1
+ /* ════════════════════════════════════════════════════════════════════
2
+ * Component — Tree / outliner (nested rows, disclosure, selection)
3
+ * Layer stacks, file browsers, settings trees, org charts, comment
4
+ * threads, nested navigation. Where .juno-list is flat and
5
+ * .juno-accordion is single-level, this nests to arbitrary depth and
6
+ * carries a selection.
7
+ *
8
+ * ZERO JS FOR THE VISUALS. Indentation comes from the nested `role=group`
9
+ * lists the ARIA pattern already requires, so depth costs no custom
10
+ * property and no inline style; collapse is `[aria-expanded='false']`
11
+ * hiding the child group. The app owns the attribute, exactly like
12
+ * __item's aria-pressed elsewhere in junoui.
13
+ *
14
+ * KEYBOARD IS NOT OPTIONAL and is not CSS. A tree without arrow-key
15
+ * traversal and a roving tabindex is a list of buttons wearing tree
16
+ * roles. junoui ships the contract in docs/components/tree.md and a
17
+ * stateless enhancer at `junoui/tree` — use one or the other, but a tree
18
+ * that has neither is not accessible and no stylesheet can fix it.
19
+ *
20
+ * Usage (the markup the roles require — see the doc for the full ARIA
21
+ * contract, which is the half apps get wrong):
22
+ * <ul class="juno-tree" role="tree" aria-label="Scene">
23
+ * <li class="juno-tree__item" role="treeitem" aria-level="1"
24
+ * aria-expanded="true" aria-selected="false">
25
+ * <div class="juno-tree__row">
26
+ * <button class="juno-tree__caret" tabindex="-1" aria-hidden="true"></button>
27
+ * <svg class="juno-icon juno-tree__icon" aria-hidden="true">…</svg>
28
+ * <span class="juno-tree__label">Terrain</span>
29
+ * <span class="juno-tree__count">12</span>
30
+ * <span class="juno-tree__trail">…</span>
31
+ * <button class="juno-tree__handle" aria-label="Reorder Terrain"></button>
32
+ * </div>
33
+ * <ul class="juno-tree__group" role="group">…</ul>
34
+ * </li>
35
+ * </ul>
36
+ * ════════════════════════════════════════════════════════════════════ */
37
+
38
+ .juno-tree {
39
+ --juno-role: var(--juno-active);
40
+
41
+ /* One indent step. Applied per nested group, so depth is structural and
42
+ arbitrary rather than a level number someone has to keep in sync. */
43
+ --juno-tree-indent: var(--juno-space-16);
44
+
45
+ margin: 0;
46
+ padding: 0;
47
+ list-style: none;
48
+ font-family: var(--juno-font-family-sans);
49
+ font-size: var(--juno-font-size-13);
50
+ color: var(--juno-data);
51
+ }
52
+
53
+ .juno-tree__group {
54
+ margin: 0;
55
+ padding: 0;
56
+ padding-inline-start: var(--juno-tree-indent);
57
+ list-style: none;
58
+ }
59
+
60
+ /* Collapsed: the child group goes. `display: none` and not a height
61
+ animation — a tree collapses an unknown number of rows at an unknown
62
+ depth, so there is no end value to transition to, and interpolate-size
63
+ is not available everywhere junoui targets. */
64
+ .juno-tree__item[aria-expanded='false'] > .juno-tree__group {
65
+ display: none;
66
+ }
67
+
68
+ .juno-tree__row {
69
+ display: flex;
70
+ align-items: center;
71
+ gap: var(--juno-space-8);
72
+ inline-size: 100%;
73
+ min-block-size: var(--juno-size-tap-min);
74
+ padding-inline: var(--juno-space-8);
75
+ padding-block: var(--juno-space-4);
76
+ border: none;
77
+ border-radius: var(--juno-radius-3);
78
+ background: transparent;
79
+ color: inherit;
80
+ font: inherit;
81
+ text-align: start;
82
+ text-decoration: none;
83
+ cursor: pointer;
84
+ }
85
+
86
+ .juno-tree__row:hover {
87
+ background: var(--juno-s2);
88
+ }
89
+
90
+ /* SELECTION is a different fact from hover and from aria-current, and the
91
+ ticket is right that conflating them is the common bug: hover is where the
92
+ pointer is, aria-current is which page you are on, aria-selected is what
93
+ the next action will apply to. A layer stack has all three at once. */
94
+ .juno-tree__item[aria-selected='true'] > .juno-tree__row {
95
+ background: var(--juno-s3);
96
+ color: var(--juno-role);
97
+ }
98
+
99
+ .juno-tree__item[aria-current] > .juno-tree__row {
100
+ box-shadow: inset var(--juno-border-width-2) 0 0 var(--juno-role);
101
+ }
102
+
103
+ .juno-tree__row:focus-visible {
104
+ outline: var(--juno-border-width-2) solid var(--juno-active);
105
+ outline-offset: calc(-1 * var(--juno-border-width-2));
106
+ }
107
+
108
+ /* ── disclosure caret ──────────────────────────────────────────────────
109
+ aria-hidden and tabindex=-1 on purpose: the row itself is the treeitem
110
+ and Left/Right already collapse and expand, so a separately focusable
111
+ caret puts a second stop in the tab order for an action the row has. It
112
+ stays a real button for the pointer. */
113
+ .juno-tree__caret {
114
+ flex-shrink: 0;
115
+ inline-size: var(--juno-space-16);
116
+ block-size: var(--juno-space-16);
117
+ display: grid;
118
+ place-items: center;
119
+ padding: 0;
120
+ border: none;
121
+ background: transparent;
122
+ color: var(--juno-label);
123
+ cursor: pointer;
124
+ transition: transform var(--juno-motion-duration-quick) var(--juno-motion-ease-standard);
125
+ }
126
+
127
+ .juno-tree__caret::before {
128
+ content: '';
129
+ inline-size: 0;
130
+ block-size: 0;
131
+ border-block: calc(var(--juno-space-4) + 1px) solid transparent;
132
+ border-inline-start: calc(var(--juno-space-4) + 2px) solid currentcolor;
133
+ }
134
+
135
+ .juno-tree__item[aria-expanded='true'] > .juno-tree__row > .juno-tree__caret {
136
+ transform: rotate(90deg);
137
+ }
138
+
139
+ /* A leaf keeps the caret's box so labels line up down the column, but paints
140
+ nothing — an empty gap would let sibling labels wander. */
141
+ .juno-tree__item:not([aria-expanded]) > .juno-tree__row > .juno-tree__caret {
142
+ visibility: hidden;
143
+ cursor: default;
144
+ }
145
+
146
+ .juno-tree__icon {
147
+ flex-shrink: 0;
148
+ color: var(--juno-label);
149
+ }
150
+
151
+ .juno-tree__label {
152
+ min-inline-size: 0;
153
+ overflow: hidden;
154
+ text-overflow: ellipsis;
155
+ white-space: nowrap;
156
+ }
157
+
158
+ /* Count badge on a group row. Pushed to the trailing edge with the rest of
159
+ the trailing furniture. */
160
+ .juno-tree__count {
161
+ margin-inline-start: auto;
162
+ flex-shrink: 0;
163
+ font-size: var(--juno-font-size-11);
164
+ font-variant-numeric: tabular-nums;
165
+ color: var(--juno-muted);
166
+ }
167
+
168
+ /* The same trailing-control slot .juno-list__row has: a switch, a menu
169
+ trigger, a value. Follows the count when both are present. */
170
+ .juno-tree__trail {
171
+ margin-inline-start: auto;
172
+ flex-shrink: 0;
173
+ display: inline-flex;
174
+ align-items: center;
175
+ gap: var(--juno-space-8);
176
+ }
177
+
178
+ .juno-tree__count + .juno-tree__trail {
179
+ margin-inline-start: 0;
180
+ }
181
+
182
+ /* ── reorder handle ────────────────────────────────────────────────────
183
+ The AFFORDANCE and its hit area; the reorder logic is the app's, which is
184
+ junoui's usual line. It is a real button and always visible on touch: a
185
+ long-press-drag is the obvious gesture and the wrong one, because a tree
186
+ sitting on or beside a pan/zoom surface has to let the pan win. An
187
+ explicit handle is the only unambiguous target — see the doc.
188
+ `touch-action: none` on the handle ALONE, so dragging it never scrolls
189
+ while the rest of the row still pans normally. */
190
+ .juno-tree__handle {
191
+ flex-shrink: 0;
192
+ inline-size: var(--juno-space-16);
193
+ block-size: var(--juno-space-16);
194
+ display: grid;
195
+ place-items: center;
196
+ padding: 0;
197
+ border: none;
198
+ background: transparent;
199
+ color: var(--juno-muted);
200
+ cursor: grab;
201
+ touch-action: none;
202
+ }
203
+
204
+ .juno-tree__handle:active {
205
+ cursor: grabbing;
206
+ }
207
+
208
+ .juno-tree__handle::before {
209
+ content: '';
210
+ inline-size: var(--juno-space-10);
211
+ block-size: var(--juno-border-width-1);
212
+ box-shadow:
213
+ 0 calc(-1 * var(--juno-space-4)) 0 currentcolor,
214
+ 0 var(--juno-space-4) 0 currentcolor;
215
+ background: currentcolor;
216
+ }
217
+
218
+ /* The row being dragged, while the app moves it. */
219
+ .juno-tree__item[data-juno-dragging] > .juno-tree__row {
220
+ opacity: var(--juno-opacity-muted);
221
+ }
222
+
223
+ /* Drop target edge. A line rather than a filled row: the drop lands BETWEEN
224
+ rows, and a filled highlight says "into this one", which is a different
225
+ operation in a tree and the one users complain about. */
226
+ .juno-tree__item[data-juno-drop='before'] > .juno-tree__row {
227
+ box-shadow: inset 0 var(--juno-border-width-2) 0 var(--juno-active);
228
+ }
229
+
230
+ .juno-tree__item[data-juno-drop='after'] > .juno-tree__row {
231
+ box-shadow: inset 0 calc(-1 * var(--juno-border-width-2)) 0 var(--juno-active);
232
+ }
233
+
234
+ .juno-tree__item[data-juno-drop='into'] > .juno-tree__row {
235
+ outline: var(--juno-border-width-2) solid var(--juno-active);
236
+ outline-offset: calc(-1 * var(--juno-border-width-2));
237
+ }
238
+
239
+ /* ── touch ─────────────────────────────────────────────────────────────
240
+ The caret and the handle paint small so a dense tree stays dense, but a
241
+ 16px target is not tappable. Grow the HIT AREA only, with a transparent
242
+ overlay, exactly as .nx-check does downstream: a 44px painted caret would
243
+ swallow the row it sits in. The row's own floor comes from
244
+ --juno-size-tap-min, which base.css promotes on a coarse pointer. */
245
+ @media (pointer: coarse) {
246
+ .juno-tree__caret,
247
+ .juno-tree__handle {
248
+ position: relative;
249
+ }
250
+
251
+ .juno-tree__caret::after,
252
+ .juno-tree__handle::after {
253
+ content: '';
254
+ position: absolute;
255
+
256
+ /* (44 - 16) / 2 = 14 */
257
+ inset: calc(-1 * (var(--juno-size-tap-comfortable) - var(--juno-space-16)) / 2);
258
+ }
259
+ }
@@ -0,0 +1,95 @@
1
+ // ════════════════════════════════════════════════════════════════════════
2
+ // junoui — the tappable surfaces, declared once
3
+ // ════════════════════════════════════════════════════════════════════════
4
+ // THE source of truth for "this is one of junoui's own tappable components".
5
+ // scripts/bundle-css.mjs generates the touch-default rules from it, so the
6
+ // class names exist in exactly one place and a member cannot be misspelled
7
+ // into silence.
8
+ //
9
+ // WHY THIS FILE EXISTS. base.css used to carry two hand-maintained `:where()`
10
+ // lists, and both had drifted:
11
+ //
12
+ // - from the CLASSES: `.juno-seg__option` (the shipped class is
13
+ // `.juno-seg__opt`) and `.juno-list__item` (it is `.juno-list__row`) sat
14
+ // in them. `:where()` matched nothing, the rule still parsed, every other
15
+ // member kept working — so every segmented control and every grouped list
16
+ // row in every consumer kept the ~300ms double-tap delay. Invisible to
17
+ // lint, to the build and to a screenshot (20260826-024).
18
+ // - from EACH OTHER: the tap-highlight list was a strict subset of the
19
+ // touch-action one, missing __overflow, __opt, chip and toggle-btn, with
20
+ // nothing recording whether that was a decision.
21
+ //
22
+ // ONE SET, NOT TWO — the open question from docs/conformance-kit.md, decided
23
+ // on the rules' own rationales rather than by merging them for tidiness. The
24
+ // tap-highlight rule exists so a UA square "never flashes past a rounded
25
+ // control on tap"; every name the shorter list omitted is a rounded tappable
26
+ // (`.juno-chip` and `.juno-pillbar__overflow` are 999px pills,
27
+ // `.juno-seg__opt` and `.juno-toggle-btn` carry radius-3). The omission has no
28
+ // stated reason and the rationale covers them, so it was an omission and not a
29
+ // decision. Both properties answer the same question — "is this one of ours,
30
+ // and is it tapped?" — and now read the same answer.
31
+ //
32
+ // Adding a component here is the whole opt-in: state the class once, get both
33
+ // defaults. test/classes.test.mjs asserts every member is a class some
34
+ // component file actually defines, so a typo fails the build rather than
35
+ // going quiet.
36
+ // ════════════════════════════════════════════════════════════════════════
37
+
38
+ export const TOUCH_SURFACES = [
39
+ 'juno-btn',
40
+ 'juno-chip',
41
+ 'juno-dock__item',
42
+ 'juno-list__row',
43
+ 'juno-menu__item',
44
+ 'juno-pillbar__item',
45
+ 'juno-pillbar__overflow',
46
+ 'juno-seg__opt',
47
+ 'juno-tabs__tab',
48
+ 'juno-toggle-btn',
49
+ 'juno-tree__row',
50
+ 'juno-gizmo__mark',
51
+ 'juno-gizmo__center',
52
+ 'juno-swatch--button',
53
+ 'juno-palette__option',
54
+ ];
55
+
56
+ /** The `:where()` selector list, formatted for the bundle. */
57
+ export function whereList(indent = '') {
58
+ return `:where(\n${TOUCH_SURFACES.map((c) => `${indent} .${c}`).join(',\n')}\n${indent})`;
59
+ }
60
+
61
+ /** The generated touch-default layer.
62
+ *
63
+ * Two rules, one member list. `touch-action` is NOT inside the coarse block:
64
+ * a hybrid device (touch laptop, iPad with a trackpad) reports a fine primary
65
+ * pointer while still taking touch input, and the property is inert on a
66
+ * mouse anyway. The tap highlight only exists on touch, so it is.
67
+ *
68
+ * `:where()` contributes zero specificity by design — these are defaults a
69
+ * component or a consumer overrides by simply declaring the property. */
70
+ export function touchDefaultsCss() {
71
+ return `/* GENERATED from src/css/touch-surfaces.mjs — do not edit here.
72
+ *
73
+ * Tappable primitives opt out of double-tap-to-zoom. A browser that still
74
+ * recognises that gesture has to WAIT after the first tap to see whether a
75
+ * second one is coming, which reads as a late, mushy tap on exactly the
76
+ * surfaces a phone UI is built from. \`manipulation\` keeps panning and
77
+ * pinch-zoom (so the page stays zoomable — never \`none\` here, that would be an
78
+ * a11y regression) and drops only the double-tap.
79
+ * Community convention — no primary Apple/WebKit source names it; see
80
+ * docs/ios-conformance.md. Named components only, so a consumer's own elements
81
+ * are untouched. See 20260803-038. */
82
+ ${whereList()} {
83
+ touch-action: manipulation;
84
+ }
85
+
86
+ /* And on touch, kill the UA tap-highlight square so it never flashes past a
87
+ * rounded control. Consumers were adding this by hand per component; it is a
88
+ * first-class touch default. See 20260802-020. */
89
+ @media (pointer: coarse) {
90
+ ${whereList(' ')} {
91
+ -webkit-tap-highlight-color: transparent;
92
+ }
93
+ }
94
+ `;
95
+ }
@@ -0,0 +1,144 @@
1
+ // ════════════════════════════════════════════════════════════════════════
2
+ // junoui/gizmo — bearing language and keyboard traversal for .juno-gizmo
3
+ // ════════════════════════════════════════════════════════════════════════
4
+ // Two things a stylesheet cannot do, and both are the reason the component
5
+ // belongs upstream rather than in each app:
6
+ //
7
+ // 1. SAYING THE BEARING. "N" is a letter, not an accessible name, and
8
+ // "37deg" is a number a screen-reader user has to convert. `bearingLabel`
9
+ // turns an angle into the words a person would use.
10
+ // 2. ONE FOCUS STOP. The gizmo is a composite widget: Tab reaches it once,
11
+ // arrow keys move between marks (wrapping, because a compass ring wraps),
12
+ // Enter activates. Eight separate tab stops for eight compass points is
13
+ // the thing apps ship and the thing that makes the widget unusable by
14
+ // keyboard.
15
+ //
16
+ // Stateless, like junoui/tree: the app owns the camera. This moves focus and
17
+ // lets the marks' own click handlers fire; it never writes an angle.
18
+ // ════════════════════════════════════════════════════════════════════════
19
+
20
+ const MARK = '.juno-gizmo__mark';
21
+
22
+ /** The 16 compass points, as words rather than letters. */
23
+ const POINTS = [
24
+ 'north',
25
+ 'north-north-east',
26
+ 'north-east',
27
+ 'east-north-east',
28
+ 'east',
29
+ 'east-south-east',
30
+ 'south-east',
31
+ 'south-south-east',
32
+ 'south',
33
+ 'south-south-west',
34
+ 'south-west',
35
+ 'west-south-west',
36
+ 'west',
37
+ 'west-north-west',
38
+ 'north-west',
39
+ 'north-north-west',
40
+ ];
41
+
42
+ /** Normalise any angle into [0, 360). */
43
+ export const normalizeBearing = (deg) => ((Number(deg) % 360) + 360) % 360;
44
+
45
+ /**
46
+ * A bearing as a person would say it: `37` → `"north-east"`.
47
+ *
48
+ * Sixteen points, nearest wins, and the boundaries are half a sector wide so
49
+ * 348.75..360 and 0..11.25 are both "north" — a naive `Math.round(deg / 22.5)`
50
+ * yields index 16 near 360 and reads off the end of the table.
51
+ */
52
+ export function bearingLabel(deg) {
53
+ const d = normalizeBearing(deg);
54
+ return POINTS[Math.round(d / 22.5) % 16];
55
+ }
56
+
57
+ /**
58
+ * The full spoken description: direction, degrees, and the tilt when there is
59
+ * one. This is what goes in the live region — the ring itself is decoration to
60
+ * a screen reader, and a rotating needle announces nothing.
61
+ */
62
+ export function orientationLabel(heading, pitch = null) {
63
+ const d = Math.round(normalizeBearing(heading));
64
+ const base = `Facing ${bearingLabel(d)}, ${d} degrees`;
65
+ return pitch === null || pitch === undefined
66
+ ? `${base}.`
67
+ : `${base}. Tilted ${Math.round(Number(pitch))} degrees.`;
68
+ }
69
+
70
+ /**
71
+ * Wire the gizmo as one focus stop. Returns a teardown function.
72
+ *
73
+ * Idempotent — enhancing twice replaces the first listener rather than
74
+ * stacking two, so a framework re-running an effect does not double-move focus
75
+ * on every keypress.
76
+ */
77
+ export function enhanceGizmo(root) {
78
+ if (!root) throw new Error('enhanceGizmo: no root element');
79
+ root._junoGizmoTeardown?.();
80
+
81
+ const marks = () => [...root.querySelectorAll(MARK)].filter((m) => !m.disabled);
82
+
83
+ // Seed the roving tabindex: the mark the app marks current, else the first.
84
+ const seed = () => {
85
+ const list = marks();
86
+ if (!list.length) return;
87
+ for (const m of list) m.tabIndex = -1;
88
+ (list.find((m) => m.getAttribute('aria-current') === 'true') ?? list[0]).tabIndex = 0;
89
+ };
90
+ seed();
91
+
92
+ const onKeyDown = (event) => {
93
+ const list = marks();
94
+ const at = list.indexOf(event.target.closest(MARK));
95
+ if (at < 0) return;
96
+
97
+ // WRAPPING, because a compass ring wraps. Clamping at the ends is the
98
+ // behaviour of a slider, and it would make west unreachable from north by
99
+ // the short way round.
100
+ const move = (next) => {
101
+ event.preventDefault();
102
+ const target = list[(next + list.length) % list.length];
103
+ for (const m of list) m.tabIndex = -1;
104
+ target.tabIndex = 0;
105
+ target.focus();
106
+ };
107
+
108
+ switch (event.key) {
109
+ case 'ArrowRight':
110
+ case 'ArrowDown':
111
+ return move(at + 1);
112
+ case 'ArrowLeft':
113
+ case 'ArrowUp':
114
+ return move(at - 1);
115
+ case 'Home':
116
+ return move(0);
117
+ case 'End':
118
+ return move(list.length - 1);
119
+ default:
120
+ // Enter and Space are the button's own; intercepting them would mean
121
+ // reimplementing activation, and a <button> already does it right.
122
+ return;
123
+ }
124
+ };
125
+
126
+ const onFocusIn = (event) => {
127
+ const mark = event.target.closest(MARK);
128
+ if (!mark || !root.contains(mark)) return;
129
+ // The pointer path: a click focuses a mark without passing through the
130
+ // keyboard move above, and the invariant has to hold for both.
131
+ for (const m of marks()) m.tabIndex = -1;
132
+ mark.tabIndex = 0;
133
+ };
134
+
135
+ root.addEventListener('keydown', onKeyDown);
136
+ root.addEventListener('focusin', onFocusIn);
137
+ const teardown = () => {
138
+ root.removeEventListener('keydown', onKeyDown);
139
+ root.removeEventListener('focusin', onFocusIn);
140
+ delete root._junoGizmoTeardown;
141
+ };
142
+ root._junoGizmoTeardown = teardown;
143
+ return teardown;
144
+ }
@@ -0,0 +1,177 @@
1
+ // ════════════════════════════════════════════════════════════════════════
2
+ // junoui/testing — guards a consumer can run against its own source
3
+ // ════════════════════════════════════════════════════════════════════════
4
+ // Framework-agnostic: throws an Error with a readable message, so it works
5
+ // under vitest, node:test, jest or a plain script. No dependencies.
6
+ //
7
+ // import { assertJunoClasses } from 'junoui/testing';
8
+ // assertJunoClasses(['src/**/*.tsx']);
9
+ //
10
+ // WHAT IT ANSWERS, and what it does not. It answers "junoui defines a rule
11
+ // mentioning this class". It does not answer "the class does what your
12
+ // component assumes" — a class that exists but was repurposed upstream
13
+ // passes. What it catches with certainty is a name that matches NOTHING,
14
+ // which is the whole of the defect it was written for: eleven such names
15
+ // once compiled silently in a consumer and rendered a phone dialog as
16
+ // unstyled UA defaults, with its confirm button off the bottom of the screen.
17
+ //
18
+ // See docs/conformance-kit.md.
19
+ // ════════════════════════════════════════════════════════════════════════
20
+
21
+ import { readFileSync, readdirSync, statSync } from 'node:fs';
22
+ import { join, dirname, relative, sep } from 'node:path';
23
+ import { fileURLToPath } from 'node:url';
24
+
25
+ const HERE = dirname(fileURLToPath(import.meta.url));
26
+
27
+ /** `juno-` or the role form `juno--`, then BEM segments.
28
+ * The leading boundary keeps `--juno-warning` out: a custom property is
29
+ * always preceded by a hyphen and a class never is. Getting this wrong makes
30
+ * the guard report components that have no defect. */
31
+ const CLASS_RE = /(?<![-\w])juno-{1,2}[a-z0-9]+(?:[-_]{1,2}[a-z0-9]+)*/g;
32
+
33
+ /** The manifest this build ships. */
34
+ export function loadJunoClasses() {
35
+ return JSON.parse(readFileSync(join(HERE, '..', 'dist', 'classes.json'), 'utf8'));
36
+ }
37
+
38
+ /**
39
+ * Source with comments removed.
40
+ *
41
+ * Crude by design — it does not parse string literals, so a `//` inside one
42
+ * truncates that line. Worth the simplicity: a class name does not live inside
43
+ * a URL, and the alternative is a second implementation of a compiler to
44
+ * answer a question about strings. Comments MUST be stripped: a file that
45
+ * documents a typo in order to explain it would otherwise be reported for it.
46
+ */
47
+ export function stripComments(source) {
48
+ return source.replace(/\/\*[\s\S]*?\*\//g, ' ').replace(/\/\/[^\n]*/g, ' ');
49
+ }
50
+
51
+ /**
52
+ * `juno-*` class names appearing in a source file, comments excluded.
53
+ *
54
+ * Matches anywhere in the code rather than parsing JSX: a class reaches the
55
+ * DOM through a template literal, a ternary or a helper as often as through a
56
+ * literal `className="…"`, and a matcher that only understood the literal form
57
+ * would skip the conditional ones — which is exactly where a typo hides.
58
+ */
59
+ export function junoClassesIn(source) {
60
+ return [...new Set([...stripComments(source).matchAll(CLASS_RE)].map((m) => m[0]))].sort();
61
+ }
62
+
63
+ /** Minimal glob: supports `**`, `*` and `?`. No braces, no negation — a
64
+ * consumer wanting more can pass an explicit file list instead. */
65
+ function globToRegExp(pattern) {
66
+ let out = '';
67
+ for (let i = 0; i < pattern.length; i++) {
68
+ const c = pattern[i];
69
+ if (c === '*') {
70
+ if (pattern[i + 1] === '*') {
71
+ out += '.*';
72
+ i++;
73
+ if (pattern[i + 1] === '/') i++;
74
+ } else out += '[^/]*';
75
+ } else if (c === '?') out += '[^/]';
76
+ else out += c.replace(/[.+^${}()|[\]\\]/g, '\\$&');
77
+ }
78
+ return new RegExp('^' + out + '$');
79
+ }
80
+
81
+ function walk(dir, acc = []) {
82
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
83
+ if (entry.name === 'node_modules' || entry.name.startsWith('.')) continue;
84
+ const p = join(dir, entry.name);
85
+ if (entry.isDirectory()) walk(p, acc);
86
+ else acc.push(p);
87
+ }
88
+ return acc;
89
+ }
90
+
91
+ /** Files matching any of `patterns`, resolved from `cwd`. */
92
+ export function resolveFiles(patterns, cwd = process.cwd()) {
93
+ const res = patterns.map(globToRegExp);
94
+ const roots = new Set();
95
+ for (const p of patterns) {
96
+ const literal = p.split(/[*?]/)[0];
97
+ const base = literal.endsWith('/') ? literal : dirname(literal);
98
+ roots.add(base === '' || base === '.' ? cwd : join(cwd, base));
99
+ }
100
+ const files = [];
101
+ for (const root of roots) {
102
+ let st;
103
+ try {
104
+ st = statSync(root);
105
+ } catch {
106
+ continue;
107
+ }
108
+ if (!st.isDirectory()) continue;
109
+ for (const f of walk(root)) {
110
+ const rel = relative(cwd, f).split(sep).join('/');
111
+ if (res.some((r) => r.test(rel))) files.push(rel);
112
+ }
113
+ }
114
+ return files.sort();
115
+ }
116
+
117
+ /**
118
+ * Throw if any file names a `juno-*` class this build does not define.
119
+ *
120
+ * @param patterns globs or explicit paths, relative to `cwd`
121
+ * @param options.allowed names the CONSUMER defines in its own stylesheet.
122
+ * Each one is a claim the caller is making; check it against that stylesheet
123
+ * rather than treating this as a waiver list.
124
+ * @param options.surface `'all'` (default) or `'public'`.
125
+ *
126
+ * 'all' is the default deliberately, against this kit's own first proposal.
127
+ * Measured on the 0.7.0 build: 310 classes have rules, 277 are named in
128
+ * docs/. The 33-name difference is NOT an internals list — it is
129
+ * `juno-sr-only`, `juno-bg-s0`, `juno-hide-below-lg`, `juno-eyebrow` and
130
+ * friends, i.e. public utilities nobody wrote up. Defaulting to 'public'
131
+ * would have failed consumers for using shipped API. 'public' remains
132
+ * available for a stricter check, and the docs gap is junoui's to close.
133
+ */
134
+ export function assertJunoClasses(patterns, options = {}) {
135
+ const { allowed = [], surface = 'all', cwd = process.cwd() } = options;
136
+ const manifest = loadJunoClasses();
137
+ // The claim is "junoui ships NOTHING by this name", not "this is not a
138
+ // class". A consumer writes `junoPx('juno-pillbar-gap')` and `#juno-i-${n}`,
139
+ // and no regex over source text can tell those from a class name — so a
140
+ // guard that only knew about classes would report correct code. Measured on
141
+ // a real consumer: 8 of 24 reports were tokens, an icon-id template and a
142
+ // keyframe, all of them names junoui does ship.
143
+ const shipped = [...manifest.keyframes, ...manifest.tokens, ...manifest.icons];
144
+ const defined = new Set(
145
+ surface === 'public'
146
+ ? [...manifest.public, ...manifest.roles, ...shipped]
147
+ : [...manifest.all, ...shipped],
148
+ );
149
+ const waived = new Set(allowed);
150
+
151
+ const files = Array.isArray(patterns)
152
+ ? resolveFiles(patterns, cwd)
153
+ : resolveFiles([patterns], cwd);
154
+ if (files.length === 0) {
155
+ // A guard that inspected nothing and passed is the failure mode this
156
+ // whole kit exists to stop.
157
+ throw new Error(
158
+ `assertJunoClasses: no files matched ${JSON.stringify(patterns)} under ${cwd} — ` +
159
+ `the check would have passed vacuously`,
160
+ );
161
+ }
162
+
163
+ const offenders = [];
164
+ for (const f of files) {
165
+ for (const cls of junoClassesIn(readFileSync(join(cwd, f), 'utf8'))) {
166
+ if (!defined.has(cls) && !waived.has(cls)) offenders.push(`${f}: ${cls}`);
167
+ }
168
+ }
169
+ if (offenders.length) {
170
+ throw new Error(
171
+ `junoui ${manifest.version} ships nothing named by ${offenders.length} \`juno-*\` name(s) ` +
172
+ `(surface: ${surface}, ${files.length} file(s) checked):\n ` +
173
+ offenders.join('\n '),
174
+ );
175
+ }
176
+ return { files: files.length, checked: defined.size };
177
+ }