@fusion.dev/helix-diagnostics 0.206.0 → 0.207.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/README.md CHANGED
@@ -3,6 +3,8 @@
3
3
  Finds the Helix mistakes that are valid CSS and therefore silent: a `::part()`
4
4
  naming a part nothing publishes, a custom property named for the wrong family, a
5
5
  part a composed component never forwarded, two copies of the library on one page.
6
+ [Diagnostics](https://fusion.dev/helix/docs/diagnostics) in the Helix
7
+ documentation walks through the setup.
6
8
 
7
9
  ```sh
8
10
  npm install --save-dev @fusion.dev/helix-diagnostics
@@ -10,14 +12,44 @@ npm install --save-dev @fusion.dev/helix-diagnostics
10
12
 
11
13
  ## Start it in your app
12
14
 
13
- Call `initDiagnostics()` once, at your app's entry, before the app mounts:
15
+ Call `initDiagnostics()` once, at your app's entry, before the app mounts. To
16
+ keep it out of what you ship, put the call in a module of its own and load that
17
+ module in development only:
14
18
 
15
19
  ```js
20
+ // diagnostics.js
16
21
  import { initDiagnostics } from '@fusion.dev/helix-diagnostics'
17
22
 
18
- initDiagnostics({ environment: import.meta.env.MODE })
23
+ initDiagnostics({ environment: 'development' })
19
24
  ```
20
25
 
26
+ ```js
27
+ // main.js
28
+ if (import.meta.env.DEV) {
29
+ void import('./diagnostics.js')
30
+ }
31
+ ```
32
+
33
+ `vite build` replaces `import.meta.env.DEV` with `false` and drops the import
34
+ with it, so a production page never loads diagnostics. Next.js does the same
35
+ with `process.env.NODE_ENV === 'development'`, and Angular with `isDevMode()`.
36
+ Every Helix template starts diagnostics this way.
37
+
38
+ To sample production pages as well, call it from the entry itself and declare
39
+ where each build runs:
40
+
41
+ ```js
42
+ import { initDiagnostics } from '@fusion.dev/helix-diagnostics'
43
+
44
+ initDiagnostics({
45
+ environment: import.meta.env.MODE,
46
+ environments: { production: { sample: 0.1 } },
47
+ })
48
+ ```
49
+
50
+ `environment` takes `development`, `ci`, or `production`. Any other value, such
51
+ as a `staging` mode, is printed in the console, and nothing starts.
52
+
21
53
  It needs nothing from a framework, so the call is the same wherever Helix runs:
22
54
 
23
55
  - `main.ts` in Vite, Vue, Svelte, or Solid;
@@ -35,9 +67,13 @@ so React's strict mode and hot module replacement start nothing twice.
35
67
  - **Counts from the start**, every Helix element that renders, including ones
36
68
  that go, such as a closed dialog.
37
69
  - **Loads the lint data for the exact Helix version on the page**, from a public
38
- npm CDN, about 18 KB compressed for each tier. Offline, or on a locked-down
39
- network, pass it yourself:
70
+ npm CDN, one file for each tier. Offline, or on a locked-down network, pass it
71
+ yourself:
40
72
  `tiers: [{ lint }]` with `import lint from '@fusion.dev/helix/lint.json'`.
73
+ An add-on package's lint data is passed the same way, and only that way:
74
+ `{ lint: sandbox }` with
75
+ `import sandbox from '@fusion.dev/helix-sandbox/lint.json'`. Without it,
76
+ each of the add-on's elements is an unknown tag.
41
77
  - **Runs itself**: after the page loads, after a navigation, and after the page
42
78
  changes, once changes stop for a second, in an idle moment, and never sooner
43
79
  than two seconds after the last run, nor sooner than twenty times its
@@ -72,7 +108,7 @@ throws stops neither the others nor the console.
72
108
 
73
109
  | Option | What it is |
74
110
  | --- | --- |
75
- | `environment` | `development`, `ci`, or `production`, as your build says it |
111
+ | `environment` | `development`, `ci`, or `production`, as your build says it. Any other value is printed, and nothing starts |
76
112
  | `tiers` | Lint data or manifests to check against, where you pass them yourself |
77
113
  | `cdn` | Where lint data loads from; `false` loads nothing |
78
114
  | `page` | A function returning the page's path as your router names it, such as `/products/[id]` |
@@ -119,18 +155,22 @@ The address is always on a local host.
119
155
  - **It serves your settings.** Where you pass no `file`, the page loads
120
156
  `helix-diagnostics.json` from the directory the command runs in, so you do
121
157
  not need to import it. Pass `--config <path>` for a file elsewhere. A file
122
- that does not parse is not served.
158
+ that does not parse is not served, and the command prints why.
123
159
  - **It writes the CI file.** With `--out reports/helix-diagnostics.json`, it
124
160
  keeps the latest run of every page there, in the shape `ciSink` writes.
125
161
  - **It takes runs from pages on a local host only**: `localhost`, `127.0.0.1`,
126
162
  `[::1]`, `*.localhost`, and `*.test`. It listens on `127.0.0.1`, so nothing
127
163
  else on your network reaches it, and production pages never send to it.
164
+ - **It exits 3 where it cannot listen**, such as on a port another process
165
+ holds.
128
166
 
129
167
  ## Scan your code
130
168
 
131
169
  `helix scan` reads every stylesheet, template, and module in your repository,
132
170
  judges them against the lint data of the Helix packages you installed, and
133
- writes one run in the shape a page writes, with `source: "code"`:
171
+ writes one run in the shape a page writes, with `source: "code"`. It reads the
172
+ lint data of each installed `@fusion.dev` package that publishes `./lint.json`
173
+ and is not private, an add-on such as `@fusion.dev/helix-sandbox` among them:
134
174
 
135
175
  ```sh
136
176
  npx helix scan
@@ -164,15 +204,22 @@ npx helix scan --build dist --out reports/helix-diagnostics.json
164
204
  - **With `--build`, it weighs your bundle**: what each Helix family and
165
205
  package adds to it, from its source maps, as `performance.weight`. Turn
166
206
  source maps on in your bundler, and pass the folder it writes the browser's
167
- files to, such as `dist` or `.next/static`.
207
+ files to, such as `dist` or `.next/static`. A bundle's map is the one the
208
+ comment on its last line names, or the `.map` file beside it, so a hidden
209
+ map is read too.
168
210
  - **With `--out`, it writes its run to the CI file**, and keeps the runs
169
211
  `helix diagnostics` wrote there. The relay keeps the scan's run the same
170
212
  way, so the two share one file, in either order.
171
213
 
172
214
  It reads `helix-diagnostics.json` from the project, then from the directory it
173
215
  runs in, or the file `--config <path>` names, and refuses a setting that names
174
- nothing, as the page does. It exits 1 on a finding at `error`, and 3 where the
175
- project installs no Helix lint data. `--json` prints the run.
216
+ nothing, as the page does. `--json` prints the run. It exits 1 on a finding at
217
+ `error`, and 3 where it cannot run, such as where:
218
+
219
+ - no installed Helix package has lint data;
220
+ - the settings file does not parse, or holds a setting or a value the page
221
+ refuses;
222
+ - the project's folder, or the `--build` folder, does not exist.
176
223
 
177
224
  ## Run it on a page, from the command line
178
225
 
@@ -187,7 +234,9 @@ npx helix diagnostics --once http://localhost:5173/ --once http://localhost:5173
187
234
  ```
188
235
 
189
236
  It needs `@fusion.dev/helix-cli` and Playwright installed beside this package.
190
- It exits 1 on a finding at `error`, and 3 where a page could not be opened.
237
+ It judges each page against the lint data `helix scan` reads, add-on packages'
238
+ included. It exits 1 on a finding at `error`, and 3 where a page could not be
239
+ opened, or where this package or Helix is not installed.
191
240
  `--out` writes the runs to the CI file, and `--json` prints one document a
192
241
  page. An agent gets the same runs from the MCP tool `helix_check_page`.
193
242
 
@@ -208,7 +257,8 @@ report(diagnose({ tiers: [{ lint: styled }, { lint: headless }] }))
208
257
  every part name is unknown and every finding would be noise. It takes lint data
209
258
  or a full `custom-elements.json`. Pass both tiers when both are installed, since
210
259
  a part the headless tier publishes and the styled tier never forwarded is only
211
- visible with both. `report` is the console sink; the `Report` it returns is the
260
+ visible with both. Pass an add-on package's lint data beside them, such as
261
+ `@fusion.dev/helix-sandbox/lint.json`, for its elements. `report` is the console sink; the `Report` it returns is the
212
262
  same object, so you can send it somewhere else as well.
213
263
 
214
264
  None of these faults produces an error anywhere. They are detectable only against
@@ -252,7 +302,9 @@ A report is one run, in one shape for every sink, versioned by `schema`:
252
302
  watcher it counts every element that rendered since `watch()` began,
253
303
  including ones since removed, such as a closed dialog. Without one it counts
254
304
  the elements on the page at the moment of the run. Elements named `helix-*`
255
- that no manifest lists, such as your own, are counted apart as `unlisted`.
305
+ that no manifest lists are counted apart as `unlisted`, and each is also an
306
+ `unknown-tag` finding. For a tag of your own, turn that kind off with
307
+ `rules: { 'unknown-tag': { severity: 'off', tags: ['helix-mine'] } }`.
256
308
  - **`configuration`**: the settings the run was made under, each override in
257
309
  effect, and how many findings each override turned off, by kind.
258
310
  - **`withheld`**: the categories your configuration turned off for sending,
@@ -280,12 +332,14 @@ works on it:
280
332
 
281
333
  | Kind | Severity | Means |
282
334
  | --- | --- | --- |
283
- | `dead-part` | error | A `::part()` names a part the element does not publish |
335
+ | `dead-part` | error | A `::part()` names a part the element does not publish, or follows another `::part()`, which is not valid CSS. The browser drops a rule of the second kind before the page reads it, so `helix scan` finds it |
284
336
  | `dead-hook` | error | A hook is set where nothing answers it, or a name close to a hook is set that nothing reads |
285
337
  | `unknown-attribute` | error | A Helix element carries an attribute it does not take |
286
338
  | `unknown-slot` | error | A child names a slot its Helix element does not have, so it is not drawn. Found by `helix scan`, from the templates |
287
339
  | `invalid-value` | error | An attribute with a closed set of values carries one outside it, so the element falls back to its default |
288
- | `unforwarded-part` | warn | The headless tier publishes the part and the styled tier does not forward it, which is a gap in Helix |
340
+ | `false-boolean` | error | A boolean attribute is written `="false"`, which turns it on: the element reads it as on wherever it is present, as `disabled` is. Remove the attribute to turn it off. An attribute that `"false"` turns off, such as Drawer's `modal`, is not one. `helix scan` leaves a framework's binding, such as Lit's `?disabled=${false}` or Vue's `:disabled="false"`, to the page, which judges what it writes |
341
+ | `nested-interactive` | error | An interactive element is inside a button or a link, which HTML forbids: a Helix element inside a native `<button>` or an `<a>` with an `href`, or anything a person presses or types in inside a Helix element drawn inside one, such as `helix-icon-button` inside `helix-button` |
342
+ | `unforwarded-part` | warn | The element's headless tag, such as `helix-headless-button` for `helix-button`, publishes the part and the styled tag does not forward it, which is a gap in Helix |
289
343
  | `unknown-tag` | error | A rule or an element names a tag no manifest lists |
290
344
  | `unknown-event` | error | A React `on` prop on a Helix element names an event the element does not dispatch, so the handler never runs. Found by `helix scan`, from the templates |
291
345
  | `unimported-tag` | warn | An element is written and nothing in the project imports the entry that registers it, so the browser draws an unknown element. Found by `helix scan`, from the imports |
@@ -308,6 +362,32 @@ for another route.
308
362
  attributes, `aria-*`, `data-*`, and the prefixes frameworks write are never
309
363
  findings. The markup Helix renders inside its own components is Helix's.
310
364
 
365
+ **Nesting is judged through elements whose place the markup settles.** The
366
+ Helix elements drawn inside a native button are Button, Icon Button, Copy
367
+ Button, Toggle, Motion Toggle, Radio, and Tab, and Skip Link is drawn inside a
368
+ link. A slot for actions, such as a card's `footer-actions`, holds a button
369
+ outside anything pressable, and is not a finding. A custom element of your own,
370
+ or a framework's component such as a router's `<Link>`, places its children
371
+ itself, so the check stops there: `helix scan` stops at the component, and the
372
+ page sees the `<a>` it renders.
373
+
374
+ ## Scope
375
+
376
+ The scan and the page check what the lint data can settle. They do not check:
377
+
378
+ - the value a token or a hook is set to, such as `--helix-radii-md: red`.
379
+ `helix scan` checks each `--helix-` name you write;
380
+ - a selector that descends into a component, such as `helix-card .title` or
381
+ `helix-button span`. It matches only the elements you put inside the
382
+ component;
383
+ - a `::part()` on a selector with no Helix tag in it, such as
384
+ `.cta::part(label)`, since the class names no element whose parts can be
385
+ read;
386
+ - an attribute value an expression sets, such as `tone={tone}` in JSX or
387
+ `:tone` in Vue, in `helix scan`. The page judges the attribute it writes;
388
+ - the contrast of a color you write yourself. `helix theme audit` checks the
389
+ theme's tokens.
390
+
311
391
  ## Runtime events
312
392
 
313
393
  Helix reports these itself as it runs, through one channel in
@@ -336,6 +416,7 @@ diagnostics counts it by kind and tag. None fails a CI job.
336
416
  | `missing-trigger` | A tooltip's `for` names no element in its own tree |
337
417
  | `negative-value` | An area chart was given a negative number, which it draws as none |
338
418
  | `not-a-graph` | `connectGraph` was given an element that is not a graph frame |
419
+ | `not-a-number` | A numeric attribute holds something that is not a number, such as `page-size="1O"`, or an attribute in milliseconds something that is not a time, such as `delay="soon"`, so the property keeps its default. Development builds only (the `development` export condition) |
339
420
  | `not-a-plot` | `connectSpatialScatterChart` or `connectSpatialSurfaceChart` was given an element that is not the chart it connects |
340
421
  | `picture-in-picture-refused` | The browser refused picture in picture |
341
422
  | `refused-length` | A grid's `min` is not a length it can write, so it lays out by `columns` |
@@ -349,6 +430,7 @@ diagnostics counts it by kind and tag. None fails a CI job.
349
430
  | `typed-marks` | A quote's text starts or ends with a typed quotation mark, which the browser's marks double |
350
431
  | `unknown-event` | A React `on` prop names an event that no element dispatches. Development builds only (the `development` export condition) |
351
432
  | `unknown-icon` | An icon's `name` is not in the Helix set, so it draws nothing |
433
+ | `unknown-language` | A sandbox editor's `language` is not a language it reads, so its source is drawn plain |
352
434
  | `unlinked-trigger` | A tooltip's trigger does not name it in `aria-describedby` |
353
435
  | `unreachable-label` | A text is rendered as a label, a deprecated value that labels no control |
354
436
  | `unregistered-part` | A part with an entry of its own, such as a filter menu, is in the page and its entry was never imported |
@@ -425,14 +507,16 @@ diagnose({
425
507
  | `detail.evidence` | `full`, `selector`, or `none` | `full` |
426
508
  | `detail.hookValues` | `true` or `false` | `true` |
427
509
  | `detail.pages` | `patterns`, `full` (the path, never the query), or `none` | `patterns` |
428
- | `environments.<name>` | `true` or `false`; `production` takes `{ sample }` from 0 to 1 | development and CI on |
510
+ | `environments.<name>` | `true` or `false`, for sending runs from that environment. `production` takes `false` or `{ sample }` from 0 to 1, which is also the share of production page loads diagnostics runs on | development and CI on |
429
511
  | `project` | `key`, `name`, and `tags` | none |
430
512
 
431
513
  `send`, `detail`, and `environments` decide what the diagnostics service is
432
514
  sent, and the service is not built yet. They are recorded in every report, so a
433
- dashboard knows what a project withheld. The relay runs on your own machine and
434
- receives each run whole. A setting or a value that does not exist throws, and
435
- names the closest that does.
515
+ dashboard knows what a project withheld. `environments.production` also
516
+ decides whether diagnostics runs in production at all, and
517
+ `environments.development` and `environments.ci` change nothing on the page.
518
+ The relay runs on your own machine and receives each run whole. A setting or a
519
+ value that does not exist throws, and names the closest that does.
436
520
 
437
521
  **What the page requests**: the lint data, from `cdn`, once per page load, and
438
522
  the relay, where `sinks.relay` is on. Nothing else leaves the page.
package/dist/analyze.d.ts CHANGED
@@ -87,9 +87,11 @@ export declare const skew: (tags: Map<string, Published>, from: Document, page?:
87
87
  */
88
88
  export declare const versionSkew: (manifests: Record<string, string>, seen: string[]) => Raw[];
89
89
  /**
90
- * The attributes on the page's Helix elements that do nothing: a name the
91
- * element does not take, and a value outside the ones a closed set allows.
92
- * Both are valid HTML and silent, since the element falls back to its default.
90
+ * The attributes on the page's Helix elements that do nothing, or the
91
+ * opposite of what they say: a name the element does not take, a value
92
+ * outside the ones a closed set allows, and `"false"` on an attribute that is
93
+ * on wherever it is present. And an interactive element inside a button or a
94
+ * link. Each is valid to the parser and silent.
93
95
  *
94
96
  * **Only what the page wrote.** An element Helix renders inside another is
95
97
  * Helix's own markup. One finding per tag, attribute, and value, however many
package/dist/analyze.js CHANGED
@@ -214,6 +214,18 @@ const unreflected = (selector, located, tags, from) => {
214
214
  }
215
215
  return found;
216
216
  };
217
+ const headlessOf = (tag) => tag.startsWith('helix-') && !tag.startsWith('helix-headless-')
218
+ ? `helix-headless-${tag.slice('helix-'.length)}`
219
+ : undefined;
220
+ const listed = (names) => {
221
+ const quoted = names.slice(0, 3).map((name) => `\`${name}\``);
222
+ if (names.length > 3) {
223
+ return `${quoted.join(', ')}, and ${names.length - 3} more`;
224
+ }
225
+ return quoted.length < 3
226
+ ? quoted.join(' and ')
227
+ : `${quoted.slice(0, 2).join(', ')}, and ${quoted[2]}`;
228
+ };
217
229
  const origin = (located) => ({
218
230
  cssText: located.rule.cssText,
219
231
  index: located.index,
@@ -276,12 +288,12 @@ export const analyze = (rules, tags, from, answered = new Map(), used = new Set(
276
288
  if (publishers) {
277
289
  continue;
278
290
  }
279
- const elsewhere = [...tags].find(([other, entry]) => other !== tag && entry.parts.has(part));
280
- if (elsewhere) {
291
+ const headless = headlessOf(tag);
292
+ if (headless !== undefined && tags.get(headless)?.parts.has(part)) {
281
293
  findings.push({
282
294
  expected: part,
283
295
  kind: 'unforwarded-part',
284
- message: `\`${tag}\` does not publish \`${part}\`, though \`${elsewhere[0]}\` does. The rule matches nothing until the part is forwarded, which is a gap in Helix rather than in this CSS.`,
296
+ message: `\`${tag}\` does not publish \`${part}\`, though \`${headless}\` does. The rule matches nothing until the part is forwarded, which is a gap in Helix rather than in this CSS.`,
285
297
  origin: origin(located),
286
298
  subject: `${tag}::part(${part})`,
287
299
  tag,
@@ -349,7 +361,7 @@ export const analyze = (rules, tags, from, answered = new Map(), used = new Set(
349
361
  kind: 'dead-hook',
350
362
  message: suggestion
351
363
  ? `\`${tag}\` does not answer \`${property}\`. It answers \`${suggestion}\`, two edits or fewer away, which is almost certainly what was meant.`
352
- : `\`${tag}\` does not answer \`${property}\`, and nothing under it that does was found in the page. ${[...answerers].sort().slice(0, 3).join(', ')} answer it, so this either targets the wrong element or expects a descendant that is not there.`,
364
+ : `\`${tag}\` does not answer \`${property}\`, and nothing under it that does was found in the page. ${listed([...answerers].sort())} ${answerers.size === 1 ? 'answers' : 'answer'} it, so this either targets the wrong element or expects a descendant that is not there.`,
353
365
  origin: origin(located),
354
366
  subject: `${selector} { ${property} }`,
355
367
  tag,
@@ -482,23 +494,93 @@ const unknownTag = (tags, tag) => {
482
494
  tag,
483
495
  };
484
496
  };
497
+ const NATIVE_INTERACTIVE = {
498
+ a: (element) => element.hasAttribute('href'),
499
+ audio: (element) => element.hasAttribute('controls'),
500
+ button: () => true,
501
+ details: () => true,
502
+ embed: () => true,
503
+ iframe: () => true,
504
+ img: (element) => element.hasAttribute('usemap'),
505
+ input: (element) => (element.getAttribute('type') ?? '').toLowerCase() !== 'hidden',
506
+ label: () => true,
507
+ select: () => true,
508
+ textarea: () => true,
509
+ video: (element) => element.hasAttribute('controls'),
510
+ };
511
+ const enclosing = (inner, tags) => {
512
+ for (let at = inner.parentElement; at; at = at.parentElement) {
513
+ const name = at.localName;
514
+ const published = tags.get(name);
515
+ if (published?.interactive === 'button' || published?.interactive === 'link') {
516
+ return { element: at, noun: published.interactive };
517
+ }
518
+ if (name === 'button') {
519
+ return { element: at, noun: 'button' };
520
+ }
521
+ if (name === 'a' && at.hasAttribute('href')) {
522
+ return { element: at, noun: 'link' };
523
+ }
524
+ if (name.includes('-') && !published) {
525
+ return undefined;
526
+ }
527
+ }
528
+ return undefined;
529
+ };
530
+ const nested = (element, tags) => {
531
+ const inner = element.localName;
532
+ const kind = tags.get(inner)?.interactive;
533
+ if (kind === undefined && !NATIVE_INTERACTIVE[inner]?.(element)) {
534
+ return undefined;
535
+ }
536
+ const found = enclosing(element, tags);
537
+ if (!found) {
538
+ return undefined;
539
+ }
540
+ const outer = found.element.localName;
541
+ const helix = tags.has(outer);
542
+ if (!(helix || kind)) {
543
+ return undefined;
544
+ }
545
+ const named = (tag, article) => tags.has(tag) ? `\`${tag}\`` : `${article} \`<${tag}>\``;
546
+ const some = (tag) => named(tag, /^[aeiou]/.test(tag) ? 'an' : 'a');
547
+ const opening = some(inner);
548
+ const hint = outer === 'a' && kind === 'button'
549
+ ? ' To draw the link as a button, write `data-button` on the `<a>`.'
550
+ : '';
551
+ return {
552
+ kind: 'nested-interactive',
553
+ message: `${opening.charAt(0).toUpperCase()}${opening.slice(1)} is inside ${some(outer)}${helix ? `, which draws its content inside a native ${found.noun}` : ''}. HTML allows no interactive element inside a button or a link, and browsers and screen readers differ on what a press or a focus there does. Move ${named(inner, 'the')} out of ${named(outer, 'the')}.${hint}`,
554
+ subject: `<${outer}> <${inner}>`,
555
+ tag: helix ? outer : inner,
556
+ };
557
+ };
485
558
  export const markup = (tags, from, page = pageOf(from)) => {
486
559
  const found = new Map();
560
+ const keep = (raw) => {
561
+ const key = `${raw.kind} ${raw.subject}`;
562
+ const seen = found.get(key);
563
+ if (seen) {
564
+ seen.count += 1;
565
+ }
566
+ else {
567
+ found.set(key, { ...raw, count: 1 });
568
+ }
569
+ };
487
570
  for (const element of page.elements) {
488
571
  const host = element.getRootNode();
489
572
  const composed = host instanceof ShadowRoot && host.host.localName.startsWith('helix-');
490
573
  const published = composed ? undefined : tags.get(element.localName);
574
+ if (!composed) {
575
+ const raw = nested(element, tags);
576
+ if (raw) {
577
+ keep(raw);
578
+ }
579
+ }
491
580
  if (!(composed || published) && tags.size > 0) {
492
581
  const raw = unknownTag(tags, element.localName);
493
582
  if (raw) {
494
- const key = `${raw.kind} ${raw.subject}`;
495
- const seen = found.get(key);
496
- if (seen) {
497
- seen.count += 1;
498
- }
499
- else {
500
- found.set(key, { ...raw, count: 1 });
501
- }
583
+ keep(raw);
502
584
  }
503
585
  }
504
586
  if (published) {
@@ -523,6 +605,14 @@ export const markup = (tags, from, page = pageOf(from)) => {
523
605
  tag,
524
606
  };
525
607
  }
608
+ else if (published.booleans?.has(name) && value.toLowerCase() === 'false') {
609
+ raw = {
610
+ kind: 'false-boolean',
611
+ message: `\`${name}="${value}"\` turns \`${name}\` on. \`${tag}\` reads the attribute as on wherever it is present, whatever its value. Remove the attribute to turn it off. In a framework, bind it, such as \`?${name}=\${false}\` in Lit or \`:${name}="false"\` in Vue.`,
612
+ subject: `<${tag} ${name}="${value}">`,
613
+ tag,
614
+ };
615
+ }
526
616
  else if (allows && value !== '' && !allows.has(value)) {
527
617
  const suggestion = nearest(value, allows);
528
618
  raw = {
@@ -534,14 +624,7 @@ export const markup = (tags, from, page = pageOf(from)) => {
534
624
  };
535
625
  }
536
626
  if (raw) {
537
- const key = `${raw.kind} ${raw.subject}`;
538
- const seen = found.get(key);
539
- if (seen) {
540
- seen.count += 1;
541
- }
542
- else {
543
- found.set(key, { ...raw, count: 1 });
544
- }
627
+ keep(raw);
545
628
  }
546
629
  }
547
630
  }
package/dist/config.d.ts CHANGED
@@ -75,6 +75,12 @@ export declare const DEFAULTS: {
75
75
  export declare const LOCAL: RegExp;
76
76
  /** Throws on a setting that names nothing, or a value it cannot take. */
77
77
  export declare const checkConfig: (config: Config | undefined, layer: Exclude<Layer, 'shipped'>) => void;
78
+ /**
79
+ * Throws on an `environment` that names none, and names the closest. Taken as
80
+ * declared, `staging`, which a bundler's mode hands over as readily as
81
+ * `development`, would run on a deployed host with the console and the relay.
82
+ */
83
+ export declare const checkEnvironment: (environment: unknown) => void;
78
84
  /** The settings in effect, from the defaults, the file, and the call. */
79
85
  export interface Resolved {
80
86
  configuration: Omit<Configuration, 'overrides' | 'silenced'>;
package/dist/config.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { nearest } from './manifest.js';
2
- import { CATEGORIES } from './report.js';
2
+ import { CATEGORIES, ENVIRONMENTS, } from './report.js';
3
3
  import { checkRules } from './rules.js';
4
4
  export const DEFAULTS = {
5
5
  console: {
@@ -109,6 +109,14 @@ export const checkConfig = (config, layer) => {
109
109
  }
110
110
  }
111
111
  };
112
+ export const checkEnvironment = (environment) => {
113
+ if (environment === undefined || ENVIRONMENTS.includes(environment)) {
114
+ return;
115
+ }
116
+ const close = typeof environment === 'string' ? nearest(environment, ENVIRONMENTS) : undefined;
117
+ throw new Error(`The argument sets \`environment\` to \`${JSON.stringify(environment)}\`. It takes ${ENVIRONMENTS.map(shown).join(', ')}.` +
118
+ (close ? ` The closest is \`${close}\`.` : ''));
119
+ };
112
120
  export const resolveConfig = (file, call) => {
113
121
  checkConfig(file, 'file');
114
122
  checkConfig(call, 'argument');
package/dist/diagnose.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { analyze, markup, registrations, skew, versionSkew } from './analyze.js';
2
2
  import { collect } from './collect.js';
3
- import { pagePattern, resolveConfig } from './config.js';
3
+ import { checkEnvironment, pagePattern, resolveConfig } from './config.js';
4
4
  import { identify } from './identity.js';
5
5
  import { index, lintFor, versionsOf } from './manifest.js';
6
6
  import { pageOf } from './page.js';
@@ -17,6 +17,7 @@ export const diagnose = (options) => {
17
17
  if (tiers.length === 0) {
18
18
  throw new Error('diagnose() needs at least one manifest. Without one every part name is unknown and every finding would be noise.');
19
19
  }
20
+ checkEnvironment(environment);
20
21
  const resolved = resolveConfig(file, call);
21
22
  const { detail } = resolved.configuration;
22
23
  const document_ = from ?? globalThis.document;
package/dist/identity.js CHANGED
@@ -3,9 +3,11 @@ const CATEGORY = {
3
3
  'dead-part': 'styling',
4
4
  'disabled-attribute': 'styling',
5
5
  'duplicate-registration': 'integrity',
6
+ 'false-boolean': 'markup',
6
7
  'invalid-value': 'markup',
7
8
  'manifest-skew': 'integrity',
8
9
  'native-element': 'guideline',
10
+ 'nested-interactive': 'markup',
9
11
  'primitive-token': 'styling',
10
12
  'unforwarded-part': 'styling',
11
13
  'unimported-tag': 'integrity',
@@ -21,9 +23,11 @@ const OWNER = {
21
23
  'dead-part': 'consumer',
22
24
  'disabled-attribute': 'consumer',
23
25
  'duplicate-registration': 'consumer',
26
+ 'false-boolean': 'consumer',
24
27
  'invalid-value': 'consumer',
25
28
  'manifest-skew': 'consumer',
26
29
  'native-element': 'consumer',
30
+ 'nested-interactive': 'consumer',
27
31
  'primitive-token': 'consumer',
28
32
  'unforwarded-part': 'helix',
29
33
  'unimported-tag': 'consumer',
package/dist/init.js CHANGED
@@ -1,4 +1,4 @@
1
- import { checkConfig, LOCAL, resolveConfig } from './config.js';
1
+ import { checkConfig, checkEnvironment, LOCAL, resolveConfig } from './config.js';
2
2
  import { report as print } from './console.js';
3
3
  import { diagnose } from './diagnose.js';
4
4
  import { overlayOn } from './overlay.js';
@@ -56,6 +56,7 @@ const start = (options, page) => {
56
56
  const { cdn = CDN, duplicates, environment, file, onReport, page: route, tiers, ...call } = options;
57
57
  let resolved;
58
58
  try {
59
+ checkEnvironment(environment);
59
60
  resolved = resolveConfig(file, call);
60
61
  }
61
62
  catch (error) {
@@ -35,8 +35,21 @@ export interface ManifestSource {
35
35
  text?: string;
36
36
  };
37
37
  }>;
38
+ /**
39
+ * Helix's own fields: `interactive`, the native control the element
40
+ * draws its content inside, from `@interactive`, and `family`, the family
41
+ * an element joins from another package, from `@family`.
42
+ */
43
+ helix?: {
44
+ family?: string;
45
+ interactive?: string;
46
+ };
38
47
  members?: Array<{
39
48
  attribute?: string;
49
+ /** How the attribute turns a boolean property on, where it is one. */
50
+ helix?: {
51
+ boolean?: string;
52
+ };
40
53
  kind?: string;
41
54
  name: string;
42
55
  readonly?: boolean;
@@ -78,9 +91,26 @@ export interface LintData {
78
91
  export interface LintTag {
79
92
  /** Each attribute, with the values it allows, or `null` where it takes any. */
80
93
  attributes: Record<string, string[] | null>;
81
- /** The family, from the module's path. */
94
+ /**
95
+ * The attributes that are on wherever they are present, `"false"`
96
+ * included: each turns on a boolean property Lit reads with
97
+ * `type: Boolean`. Absent from lint data written before this field, which a
98
+ * reader takes as unknown.
99
+ */
100
+ booleans?: string[];
101
+ /** The family, from `@family` or else from the module's path. */
82
102
  family?: string | undefined;
83
103
  hooks: string[];
104
+ /**
105
+ * What a person presses or types in, where the element is one: `button` or
106
+ * `link` for an element that draws its content inside its own native button
107
+ * or link, and `control` for any other form control. Absent where the
108
+ * element is neither. **Typed as any `string`**: a page passes the
109
+ * `lint.json` it imports, and TypeScript reads every string in an imported
110
+ * JSON file as `string`, so the {@link Interactive} union here refused every
111
+ * page's `{ lint }`. `index` keeps only the kinds it knows.
112
+ */
113
+ interactive?: string;
84
114
  parts: string[];
85
115
  /**
86
116
  * The slots the element has, by name, the default one as `""`. Absent from
@@ -96,6 +126,12 @@ export interface LintTag {
96
126
  /** The attributes the element writes on itself: one named for a read-only member. */
97
127
  written: string[];
98
128
  }
129
+ /**
130
+ * What a person presses or types in: an element drawn inside a native
131
+ * `button` or `link`, which HTML allows no interactive element inside, or
132
+ * another form `control`.
133
+ */
134
+ export type Interactive = 'button' | 'control' | 'link';
99
135
  /**
100
136
  * One installed tier: its manifest, with the package name, or its lint data,
101
137
  * which names the package itself.
@@ -123,9 +159,17 @@ export interface Published {
123
159
  * is a union of string literals, and `null` where it takes any value.
124
160
  */
125
161
  attributes: Map<string, Set<string> | null>;
162
+ /**
163
+ * The attributes that are on wherever they are present, `"false"`
164
+ * included. Absent where a tier's lint data predates the field, which says
165
+ * nothing either way.
166
+ */
167
+ booleans?: Set<string> | undefined;
126
168
  /** The family, from the module's path: `button` for `src/button/root/button-root.ts`. */
127
169
  family?: string | undefined;
128
170
  hooks: Map<string, Set<string>>;
171
+ /** What a person presses or types in, where the element is one. */
172
+ interactive?: Interactive | undefined;
129
173
  /**
130
174
  * The attributes a property writes back when it is set. Absent where a tier's
131
175
  * lint data predates the field, which says nothing either way.
package/dist/manifest.js CHANGED
@@ -1,7 +1,8 @@
1
+ const isInteractive = (kind) => kind === 'button' || kind === 'control' || kind === 'link';
1
2
  export const lintOf = (manifest, name) => {
2
3
  const tags = {};
3
4
  for (const module of manifest.modules ?? []) {
4
- const family = /^src\/([^/]+)\//.exec(module.path ?? '')?.[1];
5
+ const folder = /^src\/([^/]+)\//.exec(module.path ?? '')?.[1];
5
6
  for (const declaration of module.declarations ?? []) {
6
7
  const tag = declaration.tagName;
7
8
  if (!tag) {
@@ -9,7 +10,8 @@ export const lintOf = (manifest, name) => {
9
10
  }
10
11
  const entry = tags[tag] ?? {
11
12
  attributes: {},
12
- family,
13
+ booleans: [],
14
+ family: declaration.helix?.family ?? folder,
13
15
  hooks: [],
14
16
  parts: [],
15
17
  reflected: [],
@@ -17,10 +19,20 @@ export const lintOf = (manifest, name) => {
17
19
  written: [],
18
20
  };
19
21
  tags[tag] = entry;
22
+ const drawn = declaration.helix?.interactive;
23
+ if (drawn === 'button' || drawn === 'link') {
24
+ entry.interactive = drawn;
25
+ }
26
+ else if ((declaration.members ?? []).some((member) => member.name === 'form' && member.kind === 'field' && !member.static)) {
27
+ entry.interactive = 'control';
28
+ }
20
29
  for (const member of declaration.members ?? []) {
21
30
  if (member.reflects && member.attribute) {
22
31
  entry.reflected?.push(member.attribute);
23
32
  }
33
+ if (member.attribute && member.helix?.boolean === 'present') {
34
+ entry.booleans?.push(member.attribute);
35
+ }
24
36
  if (member.readonly && !member.static && member.kind === 'field') {
25
37
  entry.written.push(member.name, member.name.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`));
26
38
  }
@@ -40,6 +52,7 @@ export const lintOf = (manifest, name) => {
40
52
  entry.hooks = [...new Set(entry.hooks)];
41
53
  entry.slots = [...new Set(entry.slots)];
42
54
  entry.reflected = [...new Set(entry.reflected)];
55
+ entry.booleans = [...new Set(entry.booleans)];
43
56
  }
44
57
  return {
45
58
  package: manifest.helix?.package ?? name,
@@ -67,6 +80,15 @@ export const index = (tiers) => {
67
80
  for (const name of entry.written) {
68
81
  element.written.add(name);
69
82
  }
83
+ if (isInteractive(entry.interactive)) {
84
+ element.interactive ??= entry.interactive;
85
+ }
86
+ if (entry.booleans) {
87
+ element.booleans ??= new Set();
88
+ for (const name of entry.booleans) {
89
+ element.booleans.add(name);
90
+ }
91
+ }
70
92
  if (entry.reflected) {
71
93
  element.reflected ??= new Set();
72
94
  for (const name of entry.reflected) {
package/dist/report.d.ts CHANGED
@@ -50,8 +50,9 @@ export interface Origin {
50
50
  export interface Finding {
51
51
  /**
52
52
  * What the fault is about: `styling` for parts and hooks, `markup` for
53
- * attributes and slots, `integrity` for the page and the library
54
- * disagreeing, `guideline` for advice. A dashboard groups by it.
53
+ * attributes, slots, and what an element holds, `integrity` for the page
54
+ * and the library disagreeing, `guideline` for advice. A dashboard groups
55
+ * by it.
55
56
  */
56
57
  category: FindingCategory;
57
58
  /**
@@ -97,7 +98,7 @@ export interface Finding {
97
98
  tag: string;
98
99
  }
99
100
  /** Every fault class, stable across versions. */
100
- export declare const KINDS: readonly ['dead-hook', 'dead-part', 'disabled-attribute', 'duplicate-registration', 'invalid-value', 'manifest-skew', 'native-element', 'primitive-token', 'unforwarded-part', 'unimported-tag', 'unknown-attribute', 'unknown-event', 'unknown-slot', 'unknown-tag', 'unknown-token', 'unreflected-attribute'];
101
+ export declare const KINDS: readonly ['dead-hook', 'dead-part', 'disabled-attribute', 'duplicate-registration', 'false-boolean', 'invalid-value', 'manifest-skew', 'native-element', 'nested-interactive', 'primitive-token', 'unforwarded-part', 'unimported-tag', 'unknown-attribute', 'unknown-event', 'unknown-slot', 'unknown-tag', 'unknown-token', 'unreflected-attribute'];
101
102
  /** A fault class. */
102
103
  export type Kind = (typeof KINDS)[number];
103
104
  /** How serious a finding is. */
@@ -199,8 +200,10 @@ export interface RuntimeEntry {
199
200
  /** The tag of the element it happened on, where there is one. */
200
201
  tag?: string;
201
202
  }
203
+ /** Where a run can be declared to happen. */
204
+ export declare const ENVIRONMENTS: readonly ['ci', 'development', 'production'];
202
205
  /** Where a run was declared to happen. Undeclared is never sent. */
203
- export type Environment = 'ci' | 'development' | 'production';
206
+ export type Environment = (typeof ENVIRONMENTS)[number];
204
207
  /**
205
208
  * A sheet the collector could not read, and the reason. For a scan, a file
206
209
  * that did not parse is `threw`, and a stylesheet in a language the scan does
package/dist/report.js CHANGED
@@ -3,9 +3,11 @@ export const KINDS = [
3
3
  'dead-part',
4
4
  'disabled-attribute',
5
5
  'duplicate-registration',
6
+ 'false-boolean',
6
7
  'invalid-value',
7
8
  'manifest-skew',
8
9
  'native-element',
10
+ 'nested-interactive',
9
11
  'primitive-token',
10
12
  'unforwarded-part',
11
13
  'unimported-tag',
@@ -25,3 +27,4 @@ export const CATEGORIES = [
25
27
  'runtime',
26
28
  'usage',
27
29
  ];
30
+ export const ENVIRONMENTS = ['ci', 'development', 'production'];
package/dist/rules.js CHANGED
@@ -5,9 +5,11 @@ export const SHIPPED_RULES = {
5
5
  'dead-part': 'error',
6
6
  'disabled-attribute': 'error',
7
7
  'duplicate-registration': 'error',
8
+ 'false-boolean': 'error',
8
9
  'invalid-value': 'error',
9
10
  'manifest-skew': 'error',
10
11
  'native-element': 'warn',
12
+ 'nested-interactive': 'error',
11
13
  'primitive-token': 'error',
12
14
  'unforwarded-part': 'warn',
13
15
  'unimported-tag': 'warn',
package/dist/version.d.ts CHANGED
@@ -6,4 +6,4 @@
6
6
  * release, so the two never disagree; a spec beside this holds them to it.
7
7
  * Nothing else in the running code knows which release it is.
8
8
  */
9
- export declare const VERSION = "0.206.0";
9
+ export declare const VERSION = "0.207.0";
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const VERSION = '0.206.0';
1
+ export const VERSION = '0.207.0';
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@fusion.dev/helix-diagnostics",
3
3
  "description": "Finds the Helix mistakes that are valid CSS: dead parts, misnamed hooks, two copies of the library.",
4
- "version": "0.206.0",
4
+ "version": "0.207.0",
5
5
  "license": "MIT",
6
6
  "author": "Ross Sheppard <ross.sheppard@fusion.dev>",
7
7
  "repository": {
@@ -20,7 +20,8 @@
20
20
  "./ci": {
21
21
  "types": "./dist/ci.d.ts",
22
22
  "default": "./dist/ci.js"
23
- }
23
+ },
24
+ "./package.json": "./package.json"
24
25
  },
25
26
  "files": [
26
27
  "dist"
@@ -33,7 +34,7 @@
33
34
  },
34
35
  "bugs": "https://github.com/project-fusion/fusion/issues",
35
36
  "devDependencies": {
36
- "@fusion.dev/helix-testing": "0.206.0",
37
+ "@fusion.dev/helix-testing": "0.207.0",
37
38
  "@types/bun": "^1.4.2"
38
39
  },
39
40
  "homepage": "https://fusion.dev",