@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.
@@ -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-space-12) calc(var(--juno-space-16) + env(safe-area-inset-bottom, 0px));
131
- padding: var(--juno-space-4);
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-width-1) solid 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-space-12) calc(var(--juno-space-16) + env(safe-area-inset-bottom, 0px));
234
- padding: var(--juno-space-4);
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-width-1) solid 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><!-- single wrapper, any element -->
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 — matches .juno-btn--sm; for dense toolbars */
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
+ }