@fusion.dev/helix-diagnostics 0.139.0 → 0.141.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
@@ -49,6 +49,13 @@ so React's strict mode and hot module replacement start nothing twice.
49
49
  counts them and opens a list of each finding with its evidence. Hover over a
50
50
  finding, or focus it, to outline the element it is about. The badge shows
51
51
  only while the page has findings.
52
+ - **Hears what the library reports as it runs**, such as a selectable table
53
+ with no `row-key`, and lists each event in the run, the badge, and the relay.
54
+ Core still prints each one as it happens, with the stack it happened in. Set
55
+ `core: { warnings: false }` to have diagnostics print them in its group
56
+ instead.
57
+ - **Measures what the components cost, where you ask**: each tag's first
58
+ updates, layout shift, and long tasks. See [Performance](#performance).
52
59
  - **Hands each run to your app**: the `onReport` option, `subscribe()` on the
53
60
  handle it returns, and a `helix-diagnostics` event on `document`.
54
61
 
@@ -72,7 +79,7 @@ throws stops neither the others nor the console.
72
79
  | `onReport` | Called with each run |
73
80
  | `duplicates` | `duplicateRegistrations` from `@fusion.dev/helix-core` |
74
81
  | `file` | Your project's `helix-diagnostics.json`, imported |
75
- | Any setting below | `rules`, `console`, `sinks`, `send`, `detail`, `environments`, `project` |
82
+ | Any setting below | `rules`, `console`, `core`, `sinks`, `send`, `detail`, `environments`, `project` |
76
83
 
77
84
  The handle has `active`, `latest`, `run()`, `subscribe()`, and `stop()`. An
78
85
  end-to-end test reaches it at `globalThis[Symbol.for('fusion.dev/helix/diagnostics')]`
@@ -163,6 +170,13 @@ A report is one run, in one shape for every sink, versioned by `schema`:
163
170
  `integrity`, or `guideline`), an `owner` (`consumer`, or `helix` for a fault
164
171
  in the library), a `fingerprint` that stays the same from one run to the next
165
172
  for the same fault, and the rule's own text as evidence.
173
+ - **`performance`**: what the components cost, by tag, where you asked for it.
174
+ See [Performance](#performance).
175
+ - **`runtime`**: what the library reported going wrong as it ran on the page,
176
+ from its first load. One entry per distinct event, with its `kind`, its
177
+ `tag`, the sentence that says what to do, how many times it was reported,
178
+ and whether core printed it. A `fingerprint` stays the same from one run to
179
+ the next, with the numbers in the sentence left out.
166
180
  - **`usage`**: the Helix elements on the page by tag, the axes set on them
167
181
  (`emphasis`, `material`, `separation`, and `tone`), the named slots filled,
168
182
  the elements Helix renders inside other Helix elements, and the parts and
@@ -188,9 +202,11 @@ works on it:
188
202
  | A finding at `error` | `console.error` |
189
203
  | A finding at `warn` | `console.warn` |
190
204
  | A finding at `info` | `console.info` |
205
+ | A runtime event core left to diagnostics | `console.warn`, or `console.error` for a failure |
191
206
  | Stylesheets that could not be read | `console.warn` |
192
207
  | Coverage, and findings turned off | `console.info` |
193
208
  | Usage | `console.debug`, hidden at the default level |
209
+ | Performance, where you asked for it | `console.info`, when the measures change |
194
210
 
195
211
  ## Finding kinds
196
212
 
@@ -216,6 +232,58 @@ for another route.
216
232
  attributes, `aria-*`, `data-*`, and the prefixes frameworks write are never
217
233
  findings. The markup Helix renders inside its own components is Helix's.
218
234
 
235
+ ## Runtime events
236
+
237
+ Helix reports these itself as it runs, through one channel in
238
+ `@fusion.dev/helix-core`. Each is printed in the console as it happens, and
239
+ diagnostics counts it by kind and tag. None fails a CI job.
240
+
241
+ | Kind | Means |
242
+ | --- | --- |
243
+ | `duplicate-registration` | Two copies of Helix are on the page. Also the finding of the same name |
244
+ | `fullscreen-refused` | The browser refused fullscreen, usually in a frame without `allow="fullscreen"` |
245
+ | `max-past-fold` | An avatar group's `max` is past the number its fold is written for |
246
+ | `min-out-of-range` | A rating's `min` is not a score between 0 and its `max` |
247
+ | `misplaced-handler` | A React `on` prop names an event that another element dispatches |
248
+ | `missing-layout` | The three.js graph driver could not load `d3-force-3d` |
249
+ | `missing-row-key` | A selectable data table has no `row-key`, so it knows each row by its place |
250
+ | `missing-stencil-parts` | A graph stencil names parts its shape does not have |
251
+ | `missing-trigger` | A tooltip's `for` names no element in its own tree |
252
+ | `negative-value` | An area chart was given a negative number, which it draws as none |
253
+ | `not-a-graph` | `connectGraph` was given an element that is not a graph frame |
254
+ | `picture-in-picture-refused` | The browser refused picture in picture |
255
+ | `removed-callback` | A React prop is a callback that was removed and never ran from JSX |
256
+ | `solid-handler-syntax` | A React prop uses Solid's `on:` syntax |
257
+ | `stencil-regions-unused` | A graph stencil's regions could not be used |
258
+ | `stencil-without-inside` | A graph stencil has no inside to place the notes in |
259
+ | `too-many-rows` | A data table is drawing more rows at once than it stays responsive to |
260
+ | `two-default-operators` | A filter field marks more than one operator as its default |
261
+ | `unknown-event` | A React `on` prop names an event that no element dispatches |
262
+ | `unlinked-trigger` | A tooltip's trigger does not name it in `aria-describedby` |
263
+ | `unsettled-window` | A virtualized list's box has no height of its own |
264
+ | `untold-trigger` | A slotted trigger is a plain element, which cannot carry `aria-expanded` |
265
+ | `wrapped-trigger` | A tooltip wraps its trigger, so its text reaches no screen reader |
266
+
267
+ ## Performance
268
+
269
+ Diagnostics measures what the components cost only where you ask for it. Set
270
+ `console: { performance: 'info' }` to print the measures in DevTools, or
271
+ `send: { performance: true }` to keep them in each run, for your app, the
272
+ relay, and the CI file. For each tag rendered on the page, it measures:
273
+
274
+ - **First updates**: each element's own first update, from `willUpdate` to
275
+ `updated`, as a count, a median, a 95th percentile, and the slowest. Time an
276
+ element spends waiting behind other elements is not counted.
277
+ - **Layout shift**: the shift its elements moved, summed as the browser scores
278
+ it. A shift that follows a press or a keystroke is left out.
279
+ - **Long tasks**: how many tasks of 50 ms or more one of its first updates ran
280
+ in.
281
+
282
+ Core times each first update only while diagnostics is measuring, so a page
283
+ that has not asked pays one property read per element. Layout shift and long
284
+ tasks are measured in Chromium only. Elsewhere `measured` says so, and their
285
+ numbers are left out.
286
+
219
287
  ## Configure it
220
288
 
221
289
  Every setting can be given in three places, and a later place wins, field by
@@ -250,6 +318,9 @@ diagnose({
250
318
  | `console.findings` | `error`, `warn`, `info`, or `off`: the lowest severity printed | `info` |
251
319
  | `console.coverage` | `info` or `off` | `info` |
252
320
  | `console.usage` | `debug` or `off` | `debug` |
321
+ | `console.runtime` | `warn` or `off`: the runtime events core left to diagnostics | `warn` |
322
+ | `console.performance` | `info` or `off`: what the components cost, which is measured where this is on | `off` |
323
+ | `core.warnings` | `true` or `false`: whether core prints each runtime event as it happens | `true` |
253
324
  | `sinks.overlay` | `true` or `false`, for the badge in development | `true` |
254
325
  | `sinks.relay` | `true`, `false`, or an address on a local host, such as `http://127.0.0.1:7417` | `false` |
255
326
  | `send.<category>` | `true` or `false`, for `findings`, `usage`, `coverage`, `configuration`, `runtime`, `performance`, and `assist` | all on but `performance` |
package/dist/ci.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { mkdirSync, writeFileSync } from 'node:fs';
2
2
  import { dirname } from 'node:path';
3
+ import { shownRuntime } from './runtime.js';
3
4
  const RANK = { error: 3, info: 1, warn: 2 };
4
5
  const counted = (count, noun) => `${count} ${noun}${count === 1 ? '' : 's'}`;
5
6
  export const ciSink = (runs, { file, failOn = 'error' }) => {
@@ -17,8 +18,17 @@ export const ciSink = (runs, { file, failOn = 'error' }) => {
17
18
  const of = (severity) => all.filter(({ finding }) => finding.severity === severity).length;
18
19
  const failing = failOn === 'never' ? [] : all.filter(({ finding }) => RANK[finding.severity] >= RANK[failOn]);
19
20
  const unread = runs.reduce((total, run) => total + run.coverage.unreadable.length, 0);
21
+ const events = new Map();
22
+ for (const run of runs) {
23
+ for (const entry of shownRuntime(run.runtime ?? [])) {
24
+ const event = events.get(entry.fingerprint) ?? { entry, pages: new Set() };
25
+ event.pages.add(run.run.page ?? '(no page)');
26
+ events.set(entry.fingerprint, event);
27
+ }
28
+ }
20
29
  const lines = [
21
30
  `helix diagnostics: ${counted(runs.length, 'run')}, ${counted(of('error'), 'error')}, ${counted(of('warn'), 'warning')}, ${of('info')} info` +
31
+ (events.size > 0 ? `, ${events.size} runtime` : '') +
22
32
  (unread > 0 ? `, ${counted(unread, 'unreadable stylesheet')}` : '') +
23
33
  `. Written to ${file}.`,
24
34
  ];
@@ -26,5 +36,9 @@ export const ciSink = (runs, { file, failOn = 'error' }) => {
26
36
  const where = [...pages].sort();
27
37
  lines.push(` ${finding.severity} ${finding.kind}: ${finding.subject}`, ` ${finding.message}`, ` on ${where.slice(0, 5).join(', ')}${where.length > 5 ? `, and ${where.length - 5} more` : ''}`);
28
38
  }
39
+ for (const { entry, pages } of events.values()) {
40
+ const where = [...pages].sort();
41
+ lines.push(` runtime ${entry.kind}${entry.tag ? ` <${entry.tag}>` : ''}`, ` ${entry.message}`, ` on ${where.slice(0, 5).join(', ')}${where.length > 5 ? `, and ${where.length - 5} more` : ''}`);
42
+ }
29
43
  return { code: failing.length > 0 ? 1 : 0, summary: lines.join('\n') };
30
44
  };
package/dist/config.d.ts CHANGED
@@ -4,6 +4,8 @@ import { type Rules } from './rules.js';
4
4
  export interface Config {
5
5
  /** The lowest level each category prints at in the console, or `off`. */
6
6
  console?: Partial<Configuration['console']>;
7
+ /** What `@fusion.dev/helix-core` does itself: whether it prints its runtime warnings. */
8
+ core?: Partial<Configuration['core']>;
7
9
  /** How much detail goes with what is sent. */
8
10
  detail?: Partial<Configuration['detail']>;
9
11
  /** Where sending happens. Production takes `{ sample }`, from 0 to 1. */
@@ -33,8 +35,13 @@ export declare const DEFAULTS: {
33
35
  readonly console: {
34
36
  readonly coverage: 'info';
35
37
  readonly findings: 'info';
38
+ readonly performance: 'off';
39
+ readonly runtime: 'warn';
36
40
  readonly usage: 'debug';
37
41
  };
42
+ readonly core: {
43
+ readonly warnings: true;
44
+ };
38
45
  readonly detail: {
39
46
  readonly evidence: 'full';
40
47
  readonly hookValues: true;
package/dist/config.js CHANGED
@@ -2,7 +2,14 @@ import { nearest } from './manifest.js';
2
2
  import { CATEGORIES } from './report.js';
3
3
  import { checkRules } from './rules.js';
4
4
  export const DEFAULTS = {
5
- console: { coverage: 'info', findings: 'info', usage: 'debug' },
5
+ console: {
6
+ coverage: 'info',
7
+ findings: 'info',
8
+ performance: 'off',
9
+ runtime: 'warn',
10
+ usage: 'debug',
11
+ },
12
+ core: { warnings: true },
6
13
  detail: { evidence: 'full', hookValues: true, pages: 'patterns' },
7
14
  environments: { ci: true, development: true, production: false },
8
15
  send: {
@@ -32,8 +39,11 @@ const ALLOWED = {
32
39
  console: {
33
40
  coverage: ['info', 'off'],
34
41
  findings: ['error', 'warn', 'info', 'off'],
42
+ performance: ['info', 'off'],
43
+ runtime: ['warn', 'off'],
35
44
  usage: ['debug', 'off'],
36
45
  },
46
+ core: { warnings: [true, false] },
37
47
  detail: {
38
48
  evidence: ['full', 'selector', 'none'],
39
49
  hookValues: [true, false],
@@ -123,6 +133,7 @@ export const resolveConfig = (file, call) => {
123
133
  return {
124
134
  configuration: {
125
135
  console: merged('console', { ...DEFAULTS.console }),
136
+ core: merged('core', { ...DEFAULTS.core }),
126
137
  detail: merged('detail', { ...DEFAULTS.detail }),
127
138
  environments: merged('environments', { ...DEFAULTS.environments }),
128
139
  sinks: merged('sinks', { ...DEFAULTS.sinks }),
package/dist/console.d.ts CHANGED
@@ -9,9 +9,11 @@
9
9
  * | A finding at `error` | `console.error` | The CSS does nothing where it was written |
10
10
  * | A finding at `warn` | `console.warn` | Worth fixing, and the page works |
11
11
  * | A finding at `info` | `console.info` | Advice |
12
+ * | A runtime event core left to diagnostics | `console.warn`, or `console.error` for a failure | The library was used wrong as it ran |
12
13
  * | Unreadable stylesheets | `console.warn` | The run could not see part of the page |
13
14
  * | Coverage, overrides, a clean run | `console.info` | What the run looked at |
14
15
  * | Usage | `console.debug` | For whoever studies usage, hidden at the default level |
16
+ * | Performance, where asked for | `console.info` | What the components cost, by tag |
15
17
  *
16
18
  * **All of it in one group**, headed by the counts, so a page with other logs
17
19
  * keeps a run together and a clean run collapses to one line.
package/dist/console.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { shownRuntime } from './runtime.js';
1
2
  const entry = (finding) => {
2
3
  const by = finding.decidedBy === 'shipped' ? '' : ` (${finding.severity}, set by the ${finding.decidedBy})`;
3
4
  const lines = [`${finding.kind}${by}: ${finding.subject}`, finding.message];
@@ -16,16 +17,18 @@ export const report = (result, to = console, { shown, } = {}) => {
16
17
  const warnings = bySeverity('warn');
17
18
  const off = configuration.silenced.reduce((total, one) => total + one.count, 0);
18
19
  const missed = coverage.sheets - coverage.read;
20
+ const runtime = shownRuntime(result.runtime);
19
21
  const counts = [
20
22
  counted(errors, 'error'),
21
23
  counted(warnings, 'warning'),
22
24
  `${bySeverity('info')} info`,
25
+ ...(runtime.length > 0 ? [`${runtime.length} runtime`] : []),
23
26
  ...(off > 0 ? [`${off} turned off`] : []),
24
27
  ];
25
- const heading = findings.length === 0 && off === 0
28
+ const heading = findings.length === 0 && off === 0 && runtime.length === 0
26
29
  ? 'helix diagnostics: no findings'
27
30
  : `helix diagnostics: ${counts.join(', ')}`;
28
- if (errors + warnings > 0 || missed > 0) {
31
+ if (errors + warnings + runtime.length > 0 || missed > 0) {
29
32
  to.group(heading);
30
33
  }
31
34
  else {
@@ -64,6 +67,34 @@ export const report = (result, to = console, { shown, } = {}) => {
64
67
  to.info(`${counted(before, 'finding')} printed before, on this page, and not again`);
65
68
  }
66
69
  }
70
+ if (levels.runtime !== 'off') {
71
+ for (const one of runtime) {
72
+ if (one.printed || shown?.has(one.fingerprint)) {
73
+ continue;
74
+ }
75
+ shown?.add(one.fingerprint);
76
+ to[one.level](`${one.kind}${one.tag ? ` <${one.tag}>` : ''}: ${one.message}`);
77
+ }
78
+ }
79
+ if (levels.performance !== 'off' && result.performance) {
80
+ const { measured, tags } = result.performance;
81
+ const rows = Object.entries(tags)
82
+ .sort(([, a], [, b]) => b.render.p95 - a.render.p95)
83
+ .slice(0, 10);
84
+ const lines = rows.map(([tag, one]) => `${tag}: ${one.render.p50} ms median, ${one.render.p95} ms p95, ${one.render.max} ms slowest, of ${counted(one.render.count, 'first update')}` +
85
+ (measured.layoutShift && one.shift > 0 ? `; ${one.shift} layout shift` : '') +
86
+ (measured.longTasks && one.longTasks > 0 ? `; ${counted(one.longTasks, 'long task')}` : ''));
87
+ const key = `performance ${result.run.page ?? ''} ${lines.join('|')}`;
88
+ if (rows.length > 0 && !shown?.has(key)) {
89
+ shown?.add(key);
90
+ to.info([
91
+ `performance: ${counted(Object.keys(tags).length, 'tag')} measured, slowest first` +
92
+ (measured.layoutShift ? '' : '. Layout shift is not measured in this browser') +
93
+ (measured.longTasks ? '' : '. Long tasks are not measured in this browser'),
94
+ ...lines,
95
+ ].join('\n'));
96
+ }
97
+ }
67
98
  if (levels.usage !== 'off') {
68
99
  const { counted: how, hooks, parts, tags, unlisted } = result.usage;
69
100
  const sum = (counts) => Object.values(counts).reduce((total, one) => total + Object.keys(one).length, 0);
package/dist/diagnose.js CHANGED
@@ -5,6 +5,7 @@ import { identify } from './identity.js';
5
5
  import { index, lintFor, versionsOf } from './manifest.js';
6
6
  import { pageOf } from './page.js';
7
7
  import { applyRules, overridesOf } from './rules.js';
8
+ import { logged, tally } from './runtime.js';
8
9
  import { listed, loadedVersions, styled, walk } from './usage.js';
9
10
  import { VERSION } from './version.js';
10
11
  const runId = () => globalThis.crypto?.randomUUID?.() ??
@@ -24,6 +25,20 @@ export const diagnose = (options) => {
24
25
  const lints = tiers.map((tier) => ({ lint: lintFor(tier) }));
25
26
  const tags = index(lints);
26
27
  const counted = watcher ? watcher.counts() : walk(document_, walked);
28
+ const runtime = watcher
29
+ ? watcher.runtime()
30
+ : (() => {
31
+ const kept = tally();
32
+ for (const event of logged()) {
33
+ kept.add(event, true);
34
+ }
35
+ return kept.entries();
36
+ })();
37
+ const reported = runtime
38
+ .filter((one) => one.kind === 'duplicate-registration' && one.tag !== undefined)
39
+ .map((one) => one.tag);
40
+ const twice = duplicates ?? (reported.length > 0 ? () => reported : undefined);
41
+ const measured = watcher?.performance();
27
42
  const seen = loadedVersions();
28
43
  const manifests = versionsOf(lints);
29
44
  const byVersion = versionSkew(manifests, seen);
@@ -34,7 +49,7 @@ export const diagnose = (options) => {
34
49
  : applyRules([
35
50
  ...analyze(located, tags, document_, answered, used, readers),
36
51
  ...markup(tags, document_, walked),
37
- ...registrations(document_, duplicates, walked),
52
+ ...registrations(document_, twice, walked),
38
53
  ].map((one) => identify(one, tags)), file?.rules, call.rules);
39
54
  const path = page ?? globalThis.location?.pathname;
40
55
  const shownPage = path === undefined || detail.pages === 'none'
@@ -59,6 +74,8 @@ export const diagnose = (options) => {
59
74
  ...(environment === undefined ? {} : { environment }),
60
75
  ...(shownPage === undefined ? {} : { page: shownPage }),
61
76
  },
77
+ ...(measured === undefined ? {} : { performance: measured }),
78
+ runtime,
62
79
  schema: 1,
63
80
  usage: {
64
81
  ...listed(counted, tags),
@@ -30,5 +30,11 @@ export declare const normalize: (subject: string) => string;
30
30
  * seen twice, which is how a reader thinks of it.
31
31
  */
32
32
  export declare const fingerprintOf: (finding: Pick<Raw, 'kind' | 'subject' | 'tag'>) => string;
33
+ /**
34
+ * A runtime event's identity across runs: its kind, its tag, and its sentence
35
+ * with each run of digits read as one, so a count in the sentence is not part
36
+ * of what the event is.
37
+ */
38
+ export declare const runtimeFingerprint: (kind: string, tag: string | undefined, message: string) => string;
33
39
  /** A finding with its category, owner, family, and fingerprint. */
34
40
  export declare const identify: (finding: Raw, tags: Map<string, Published>) => Identified;
package/dist/identity.js CHANGED
@@ -37,6 +37,7 @@ const hash = (text) => {
37
37
  return (4294967296 * (2097151 & two) + (one >>> 0)).toString(16).padStart(14, '0');
38
38
  };
39
39
  export const fingerprintOf = (finding) => hash(`${finding.kind}\u0000${finding.tag}\u0000${normalize(finding.subject)}`);
40
+ export const runtimeFingerprint = (kind, tag, message) => hash(`runtime\u0000${kind}\u0000${tag ?? ''}\u0000${message.replace(/\d[\d,.]*/g, '#')}`);
40
41
  export const identify = (finding, tags) => ({
41
42
  ...finding,
42
43
  category: CATEGORY[finding.kind],
package/dist/init.js CHANGED
@@ -2,6 +2,7 @@ import { checkConfig, 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';
5
+ import { shownRuntime } from './runtime.js';
5
6
  import { schedule } from './schedule.js';
6
7
  import { loadedVersions, watch } from './usage.js';
7
8
  export const INSTANCE = Symbol.for('fusion.dev/helix/diagnostics');
@@ -53,14 +54,15 @@ export const initDiagnostics = (options = {}) => {
53
54
  };
54
55
  const start = (options, page) => {
55
56
  const { cdn = CDN, duplicates, environment, file, onReport, page: route, tiers, ...call } = options;
56
- let configuration;
57
+ let resolved;
57
58
  try {
58
- configuration = resolveConfig(file, call).configuration;
59
+ resolved = resolveConfig(file, call);
59
60
  }
60
61
  catch (error) {
61
62
  once(`settings ${messageOf(error)}`, () => console.error(`helix diagnostics: not started. ${messageOf(error)}`));
62
63
  return inert();
63
64
  }
65
+ const { configuration } = resolved;
64
66
  const host = globalThis.location?.hostname ?? '';
65
67
  if (environment === undefined && !LOCAL.test(host)) {
66
68
  once(`host ${host}`, () => console.info(`helix diagnostics: not running on ${host || 'this page'}, which is no local host, with no environment declared. Pass \`environment\` to run here.`));
@@ -78,11 +80,13 @@ const start = (options, page) => {
78
80
  duplicates,
79
81
  environment,
80
82
  file,
83
+ measure: resolved.send.performance.on || configuration.console.performance !== 'off',
81
84
  onReport,
82
85
  page,
83
86
  route,
84
87
  sinks: configuration.sinks,
85
88
  tiers,
89
+ warnings: configuration.core.warnings,
86
90
  });
87
91
  };
88
92
  const running = (given) => {
@@ -90,6 +94,7 @@ const running = (given) => {
90
94
  const production = environment === 'production';
91
95
  const development = environment === undefined || environment === 'development';
92
96
  let file = given.file;
97
+ let warnings = given.warnings;
93
98
  let overlay;
94
99
  const relay = production ? undefined : sinks.relay === true ? RELAY : sinks.relay || undefined;
95
100
  let relayed = false;
@@ -164,7 +169,7 @@ const running = (given) => {
164
169
  deliver('the helix-diagnostics event', () => page.dispatchEvent(new CustomEvent('helix-diagnostics', { detail: run })));
165
170
  if (sinks.overlay && development) {
166
171
  deliver('the overlay', () => {
167
- if (overlay || run.findings.length > 0) {
172
+ if (overlay || run.findings.length > 0 || shownRuntime(run.runtime).length > 0) {
168
173
  overlay ??= overlayOn(page);
169
174
  overlay.show(run);
170
175
  }
@@ -251,6 +256,7 @@ const running = (given) => {
251
256
  try {
252
257
  checkConfig(served, 'file');
253
258
  file = served;
259
+ warnings = resolveConfig(file, call).configuration.core.warnings;
254
260
  }
255
261
  catch (error) {
256
262
  once(`relay settings ${messageOf(error)}`, () => console.error(`helix diagnostics: the settings the relay served were not used. ${messageOf(error)}`));
@@ -262,7 +268,12 @@ const running = (given) => {
262
268
  .catch((error) => once('no relay', () => console.warn(`helix diagnostics: no relay at ${relay} (${messageOf(error)}). Start it with \`helix diagnostics\`, or turn \`sinks.relay\` off.`)));
263
269
  }
264
270
  const plan = schedule(runNow);
265
- const watcher = watch(page, { onRender: () => plan.poke() });
271
+ const watcher = watch(page, {
272
+ onRender: () => plan.poke(),
273
+ onRuntime: () => plan.poke(),
274
+ performance: given.measure,
275
+ quiet: () => !warnings,
276
+ });
266
277
  const poke = () => plan.poke();
267
278
  const observer = typeof MutationObserver === 'function'
268
279
  ? new MutationObserver((records) => {
package/dist/overlay.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { matchable } from './analyze.js';
2
2
  import { OVERLAY } from './page.js';
3
+ import { shownRuntime } from './runtime.js';
3
4
  const STYLE = `
4
5
  :host {
5
6
  all: initial;
@@ -228,30 +229,40 @@ export const overlayOn = (page) => {
228
229
  badge.focus();
229
230
  }
230
231
  });
231
- const point = (finding) => {
232
- const target = finding ? locate(finding, page) : null;
232
+ const point = (target, severity) => {
233
233
  if (!target) {
234
234
  outline.hidden = true;
235
235
  return;
236
236
  }
237
237
  const box = target.getBoundingClientRect();
238
- outline.style.borderColor = `var(--${finding?.severity})`;
238
+ outline.style.borderColor = `var(--${severity})`;
239
239
  outline.style.insetBlockStart = `${box.top - 3}px`;
240
240
  outline.style.insetInlineStart = `${box.left - 3}px`;
241
241
  outline.style.blockSize = `${box.height + 2}px`;
242
242
  outline.style.inlineSize = `${box.width + 2}px`;
243
243
  outline.hidden = false;
244
244
  };
245
+ const follow = (item, target, severity) => {
246
+ item.addEventListener('mouseenter', () => point(target(), severity));
247
+ item.addEventListener('focus', () => point(target(), severity));
248
+ item.addEventListener('mouseleave', () => point(null));
249
+ item.addEventListener('blur', () => point(null));
250
+ };
245
251
  const show = (run) => {
246
252
  const findings = run.findings;
253
+ const runtime = shownRuntime(run.runtime);
247
254
  const of = (severity) => findings.filter((one) => one.severity === severity).length;
248
- const worst = of('error') > 0 ? 'error' : of('warn') > 0 ? 'warn' : 'info';
249
- const counts = ['error', 'warn', 'info']
250
- .filter((severity) => of(severity) > 0)
251
- .map((severity) => counted(of(severity), severity))
252
- .join(', ');
253
- badge.hidden = findings.length === 0;
254
- if (findings.length === 0) {
255
+ const failed = runtime.some((one) => one.level === 'error');
256
+ const worst = of('error') > 0 || failed ? 'error' : of('warn') > 0 || runtime.length > 0 ? 'warn' : 'info';
257
+ const counts = [
258
+ ...['error', 'warn', 'info']
259
+ .filter((severity) => of(severity) > 0)
260
+ .map((severity) => counted(of(severity), severity)),
261
+ ...(runtime.length > 0 ? [`${runtime.length} runtime`] : []),
262
+ ].join(', ');
263
+ const total = findings.length + runtime.length;
264
+ badge.hidden = total === 0;
265
+ if (total === 0) {
255
266
  open(false);
256
267
  }
257
268
  dot.style.background = `var(--${worst})`;
@@ -267,10 +278,21 @@ export const overlayOn = (page) => {
267
278
  if (finding.origin) {
268
279
  item.append(make('span', 'where', `in ${finding.origin.sheet}`), make('pre', undefined, finding.origin.cssText));
269
280
  }
270
- item.addEventListener('mouseenter', () => point(finding));
271
- item.addEventListener('focus', () => point(finding));
272
- item.addEventListener('mouseleave', () => point(undefined));
273
- item.addEventListener('blur', () => point(undefined));
281
+ follow(item, () => locate(finding, page), finding.severity);
282
+ return item;
283
+ }), ...runtime.map((one) => {
284
+ const item = make('li');
285
+ item.tabIndex = 0;
286
+ const head = make('span', 'kind');
287
+ head.append(make('span', one.level, `${one.level} `), document.createTextNode(one.kind));
288
+ item.append(head);
289
+ if (one.tag) {
290
+ item.append(make('code', 'subject', `<${one.tag}>`));
291
+ }
292
+ item.append(worded(one.message), make('span', 'where', one.count === 1
293
+ ? 'Reported by Helix as the page ran'
294
+ : `Reported by Helix ${one.count} times as the page ran`));
295
+ follow(item, () => (one.tag ? page.querySelector(one.tag) : null), one.level);
274
296
  return item;
275
297
  }));
276
298
  };
@@ -0,0 +1,34 @@
1
+ import type { PerformanceReport } from './report.js';
2
+ /** The key `@fusion.dev/helix-core` hands each first update's timing to. */
3
+ export declare const RENDER_SLOT: unique symbol;
4
+ /** A measure as the page holds it, until a run reads it. */
5
+ export interface Measurer {
6
+ /** The measures so far. A copy: measuring goes on after it is read. */
7
+ results(): PerformanceReport;
8
+ /** Stops measuring, and gives the slot back to whatever held it before. */
9
+ stop(): void;
10
+ }
11
+ /**
12
+ * The tag of the Helix element a node belongs to: the node itself, or the
13
+ * nearest Helix element above it, through shadow roots.
14
+ */
15
+ export declare const helixOf: (node: Node | null | undefined) => string | undefined;
16
+ /** The value at a fraction of the way through sorted numbers, by nearest rank. */
17
+ export declare const percentile: (sorted: readonly number[], fraction: number) => number;
18
+ /** A layout-shift entry's part, as this reads it. */
19
+ interface Shift {
20
+ hadRecentInput?: boolean;
21
+ sources?: ReadonlyArray<{
22
+ node?: Node | null | undefined;
23
+ }>;
24
+ value: number;
25
+ }
26
+ /**
27
+ * Shares one shift's score out among the Helix elements its sources belong
28
+ * to, an equal share each. A shift a reader caused, by typing or pressing, is
29
+ * no fault of the page's, which is how the browser scores it too.
30
+ */
31
+ export declare const shares: (shift: Shift) => Map<string, number>;
32
+ /** Starts measuring, from now on, and the layout shift and long tasks the browser kept from before. */
33
+ export declare const measure: () => Measurer;
34
+ export {};
@@ -0,0 +1,121 @@
1
+ import { OVERLAY } from './page.js';
2
+ export const RENDER_SLOT = Symbol.for('fusion.dev/helix/render');
3
+ const KEPT = 1000;
4
+ export const helixOf = (node) => {
5
+ let at = node;
6
+ while (at) {
7
+ if (at.nodeType === 1) {
8
+ const tag = at.localName;
9
+ if (tag.startsWith('helix-') && tag !== OVERLAY) {
10
+ return tag;
11
+ }
12
+ }
13
+ at = at.parentNode ?? at.host ?? null;
14
+ }
15
+ return undefined;
16
+ };
17
+ export const percentile = (sorted, fraction) => sorted.length === 0
18
+ ? 0
19
+ : (sorted[Math.min(sorted.length - 1, Math.ceil(fraction * sorted.length) - 1)] ?? 0);
20
+ const tenth = (value) => Math.round(value * 10) / 10;
21
+ const thousandth = (value) => Math.round(value * 1000) / 1000;
22
+ export const shares = (shift) => {
23
+ const out = new Map();
24
+ const sources = shift.sources ?? [];
25
+ if (shift.hadRecentInput || sources.length === 0) {
26
+ return out;
27
+ }
28
+ const each = shift.value / sources.length;
29
+ for (const source of sources) {
30
+ const tag = helixOf(source.node);
31
+ if (tag) {
32
+ out.set(tag, (out.get(tag) ?? 0) + each);
33
+ }
34
+ }
35
+ return out;
36
+ };
37
+ const supports = (type) => typeof PerformanceObserver === 'function' &&
38
+ (PerformanceObserver.supportedEntryTypes ?? []).includes(type);
39
+ export const measure = () => {
40
+ const gathered = new Map();
41
+ const of = (tag) => {
42
+ let one = gathered.get(tag);
43
+ if (!one) {
44
+ one = { durations: [], shift: 0, spans: [] };
45
+ gathered.set(tag, one);
46
+ }
47
+ return one;
48
+ };
49
+ const long = [];
50
+ const store = globalThis;
51
+ const before = store[RENDER_SLOT];
52
+ let live = true;
53
+ const listener = (element, timing) => {
54
+ if (live && element?.localName !== OVERLAY) {
55
+ const one = of(element.localName);
56
+ one.durations.push(timing.duration);
57
+ if (one.spans.length < KEPT) {
58
+ one.spans.push({ end: timing.start + timing.duration, start: timing.start });
59
+ }
60
+ }
61
+ if (typeof before === 'function') {
62
+ before(element, timing);
63
+ }
64
+ };
65
+ store[RENDER_SLOT] = listener;
66
+ const observers = [];
67
+ const observe = (type, take) => {
68
+ if (!supports(type)) {
69
+ return false;
70
+ }
71
+ try {
72
+ const observer = new PerformanceObserver((list) => {
73
+ for (const entry of list.getEntries()) {
74
+ take(entry);
75
+ }
76
+ });
77
+ observer.observe({ buffered: true, type });
78
+ observers.push(observer);
79
+ return true;
80
+ }
81
+ catch {
82
+ return false;
83
+ }
84
+ };
85
+ const layoutShift = observe('layout-shift', (entry) => {
86
+ for (const [tag, share] of shares(entry)) {
87
+ of(tag).shift += share;
88
+ }
89
+ });
90
+ const longTasks = observe('longtask', (entry) => {
91
+ long.push({ end: entry.startTime + entry.duration, start: entry.startTime });
92
+ });
93
+ return {
94
+ results: () => {
95
+ const tags = {};
96
+ for (const [tag, one] of [...gathered].sort(([a], [b]) => (a < b ? -1 : 1))) {
97
+ const sorted = [...one.durations].sort((a, b) => a - b);
98
+ tags[tag] = {
99
+ longTasks: long.filter((task) => one.spans.some((span) => span.start < task.end && span.end > task.start)).length,
100
+ render: {
101
+ count: sorted.length,
102
+ max: tenth(sorted.at(-1) ?? 0),
103
+ p50: tenth(percentile(sorted, 0.5)),
104
+ p95: tenth(percentile(sorted, 0.95)),
105
+ },
106
+ shift: thousandth(one.shift),
107
+ };
108
+ }
109
+ return { measured: { layoutShift, longTasks }, tags };
110
+ },
111
+ stop: () => {
112
+ live = false;
113
+ for (const observer of observers) {
114
+ observer.disconnect();
115
+ }
116
+ if (store[RENDER_SLOT] === listener) {
117
+ store[RENDER_SLOT] = before;
118
+ }
119
+ },
120
+ };
121
+ };
package/dist/report.d.ts CHANGED
@@ -109,6 +109,63 @@ export type Identified = Omit<Finding, 'decidedBy' | 'severity'>;
109
109
  export declare const CATEGORIES: readonly ['assist', 'configuration', 'coverage', 'findings', 'performance', 'runtime', 'usage'];
110
110
  /** A kind of telemetry. */
111
111
  export type Category = (typeof CATEGORIES)[number];
112
+ /**
113
+ * What the components cost on a page, by tag, where a project asked for it.
114
+ */
115
+ export interface PerformanceReport {
116
+ /**
117
+ * What this browser could measure. Layout shift and long tasks are
118
+ * Chromium's, so elsewhere their numbers are absent, never zero.
119
+ */
120
+ measured: {
121
+ layoutShift: boolean;
122
+ longTasks: boolean;
123
+ };
124
+ /** Each tag rendered on the page. */
125
+ tags: Record<string, {
126
+ /** How many long tasks, of 50 ms or more, one of its elements' first updates ran in. */
127
+ longTasks: number;
128
+ /**
129
+ * Its elements' first updates, in milliseconds: how many, the median,
130
+ * the 95th percentile, and the slowest. Each is the element's own
131
+ * update, from `willUpdate` to `updated`, so time spent waiting behind
132
+ * other elements is not counted.
133
+ */
134
+ render: {
135
+ count: number;
136
+ max: number;
137
+ p50: number;
138
+ p95: number;
139
+ };
140
+ /** The layout shift its elements moved, summed as the browser scores it. */
141
+ shift: number;
142
+ }>;
143
+ }
144
+ /**
145
+ * A runtime event as a run holds it: what `@fusion.dev/helix-core` reported
146
+ * as the library ran, such as a selectable table with no `row-key`, one entry
147
+ * per distinct event on the page.
148
+ */
149
+ export interface RuntimeEntry {
150
+ /** How many times it was reported on this page. */
151
+ count: number;
152
+ /**
153
+ * Its identity across runs: the kind, the tag, and the sentence with its
154
+ * numbers left out, so a table drawing 3,000 rows and one drawing 3,200 are
155
+ * one event.
156
+ */
157
+ fingerprint: string;
158
+ /** What went wrong, as core names it, stable across versions. */
159
+ kind: string;
160
+ /** `error` for a failure, `warn` for a mistake in how the library is used. */
161
+ level: 'error' | 'warn';
162
+ /** The sentence core prints, which says what to do. */
163
+ message: string;
164
+ /** Whether core printed it as it happened; where it did not, the console sink does. */
165
+ printed: boolean;
166
+ /** The tag of the element it happened on, where there is one. */
167
+ tag?: string;
168
+ }
112
169
  /** Where a run was declared to happen. Undeclared is never sent. */
113
170
  export type Environment = 'ci' | 'development' | 'production';
114
171
  /** A sheet the collector could not read, and the reason. */
@@ -188,8 +245,20 @@ export interface Configuration {
188
245
  console: {
189
246
  coverage: 'info' | 'off';
190
247
  findings: Severity | 'off';
248
+ /** What the components cost, by tag, which is measured where this is on. */
249
+ performance: 'info' | 'off';
250
+ /** The runtime events core left to diagnostics to print. */
251
+ runtime: 'warn' | 'off';
191
252
  usage: 'debug' | 'off';
192
253
  };
254
+ /** What `@fusion.dev/helix-core` does itself. */
255
+ core: {
256
+ /**
257
+ * Whether core prints each runtime warning as it happens. Off, diagnostics
258
+ * prints them in its own group, at the `console.runtime` level.
259
+ */
260
+ warnings: boolean;
261
+ };
193
262
  /** How much detail goes with what is sent. */
194
263
  detail: {
195
264
  evidence: 'full' | 'none' | 'selector';
@@ -242,6 +311,16 @@ export interface Report {
242
311
  configuration: Configuration;
243
312
  coverage: Coverage;
244
313
  findings: Finding[];
314
+ /**
315
+ * What the components cost, by tag. Present only where the project asked,
316
+ * with `console.performance` or `send.performance`.
317
+ */
318
+ performance?: PerformanceReport;
319
+ /**
320
+ * What the library reported going wrong as it ran on this page, from its
321
+ * first load: one entry per distinct event, counted.
322
+ */
323
+ runtime: RuntimeEntry[];
245
324
  /**
246
325
  * The project's own name and labels, as its configuration gives them. The
247
326
  * key is never written into a report.
@@ -0,0 +1,31 @@
1
+ import type { RuntimeEntry } from './report.js';
2
+ /** The key core reports each event to. */
3
+ export declare const RUNTIME_SLOT: unique symbol;
4
+ /** The key core keeps the events it reported under, the first two hundred. */
5
+ export declare const RUNTIME_LOG: unique symbol;
6
+ /**
7
+ * The kinds a finding already reports, which the console and the overlay leave
8
+ * out: two copies of Helix on a page is the `duplicate-registration` finding.
9
+ */
10
+ export declare const AS_FINDINGS: ReadonlySet<string>;
11
+ /** An event as core reports it, read without importing core. */
12
+ export interface Heard {
13
+ detail?: unknown;
14
+ kind: string;
15
+ level?: 'error' | 'warn';
16
+ message: string;
17
+ tag?: string | undefined;
18
+ }
19
+ /** The events core has kept so far. */
20
+ export declare const logged: () => Heard[];
21
+ /** One entry per distinct event, counted as each is heard. */
22
+ export interface Tally {
23
+ /** Counts one event; `printed` says whether core printed it. */
24
+ add(event: unknown, printed: boolean): void;
25
+ /** The entries so far. Copies: counting goes on after they are read. */
26
+ entries(): RuntimeEntry[];
27
+ }
28
+ /** Starts a count. */
29
+ export declare const tally: () => Tally;
30
+ /** The entries a reader is shown: those no finding reports already. */
31
+ export declare const shownRuntime: (entries: readonly RuntimeEntry[]) => RuntimeEntry[];
@@ -0,0 +1,39 @@
1
+ import { runtimeFingerprint } from './identity.js';
2
+ export const RUNTIME_SLOT = Symbol.for('fusion.dev/helix/runtime');
3
+ export const RUNTIME_LOG = Symbol.for('fusion.dev/helix/runtime-log');
4
+ export const AS_FINDINGS = new Set(['duplicate-registration']);
5
+ const isHeard = (value) => typeof value === 'object' &&
6
+ value !== null &&
7
+ typeof value.kind === 'string' &&
8
+ typeof value.message === 'string';
9
+ export const logged = () => {
10
+ const log = globalThis[RUNTIME_LOG];
11
+ return Array.isArray(log) ? log.filter(isHeard) : [];
12
+ };
13
+ export const tally = () => {
14
+ const entries = new Map();
15
+ return {
16
+ add(event, printed) {
17
+ if (!isHeard(event)) {
18
+ return;
19
+ }
20
+ const fingerprint = runtimeFingerprint(event.kind, event.tag, event.message);
21
+ const known = entries.get(fingerprint);
22
+ if (known) {
23
+ known.count += 1;
24
+ return;
25
+ }
26
+ entries.set(fingerprint, {
27
+ count: 1,
28
+ fingerprint,
29
+ kind: event.kind,
30
+ level: event.level === 'error' ? 'error' : 'warn',
31
+ message: event.message,
32
+ printed,
33
+ ...(event.tag === undefined ? {} : { tag: event.tag }),
34
+ });
35
+ },
36
+ entries: () => [...entries.values()].map((one) => ({ ...one })),
37
+ };
38
+ };
39
+ export const shownRuntime = (entries) => entries.filter((one) => !AS_FINDINGS.has(one.kind));
package/dist/usage.d.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  import type { Answered, Located } from './collect.js';
2
2
  import type { Published } from './manifest.js';
3
3
  import { type Page } from './page.js';
4
- import type { Usage } from './report.js';
4
+ import type { PerformanceReport, RuntimeEntry, Usage } from './report.js';
5
+ import { type Heard } from './runtime.js';
5
6
  /** The element counts, before the stylesheets add theirs. */
6
7
  type Counted = Pick<Usage, 'axes' | 'composed' | 'slots' | 'tags'>;
7
8
  /**
@@ -23,7 +24,15 @@ export declare const listed: (counted: Counted, tags: Map<string, Published>) =>
23
24
  export interface Watcher {
24
25
  /** The counts so far. A copy: counting goes on after it is read. */
25
26
  counts(): Counted;
26
- /** Stops counting, and gives the slot back to whatever held it before. */
27
+ /** What the components cost so far, where the watcher was asked to measure. */
28
+ performance(): PerformanceReport | undefined;
29
+ /**
30
+ * The runtime events reported on this page from its first load, one entry
31
+ * per distinct event: the ones core kept from before the watcher started,
32
+ * and the ones heard since.
33
+ */
34
+ runtime(): RuntimeEntry[];
35
+ /** Stops counting, and gives the slots back to whatever held them before. */
27
36
  stop(): void;
28
37
  }
29
38
  /**
@@ -41,13 +50,26 @@ export interface Watcher {
41
50
  * Two watchers on one page both count: a second one keeps the first's
42
51
  * listener and calls it too.
43
52
  */
44
- export declare const watch: (from?: Document, { onRender, }?: {
53
+ export declare const watch: (from?: Document, { onRender, onRuntime, performance, quiet, }?: {
45
54
  /**
46
55
  * Called as each Helix element renders for the first time, after it is
47
56
  * counted: how `initDiagnostics()` hears that the page changed when an
48
57
  * element upgrades without adding anything to the DOM.
49
58
  */
50
59
  onRender?: ((element: Element) => void) | undefined;
60
+ /** Called with each runtime event core reports, after it is counted. */
61
+ onRuntime?: ((event: Heard) => void) | undefined;
62
+ /**
63
+ * Whether to measure what the components cost, from now on: each
64
+ * element's first update, and the layout shift and long tasks the browser
65
+ * reports.
66
+ */
67
+ performance?: boolean | undefined;
68
+ /**
69
+ * Whether core should leave each runtime event for diagnostics to print,
70
+ * asked as each arrives: `core.warnings: false`.
71
+ */
72
+ quiet?: (() => boolean) | undefined;
51
73
  }) => Watcher;
52
74
  /**
53
75
  * The parts and hooks a page's stylesheets reach for, where the manifest or
package/dist/usage.js CHANGED
@@ -1,5 +1,7 @@
1
1
  import { parts, targets } from './analyze.js';
2
2
  import { OVERLAY, pageOf } from './page.js';
3
+ import { measure } from './performance.js';
4
+ import { logged, RUNTIME_SLOT, tally as tallyRuntime } from './runtime.js';
3
5
  const SLOT = Symbol.for('fusion.dev/helix/usage');
4
6
  const AXES = ['emphasis', 'material', 'separation', 'tone'];
5
7
  const bump = (into, key, by = 1) => {
@@ -68,7 +70,7 @@ export const listed = (counted, tags) => {
68
70
  unlisted,
69
71
  };
70
72
  };
71
- export const watch = (from = document, { onRender, } = {}) => {
73
+ export const watch = (from = document, { onRender, onRuntime, performance, quiet, } = {}) => {
72
74
  const counted = empty();
73
75
  const seen = new WeakSet();
74
76
  const see = (element) => {
@@ -93,13 +95,38 @@ export const watch = (from = document, { onRender, } = {}) => {
93
95
  for (const element of pageOf(from).elements) {
94
96
  see(element);
95
97
  }
98
+ const heard = tallyRuntime();
99
+ for (const event of logged()) {
100
+ heard.add(event, true);
101
+ }
102
+ const beforeRuntime = store[RUNTIME_SLOT];
103
+ const hear = (event) => {
104
+ let silent = false;
105
+ if (live) {
106
+ silent = quiet?.() === true;
107
+ heard.add(event, !silent);
108
+ onRuntime?.(event);
109
+ }
110
+ if (typeof beforeRuntime === 'function' && beforeRuntime(event) === true) {
111
+ silent = true;
112
+ }
113
+ return silent;
114
+ };
115
+ store[RUNTIME_SLOT] = hear;
116
+ const measured = performance ? measure() : undefined;
96
117
  return {
97
118
  counts: () => structuredClone(counted),
119
+ performance: () => measured?.results(),
120
+ runtime: () => heard.entries(),
98
121
  stop: () => {
99
122
  live = false;
123
+ measured?.stop();
100
124
  if (store[SLOT] === listener) {
101
125
  store[SLOT] = before;
102
126
  }
127
+ if (store[RUNTIME_SLOT] === hear) {
128
+ store[RUNTIME_SLOT] = beforeRuntime;
129
+ }
103
130
  },
104
131
  };
105
132
  };
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.139.0";
9
+ export declare const VERSION = "0.141.0";
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const VERSION = '0.139.0';
1
+ export const VERSION = '0.141.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.139.0",
4
+ "version": "0.141.0",
5
5
  "license": "MIT",
6
6
  "author": "Ross Sheppard <ross.sheppard@fusion.dev>",
7
7
  "repository": {
@@ -33,7 +33,7 @@
33
33
  },
34
34
  "bugs": "https://github.com/project-fusion/fusion/issues",
35
35
  "devDependencies": {
36
- "@fusion.dev/helix-testing": "0.139.0",
36
+ "@fusion.dev/helix-testing": "0.141.0",
37
37
  "@types/bun": "^1.3.0"
38
38
  },
39
39
  "homepage": "https://fusion.dev",