@junoput01/junoui 0.5.0 → 0.7.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.
- package/CHANGELOG.md +188 -0
- package/README.md +44 -14
- package/dist/classes.json +1614 -0
- package/dist/css/juno.css +339 -37
- package/dist/icons/inline.js +7 -9
- package/dist/icons/install.js +26 -0
- package/docs/accessibility.md +6 -0
- package/docs/browser-support.md +176 -0
- package/docs/components/button.md +11 -2
- package/docs/components/dock.md +34 -0
- package/docs/components/fold-slot.md +26 -1
- package/docs/conformance-kit.md +243 -0
- package/docs/getting-started.md +36 -0
- package/docs/icon-subsetting.md +29 -0
- package/docs/integration.md +52 -6
- package/docs/ios-conformance.md +403 -4
- package/docs/ios-pwa.md +273 -0
- package/docs/layout.md +35 -0
- package/package.json +15 -3
- package/src/css/base.css +103 -23
- package/src/css/components/button.css +42 -2
- package/src/css/components/dock.css +65 -6
- package/src/css/components/fold-slot.css +49 -3
- package/src/css/components/input.css +12 -0
- package/src/css/components/pillbar.css +1 -1
- package/src/css/components/segmented.css +15 -2
- package/src/css/overrides.css +51 -0
- package/tools/testing.mjs +177 -0
|
@@ -29,6 +29,55 @@
|
|
|
29
29
|
media query. */
|
|
30
30
|
--juno-dock-scale: 1;
|
|
31
31
|
|
|
32
|
+
/* ── Horizontal budget contract ──────────────────────────────────────
|
|
33
|
+
Published so a consumer's capacity planner ("how many items fit, and
|
|
34
|
+
do they still hold a tap target?") reads junoui's real geometry
|
|
35
|
+
instead of re-deriving it from the numbers in this file. Two
|
|
36
|
+
consumers had derived it by hand, twice, in prose — and both had
|
|
37
|
+
drifted: they subtracted 12px of inline padding where the pill
|
|
38
|
+
actually spends 8, so their per-item width came out ~0.8px low at
|
|
39
|
+
every viewport (20260826-027).
|
|
40
|
+
|
|
41
|
+
--juno-dock-items the item budget. junoui does not enforce it —
|
|
42
|
+
__item is `flex: 1 1 0`, so the layout divides
|
|
43
|
+
by however many items are present — it is the
|
|
44
|
+
number the DERIVATIONS below are about, and the
|
|
45
|
+
consumer sets it to what it renders.
|
|
46
|
+
--juno-dock-item-inline the width one item gets. A prediction of what
|
|
47
|
+
the flex layout produces, not an input to it;
|
|
48
|
+
asserted against the measured box in the test
|
|
49
|
+
suite so it cannot drift from the layout.
|
|
50
|
+
--juno-dock-fit-inline the narrowest viewport at which every item
|
|
51
|
+
still holds --juno-size-tap-comfortable. Use it
|
|
52
|
+
in a media query and DROP an item; that is the
|
|
53
|
+
answer junoui can give exactly.
|
|
54
|
+
|
|
55
|
+
A SCALE floor (44px / the item's width) is deliberately not published:
|
|
56
|
+
it is a ratio of two lengths, and CSS cannot divide by a length. A
|
|
57
|
+
consumer that must scale rather than drop compares
|
|
58
|
+
--juno-dock-item-inline against --juno-size-tap-comfortable itself.
|
|
59
|
+
|
|
60
|
+
The three inline terms are declared ONCE here and consumed by both the
|
|
61
|
+
variants' own margin/padding/border and the sums below, so the budget
|
|
62
|
+
cannot disagree with the bar it describes — the same reason
|
|
63
|
+
--juno-dock-edge-offset exists (20260815-055). The base bar is
|
|
64
|
+
full-bleed with a top-edge border only, so all three are zero; --pill
|
|
65
|
+
and --float set them. */
|
|
66
|
+
--juno-dock-items: 5;
|
|
67
|
+
--juno-dock-margin-inline: 0px;
|
|
68
|
+
--juno-dock-pad-inline: 0px;
|
|
69
|
+
--juno-dock-border-inline: 0px;
|
|
70
|
+
--juno-dock-chrome-inline: calc(
|
|
71
|
+
2 * (var(--juno-dock-margin-inline) + var(--juno-dock-pad-inline) + var(--juno-dock-border-inline))
|
|
72
|
+
);
|
|
73
|
+
--juno-dock-avail: 100vw;
|
|
74
|
+
--juno-dock-item-inline: calc(
|
|
75
|
+
(var(--juno-dock-avail) - var(--juno-dock-chrome-inline)) / var(--juno-dock-items)
|
|
76
|
+
);
|
|
77
|
+
--juno-dock-fit-inline: calc(
|
|
78
|
+
var(--juno-dock-items) * var(--juno-size-tap-comfortable) + var(--juno-dock-chrome-inline)
|
|
79
|
+
);
|
|
80
|
+
|
|
32
81
|
position: sticky;
|
|
33
82
|
inset-block-end: 0;
|
|
34
83
|
z-index: var(--juno-z-raised);
|
|
@@ -123,16 +172,21 @@
|
|
|
123
172
|
.juno-dock--pill {
|
|
124
173
|
--juno-role: var(--juno-active);
|
|
125
174
|
|
|
175
|
+
/* the three inline terms the budget contract sums — see .juno-dock */
|
|
176
|
+
--juno-dock-margin-inline: var(--juno-space-12);
|
|
177
|
+
--juno-dock-pad-inline: var(--juno-space-4);
|
|
178
|
+
--juno-dock-border-inline: var(--juno-border-width-1);
|
|
179
|
+
|
|
126
180
|
position: fixed;
|
|
127
181
|
inset-inline: 0;
|
|
128
182
|
inset-block-end: 0;
|
|
129
183
|
z-index: var(--juno-z-raised);
|
|
130
|
-
margin: 0 var(--juno-
|
|
131
|
-
padding: var(--juno-
|
|
184
|
+
margin: 0 var(--juno-dock-margin-inline) var(--juno-dock-edge-offset);
|
|
185
|
+
padding: var(--juno-dock-pad-inline);
|
|
132
186
|
background: color-mix(in srgb, var(--juno-s1) 88%, transparent);
|
|
133
187
|
-webkit-backdrop-filter: blur(12px);
|
|
134
188
|
backdrop-filter: blur(12px);
|
|
135
|
-
border: var(--juno-border-
|
|
189
|
+
border: var(--juno-dock-border-inline) solid var(--juno-border);
|
|
136
190
|
border-radius: 999px;
|
|
137
191
|
box-shadow: var(--juno-shadow-2);
|
|
138
192
|
}
|
|
@@ -226,17 +280,22 @@
|
|
|
226
280
|
Out of flow (fixed) so the page scrolls UNDER it — reserve room on the
|
|
227
281
|
scroller with padding-block-end: var(--juno-dock-clearance). */
|
|
228
282
|
.juno-dock--float {
|
|
283
|
+
/* the three inline terms the budget contract sums — see .juno-dock */
|
|
284
|
+
--juno-dock-margin-inline: var(--juno-space-12);
|
|
285
|
+
--juno-dock-pad-inline: var(--juno-space-4);
|
|
286
|
+
--juno-dock-border-inline: var(--juno-border-width-1);
|
|
287
|
+
|
|
229
288
|
position: fixed;
|
|
230
289
|
inset-inline: 0;
|
|
231
290
|
inset-block-end: 0;
|
|
232
291
|
z-index: var(--juno-z-raised);
|
|
233
|
-
margin: 0 var(--juno-
|
|
234
|
-
padding: var(--juno-
|
|
292
|
+
margin: 0 var(--juno-dock-margin-inline) var(--juno-dock-edge-offset);
|
|
293
|
+
padding: var(--juno-dock-pad-inline);
|
|
235
294
|
overflow: hidden;
|
|
236
295
|
background: color-mix(in srgb, var(--juno-s1) 88%, transparent);
|
|
237
296
|
-webkit-backdrop-filter: blur(12px);
|
|
238
297
|
backdrop-filter: blur(12px);
|
|
239
|
-
border: var(--juno-border-
|
|
298
|
+
border: var(--juno-dock-border-inline) solid var(--juno-border);
|
|
240
299
|
border-radius: 999px;
|
|
241
300
|
box-shadow: var(--juno-shadow-2);
|
|
242
301
|
}
|
|
@@ -12,17 +12,44 @@
|
|
|
12
12
|
* gap, the folded slot still occupies one gap — name it in
|
|
13
13
|
* --juno-fold-gap and the fold swallows it with a negative margin.
|
|
14
14
|
*
|
|
15
|
+
* COMPOSITION. The canonical use puts .juno-fold on an element that is
|
|
16
|
+
* already a component — a .juno-pillbar__item, a .juno-btn, a .juno-chip
|
|
17
|
+
* — because that is where the capsule chrome (tap floor, padding, hover,
|
|
18
|
+
* focus ring, aria-pressed) lives. Two things follow, and both are
|
|
19
|
+
* load-bearing rather than defensive:
|
|
20
|
+
*
|
|
21
|
+
* 1. The fold's own declarations are stated at ATTRIBUTE specificity
|
|
22
|
+
* (0,2,0), on both branches of data-juno-in, i.e. always. A component
|
|
23
|
+
* class is (0,1,0), and `transition` is a SHORTHAND: a later rule does
|
|
24
|
+
* not add to the list, it replaces it. Measured in Chromium against the
|
|
25
|
+
* built bundle, before this was raised: on a
|
|
26
|
+
* .juno-fold.juno-pillbar__item, transition-property resolved to
|
|
27
|
+
* `color, background-color` — pillbar.css sorts after this file, so the
|
|
28
|
+
* fold had no transition at all and jumped shut instead of folding.
|
|
29
|
+
* inline-size and overflow would lose the same way to a component that
|
|
30
|
+
* sets either.
|
|
31
|
+
*
|
|
32
|
+
* 2. Owning `transition` means owning ALL of it, so the capsule chrome
|
|
33
|
+
* properties are in the fold's list too, at the quick duration the
|
|
34
|
+
* components themselves use. Dropping them would trade a broken fold
|
|
35
|
+
* for a broken hover.
|
|
36
|
+
*
|
|
37
|
+
* Which floors the fold RELEASES when folded — see the folded rule below:
|
|
38
|
+
* min-inline-size, padding-inline, border-inline-width. Those are exactly
|
|
39
|
+
* the three inputs that can hold a border-box inline size above zero.
|
|
40
|
+
*
|
|
15
41
|
* Canonical use: a transient action in a toolbar/pill row (a
|
|
16
42
|
* scroll-to-top arrow, a contextual button) whose arrival should slide
|
|
17
43
|
* the row open rather than jump it.
|
|
18
44
|
* Usage:
|
|
19
45
|
* <div style="display:flex; gap:4px"> (any row)
|
|
20
|
-
* <button class="juno-fold" data-juno-in aria-label="Scroll to top">…</button>
|
|
46
|
+
* <button class="juno-fold juno-pillbar__item" data-juno-in aria-label="Scroll to top">…</button>
|
|
21
47
|
* …other members…
|
|
22
48
|
* </div>
|
|
23
49
|
* prefers-reduced-motion needs nothing component-local — the base
|
|
24
50
|
* layer zeroes every transition duration. */
|
|
25
|
-
.juno-fold
|
|
51
|
+
.juno-fold[data-juno-in],
|
|
52
|
+
.juno-fold:not([data-juno-in]) {
|
|
26
53
|
--juno-fold-size: var(--juno-size-tap-comfortable);
|
|
27
54
|
--juno-fold-gap: 0px;
|
|
28
55
|
|
|
@@ -31,13 +58,32 @@
|
|
|
31
58
|
overflow: hidden;
|
|
32
59
|
transition:
|
|
33
60
|
inline-size var(--juno-motion-duration-base) var(--juno-motion-ease-standard),
|
|
61
|
+
min-inline-size var(--juno-motion-duration-base) var(--juno-motion-ease-standard),
|
|
62
|
+
padding var(--juno-motion-duration-base) var(--juno-motion-ease-standard),
|
|
63
|
+
border-width var(--juno-motion-duration-base) var(--juno-motion-ease-standard),
|
|
34
64
|
margin var(--juno-motion-duration-base) var(--juno-motion-ease-standard),
|
|
35
65
|
opacity var(--juno-motion-duration-base) var(--juno-motion-ease-standard),
|
|
36
|
-
visibility var(--juno-motion-duration-base) var(--juno-motion-ease-standard)
|
|
66
|
+
visibility var(--juno-motion-duration-base) var(--juno-motion-ease-standard),
|
|
67
|
+
color var(--juno-motion-duration-quick) var(--juno-motion-ease-standard),
|
|
68
|
+
background-color var(--juno-motion-duration-quick) var(--juno-motion-ease-standard);
|
|
37
69
|
}
|
|
38
70
|
|
|
71
|
+
/* Folded away. A min-* floor clamps the USED value whichever rule won the
|
|
72
|
+
cascade, and a border-box inline size can never resolve below its own
|
|
73
|
+
padding plus border — so a composed capsule holds the slot open however
|
|
74
|
+
hard the fold pushes. Measured against the built bundle, composed with
|
|
75
|
+
.juno-pillbar__item at a 390px viewport: 44px with both floors in place
|
|
76
|
+
(the tap target), 20px with min-inline-size released (the padding alone),
|
|
77
|
+
0px with all of them released.
|
|
78
|
+
min-inline-size is in the transition list above for the OPENING half:
|
|
79
|
+
released to 0 it would otherwise snap back to the capsule's tap floor the
|
|
80
|
+
instant data-juno-in returns, and the used width — max(inline-size,
|
|
81
|
+
min-inline-size) — would jump to full width with nothing to animate. */
|
|
39
82
|
.juno-fold:not([data-juno-in]) {
|
|
40
83
|
inline-size: 0;
|
|
84
|
+
min-inline-size: 0;
|
|
85
|
+
padding-inline: 0;
|
|
86
|
+
border-inline-width: 0;
|
|
41
87
|
margin-inline-end: calc(-1 * var(--juno-fold-gap));
|
|
42
88
|
opacity: 0;
|
|
43
89
|
visibility: hidden;
|
|
@@ -28,6 +28,18 @@
|
|
|
28
28
|
color: var(--juno-data);
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
/* The iOS focus-zoom floor, and it has to live HERE, not in base.css's
|
|
32
|
+
`@media (pointer: coarse)` block where it was written. A media query adds no
|
|
33
|
+
specificity, so `.juno-input { font-size: var(--juno-font-size-14) }` above —
|
|
34
|
+
same 0,1,0 selector, later in the bundle (base.css sorts before components/)
|
|
35
|
+
— simply won it, and the floor never applied on any touch device. Nothing
|
|
36
|
+
caught it because the suite had no coarse-pointer project until 20260815-006
|
|
37
|
+
added one; the numeric assertion in test/visual/tap-targets.spec.mjs is what
|
|
38
|
+
surfaced it. See base.css for the sourcing caveat on the behavior itself. */
|
|
39
|
+
@media (pointer: coarse) {
|
|
40
|
+
.juno-input { font-size: max(16px, var(--juno-font-size-16)); }
|
|
41
|
+
}
|
|
42
|
+
|
|
31
43
|
.juno-input--sans { font-family: var(--juno-font-family-sans); }
|
|
32
44
|
|
|
33
45
|
.juno-input::placeholder { color: var(--juno-muted); }
|
|
@@ -257,7 +257,7 @@
|
|
|
257
257
|
<svg class="juno-icon" aria-hidden="true"><use href="…#juno-i-dots-three" /></svg>
|
|
258
258
|
</button>
|
|
259
259
|
<div class="juno-pillbar__tray">
|
|
260
|
-
<div
|
|
260
|
+
<div>\3c !-- single wrapper, any element -->
|
|
261
261
|
…the usual __item / __sep / __input children…
|
|
262
262
|
</div>
|
|
263
263
|
</div>
|
|
@@ -38,11 +38,20 @@
|
|
|
38
38
|
opacity: 0;
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
/* the pill — a span after the radio, or the aria-pressed button itself
|
|
41
|
+
/* the pill — a span after the radio, or the aria-pressed button itself.
|
|
42
|
+
The tap floor is on the PAINTED box, not on the label that wraps it: the
|
|
43
|
+
label is a bare inline-flex and takes its height from this. Segmented is a
|
|
44
|
+
phone-first control and is routinely the only control on a whole settings
|
|
45
|
+
section, so it holds --juno-size-tap-min like .juno-btn does — 24px (WCAG
|
|
46
|
+
2.2 AA, 2.5.8) on a fine pointer, promoted to the 44px comfortable target
|
|
47
|
+
under (pointer: coarse) by base.css. Without it the pill computed ~25px
|
|
48
|
+
from its padding alone, which is the AA floor by accident and nothing on
|
|
49
|
+
touch. See 20260826-025. */
|
|
42
50
|
.juno-seg__opt input + span,
|
|
43
51
|
button.juno-seg__opt {
|
|
44
52
|
display: inline-flex;
|
|
45
53
|
align-items: center;
|
|
54
|
+
min-block-size: var(--juno-size-tap-min);
|
|
46
55
|
gap: var(--juno-gap-control);
|
|
47
56
|
font-family: var(--juno-font-family-sans);
|
|
48
57
|
font-size: var(--juno-font-size-11);
|
|
@@ -90,7 +99,11 @@ button.juno-seg__opt:disabled {
|
|
|
90
99
|
cursor: not-allowed;
|
|
91
100
|
}
|
|
92
101
|
|
|
93
|
-
/* Small —
|
|
102
|
+
/* Small — dense toolbars: less type, tighter padding. UNLIKE .juno-btn--sm it
|
|
103
|
+
does NOT drop below the tap floor, which the base rule above holds: --sm is a
|
|
104
|
+
density modifier here, not an opt-out of the touch target. A segmented row is
|
|
105
|
+
often the only control on a settings section, so a sub-tap variant of it has
|
|
106
|
+
no safe use on a phone. */
|
|
94
107
|
.juno-seg--sm .juno-seg__opt input + span,
|
|
95
108
|
.juno-seg--sm > button.juno-seg__opt {
|
|
96
109
|
font-size: var(--juno-font-size-10);
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/* ════════════════════════════════════════════════════════════════════
|
|
2
|
+
* Overrides — cross-cutting gates, bundled LAST
|
|
3
|
+
* ════════════════════════════════════════════════════════════════════
|
|
4
|
+
* A @media or @supports block adds NO specificity. So a gate written in
|
|
5
|
+
* base.css — which the bundler emits before components/ — loses to any
|
|
6
|
+
* component rule declaring the same property on the same selector, purely
|
|
7
|
+
* on source order, and loses SILENTLY: the gate looks right in the file it
|
|
8
|
+
* was written in, and there is no error anywhere.
|
|
9
|
+
*
|
|
10
|
+
* Four instances of that shipped before this file existed (2026-08-15):
|
|
11
|
+
* - .juno-input's 16px iOS focus-zoom floor, beaten by input.css. The floor
|
|
12
|
+
* never applied on any touch device, in any release (20260815-011).
|
|
13
|
+
* - the forced-colors border below, beaten by button.css / badge.css /
|
|
14
|
+
* card.css (20260815-029).
|
|
15
|
+
* - the Popover fallback, which happens to win today only because nothing
|
|
16
|
+
* later declares `display` on those selectors — pinned by a test rather
|
|
17
|
+
* than by structure (20260815-013).
|
|
18
|
+
*
|
|
19
|
+
* So: gates whose whole job is to BEAT a component default live here, after
|
|
20
|
+
* everything they guard. Same reasoning the bundler already applies to
|
|
21
|
+
* utilities.css (role helpers must outrank component defaults) — see
|
|
22
|
+
* scripts/bundle-css.mjs.
|
|
23
|
+
*
|
|
24
|
+
* What does NOT belong here: a gate that only has to beat the UA or another
|
|
25
|
+
* rule in its own file. Keep those next to what they modify; this file is for
|
|
26
|
+
* the cross-file case, or it becomes a dumping ground and the cascade gets
|
|
27
|
+
* harder to read rather than easier.
|
|
28
|
+
*
|
|
29
|
+
* A build test (test/build.test.mjs) asserts the bundle contains no gated
|
|
30
|
+
* declaration that a later ungated rule overrides at equal-or-lower
|
|
31
|
+
* specificity. It asserts an EMPTY SET — no allowlist, because an allowlist
|
|
32
|
+
* is where violations go to be forgotten.
|
|
33
|
+
* ════════════════════════════════════════════════════════════════════ */
|
|
34
|
+
|
|
35
|
+
/* Windows High Contrast / forced-colors: opt into the system palette so
|
|
36
|
+
borders and focus stay visible when the user's palette replaces ours.
|
|
37
|
+
|
|
38
|
+
.juno-badge additionally sets `forced-color-adjust: none` to keep its status
|
|
39
|
+
fill meaningful — and that opt-out is why the badge was the one element where
|
|
40
|
+
the lost cascade actually reached the screen: it also disables the UA's own
|
|
41
|
+
repaint, which was silently rescuing .juno-btn and .juno-card. Measured, not
|
|
42
|
+
inferred (20260815-029). */
|
|
43
|
+
@media (forced-colors: active) {
|
|
44
|
+
*:focus-visible { outline-color: Highlight; }
|
|
45
|
+
|
|
46
|
+
.juno-badge,
|
|
47
|
+
.juno-btn,
|
|
48
|
+
.juno-card,
|
|
49
|
+
.juno-readout { border: 1px solid CanvasText; }
|
|
50
|
+
.juno-badge { forced-color-adjust: none; } /* keep status fill meaningful */
|
|
51
|
+
}
|
|
@@ -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
|
+
}
|