@fusion.dev/helix-headless 0.212.1 → 0.213.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.
@@ -346,6 +346,8 @@ export declare class HelixHeadlessChat extends HelixHeadlessChat_base {
346
346
  private grower;
347
347
  /** What `grower` is watching, so a render that changed nothing re-observes nothing. */
348
348
  private watched;
349
+ /** The frame `watchSizes` will observe in, while one is booked. */
350
+ private watchFrame;
349
351
  /** The scroll offset this element last set, which the reader has not moved from. */
350
352
  private stuckAt;
351
353
  /** The turn at the top of the reader's view, and how far below the top it sat. */
@@ -424,7 +426,15 @@ export declare class HelixHeadlessChat extends HelixHeadlessChat_base {
424
426
  * a reader asking to go to the bottom means.
425
427
  */
426
428
  resume(): void;
427
- /** Scrolls to the bottom, and remembers where that was. */
429
+ /**
430
+ * Scrolls to the bottom, and remembers where that was.
431
+ *
432
+ * **At once, whatever `scroll-behavior` the page gives the log.** Pinned,
433
+ * this runs on every update, every token of a streaming turn included, and a
434
+ * smooth scroll sent again each time never arrives: in WebKit, a log that had
435
+ * streamed for a second under `scroll-behavior: smooth` rested 6 pixels short
436
+ * of the bottom, and `stuckAt` held where each scroll began.
437
+ */
428
438
  private stick;
429
439
  /**
430
440
  * Asks to pin or unpin, and reports whether the request stood.
@@ -496,8 +506,34 @@ export declare class HelixHeadlessChat extends HelixHeadlessChat_base {
496
506
  * in a conversation of several knows who spoke.
497
507
  */
498
508
  private phrase;
499
- /** Watches what is drawn for size, re-observing only when that changed. */
509
+ /**
510
+ * Watches what is drawn for size, re-observing only when that changed.
511
+ *
512
+ * **In the next frame's animation callbacks, never in the update that asked.**
513
+ * A resize notice asks for an update, the update draws new turns while the
514
+ * browser is still delivering notices, and turns observed then are held to
515
+ * the next frame with "ResizeObserver loop completed with undelivered
516
+ * notifications": twice in WebKit's run of Chat's specs and once in
517
+ * Chromium's, on October 11, 2026. Observed from the next frame's callbacks,
518
+ * their first notice comes before that frame paints, and a turn drawn for the
519
+ * first time is answered sooner still, by `settleDrawn`.
520
+ */
500
521
  private watchSizes;
522
+ /** Observes the log and the turns drawn now, where that changed. */
523
+ private observeSizes;
524
+ /**
525
+ * Answers the turns this render drew for the first time, once each has drawn
526
+ * what is in it: a microtask after this update, and before the frame.
527
+ *
528
+ * **Sooner than the resize observer, which `watchSizes` starts a frame
529
+ * later.** A turn is drawn empty and fills in after this update, and until
530
+ * it is measured the reader's turn sits where the estimates put it. A timer
531
+ * that ran before the next frame read that place as the reader's: on a Linux
532
+ * runner, the controller's rest after a scroll left a reader's turn 55.5
533
+ * pixels from where it was when history arrived above it, in WebKit, on
534
+ * October 11, 2026. Answered here, the history is held before any timer runs.
535
+ */
536
+ private settleDrawn;
501
537
  /**
502
538
  * A turn or the log changed size, which moves nothing on its own.
503
539
  *
@@ -62,6 +62,7 @@ let HelixHeadlessChat = class HelixHeadlessChat extends MixinCollection(HelixEle
62
62
  this.settling = undefined;
63
63
  this.grower = undefined;
64
64
  this.watched = [];
65
+ this.watchFrame = 0;
65
66
  this.stuckAt = -1;
66
67
  this.anchor = undefined;
67
68
  this.asked = false;
@@ -259,7 +260,12 @@ let HelixHeadlessChat = class HelixHeadlessChat extends MixinCollection(HelixEle
259
260
  stick() {
260
261
  const log = this.log;
261
262
  if (log) {
262
- log.scrollTop = log.scrollHeight;
263
+ if (typeof log.scrollTo === 'function') {
264
+ log.scrollTo({ behavior: 'instant', top: log.scrollHeight });
265
+ }
266
+ else {
267
+ log.scrollTop = log.scrollHeight;
268
+ }
263
269
  this.stuckAt = log.scrollTop;
264
270
  }
265
271
  }
@@ -335,7 +341,7 @@ let HelixHeadlessChat = class HelixHeadlessChat extends MixinCollection(HelixEle
335
341
  }
336
342
  const found = turns[low];
337
343
  this.anchor = found
338
- ? { id: found.id, offset: found.element.getBoundingClientRect().top - top }
344
+ ? { at: log.scrollTop, id: found.id, offset: found.element.getBoundingClientRect().top - top }
339
345
  : undefined;
340
346
  }
341
347
  restore() {
@@ -344,6 +350,10 @@ let HelixHeadlessChat = class HelixHeadlessChat extends MixinCollection(HelixEle
344
350
  if (!log || !anchor || (this.fed && this.virtualize.scrolling())) {
345
351
  return;
346
352
  }
353
+ if (!this.fed && Math.abs(log.scrollTop - anchor.at) >= 1) {
354
+ this.remember();
355
+ return;
356
+ }
347
357
  const found = this.rendered().find((turn) => turn.id === anchor.id);
348
358
  if (!found) {
349
359
  return;
@@ -418,9 +428,21 @@ let HelixHeadlessChat = class HelixHeadlessChat extends MixinCollection(HelixEle
418
428
  : formatWords(this, 'arrivalLabel', { author: turn.by, text: turn.text });
419
429
  }
420
430
  watchSizes() {
421
- if (typeof ResizeObserver === 'undefined') {
431
+ if (typeof ResizeObserver === 'undefined' || this.watchFrame !== 0) {
432
+ return;
433
+ }
434
+ if (typeof requestAnimationFrame !== 'function') {
435
+ this.observeSizes();
422
436
  return;
423
437
  }
438
+ this.watchFrame = requestAnimationFrame(() => {
439
+ this.watchFrame = 0;
440
+ if (this.isConnected) {
441
+ this.observeSizes();
442
+ }
443
+ });
444
+ }
445
+ observeSizes() {
424
446
  const log = this.log;
425
447
  const targets = [...(log ? [log] : []), ...this.rendered().map((turn) => turn.element)];
426
448
  if (targets.length === this.watched.length &&
@@ -434,6 +456,18 @@ let HelixHeadlessChat = class HelixHeadlessChat extends MixinCollection(HelixEle
434
456
  }
435
457
  this.watched = targets;
436
458
  }
459
+ settleDrawn() {
460
+ const waiting = this.drawn().filter((row) => !drawnInFull(row));
461
+ if (waiting.length === 0) {
462
+ return;
463
+ }
464
+ void Promise.all(waiting.map(({ element }) => element.firstElementChild
465
+ ?.updateComplete)).then(() => {
466
+ if (this.isConnected && this.fed) {
467
+ this.handleResize();
468
+ }
469
+ });
470
+ }
437
471
  connectedCallback() {
438
472
  super.connectedCallback();
439
473
  this.addEventListener('keydown', this.handleKeydown);
@@ -458,6 +492,10 @@ let HelixHeadlessChat = class HelixHeadlessChat extends MixinCollection(HelixEle
458
492
  this.settling = undefined;
459
493
  this.grower?.disconnect();
460
494
  this.watched = [];
495
+ if (this.watchFrame !== 0) {
496
+ cancelAnimationFrame(this.watchFrame);
497
+ this.watchFrame = 0;
498
+ }
461
499
  }
462
500
  willUpdate(changed) {
463
501
  super.willUpdate(changed);
@@ -518,6 +556,7 @@ let HelixHeadlessChat = class HelixHeadlessChat extends MixinCollection(HelixEle
518
556
  }
519
557
  if (this.fed) {
520
558
  this.virtualize.measure();
559
+ this.settleDrawn();
521
560
  }
522
561
  this.watchSizes();
523
562
  this.markScroller();
@@ -1,6 +1,21 @@
1
1
  import { type LogRun } from './ansi.js';
2
2
  /** How much a line matters, which a stylesheet draws and a screen reader may hear. */
3
- export type LogLevel = 'debug' | 'error' | 'info' | 'warn';
3
+ export type LogLevel = 'debug' | 'error' | 'info' | 'log' | 'warn';
4
+ /**
5
+ * The levels from the quietest to the loudest, which `level` on a log counts
6
+ * up from: `log` is a plain line, a console's `console.log`, below `info`.
7
+ */
8
+ export declare const LOG_LEVELS: readonly LogLevel[];
9
+ /** Whether a line at `level` shows under a log whose quietest level shown is `floor`. A line with no level is `info`. */
10
+ export declare const loudEnough: (level: LogLevel | undefined, floor: LogLevel | undefined) => boolean;
11
+ /**
12
+ * The words a screen reader hears before a line's text, or nothing where the
13
+ * text already starts with them: `console.error(error)` writes `Error: …`,
14
+ * which `Error:` before it would read twice. The words' closing colon is not
15
+ * needed, and a word that only starts the text's first word, such as `Error`
16
+ * before `Errors`, is still said.
17
+ */
18
+ export declare const levelSaid: (words: string | undefined, text: string) => string | undefined;
4
19
  /** Where a step of the run stands. */
5
20
  export type LogStatus = 'done' | 'failed' | 'pending' | 'running' | 'skipped';
6
21
  /** One line of output, as `lines` holds it and `parse` returns it. */
@@ -9,6 +24,11 @@ export interface LogLine {
9
24
  at?: number | string;
10
25
  /** The `id` of the group in `groups` the line belongs to. */
11
26
  group?: string;
27
+ /**
28
+ * How many times it was written in a row, where the log folds repeats with
29
+ * `collapse`. Absent is once.
30
+ */
31
+ count?: number;
12
32
  /** How much it matters. Absent is `info`. */
13
33
  level?: LogLevel;
14
34
  /** What was written, with any ANSI escape codes still in it. */
@@ -95,6 +115,8 @@ export declare class LogBuffer {
95
115
  dropped: number;
96
116
  /** The most finished lines kept, or 0 to keep them all. */
97
117
  limit: number;
118
+ /** Whether a line written again straight after itself folds into it, with a count. */
119
+ collapse: boolean;
98
120
  /** Reads each finished line of written text. */
99
121
  parse: LogParser | undefined;
100
122
  /** The steps `parse` opened, in order, replaced as a whole when one changes. */
@@ -119,8 +141,13 @@ export declare class LogBuffer {
119
141
  * lines it finished.
120
142
  */
121
143
  write(chunk: string): number;
122
- /** Replaces every line, starting a fresh buffer that keeps its `parse` and `limit`. */
144
+ /** Replaces every line, starting a fresh buffer that keeps its `parse`, `limit` and `collapse`. */
123
145
  replace(lines: readonly LogLine[]): void;
146
+ /**
147
+ * Adds one finished line as data, after the lines held, folding it where
148
+ * `collapse` says, and lets the oldest go past `limit`.
149
+ */
150
+ push(line: LogLine): void;
124
151
  /** Lets every line go. */
125
152
  clear(): void;
126
153
  /** The line still being written, or undefined where the last line ended. */
@@ -142,6 +169,12 @@ export declare class LogBuffer {
142
169
  * as a CI page folds a step that passed.
143
170
  */
144
171
  private closeStep;
172
+ /**
173
+ * Keeps a finished line, or folds it into the line before it where it
174
+ * repeats that line and `collapse` is set. The fold is a new object, so a
175
+ * log comparing lines sees it change, drawn in the runs of the first.
176
+ */
177
+ private add;
145
178
  /** Lets the oldest lines go past `limit`, all at once. */
146
179
  private trim;
147
180
  }
@@ -1,4 +1,20 @@
1
1
  import { parseAnsi } from './ansi.js';
2
+ export const LOG_LEVELS = ['debug', 'log', 'info', 'warn', 'error'];
3
+ export const loudEnough = (level, floor) => floor === undefined || LOG_LEVELS.indexOf(level ?? 'info') >= LOG_LEVELS.indexOf(floor);
4
+ export const levelSaid = (words, text) => {
5
+ const word = words?.trim().replace(/:$/, '').toLocaleLowerCase();
6
+ if (!word) {
7
+ return undefined;
8
+ }
9
+ const start = text.trimStart();
10
+ const repeats = start.slice(0, word.length).toLocaleLowerCase() === word &&
11
+ !/^[\p{L}\p{N}]/u.test(start.slice(word.length));
12
+ return repeats ? undefined : words;
13
+ };
14
+ const repeats = (line, last) => last !== undefined &&
15
+ last.text === line.text &&
16
+ last.level === line.level &&
17
+ last.group === line.group;
2
18
  export const stepDuration = (seconds) => {
3
19
  const whole = Math.max(0, Math.round(seconds));
4
20
  if (whole < 60) {
@@ -23,6 +39,7 @@ export class LogBuffer {
23
39
  this.lines = [];
24
40
  this.dropped = 0;
25
41
  this.limit = 0;
42
+ this.collapse = false;
26
43
  this.parse = undefined;
27
44
  this.steps = [];
28
45
  this.openStep = undefined;
@@ -60,7 +77,10 @@ export class LogBuffer {
60
77
  return finished;
61
78
  }
62
79
  replace(lines) {
63
- this.lines = [...lines];
80
+ this.lines = [];
81
+ for (const line of lines) {
82
+ this.add(line);
83
+ }
64
84
  this.dropped = 0;
65
85
  this.partial = '';
66
86
  this.returned = false;
@@ -72,6 +92,10 @@ export class LogBuffer {
72
92
  this.stepStarted = undefined;
73
93
  this.trim();
74
94
  }
95
+ push(line) {
96
+ this.add(line);
97
+ this.trim();
98
+ }
75
99
  clear() {
76
100
  this.replace([]);
77
101
  }
@@ -126,7 +150,7 @@ export class LogBuffer {
126
150
  this.stepEnded = at ?? this.stepEnded;
127
151
  }
128
152
  this.runs.set(line, line.text === raw ? flow.runs : parseAnsi(line.text, this.lineStyle).runs);
129
- this.lines.push(line);
153
+ this.add(line);
130
154
  }
131
155
  }
132
156
  this.lineStyle = flow.style;
@@ -166,6 +190,19 @@ export class LogBuffer {
166
190
  this.steps = this.steps.map((each, place) => (place === index ? closed : each));
167
191
  this.openStep = undefined;
168
192
  }
193
+ add(line) {
194
+ const last = this.lines.at(-1);
195
+ if (!this.collapse || !last || !repeats(line, last)) {
196
+ this.lines.push(line);
197
+ return;
198
+ }
199
+ const folded = { ...last, count: (last.count ?? 1) + (line.count ?? 1) };
200
+ const runs = this.runs.get(last);
201
+ if (runs) {
202
+ this.runs.set(folded, runs);
203
+ }
204
+ this.lines[this.lines.length - 1] = folded;
205
+ }
169
206
  trim() {
170
207
  const over = this.limit > 0 ? this.lines.length - this.limit : 0;
171
208
  if (over > 0) {
@@ -1,5 +1,5 @@
1
1
  export { type LogRun, type LogStyle, parseAnsi, stripAnsi } from './ansi.js';
2
- export type { LogGroup, LogLevel, LogLine, LogParsed, LogParser, LogStatus } from './buffer.js';
2
+ export { type LogGroup, type LogLevel, type LogLine, type LogParsed, type LogParser, type LogStatus, levelSaid, } from './buffer.js';
3
3
  export { HelixHeadlessLogGroup } from './group/log-group.js';
4
4
  export { HelixHeadlessLogLine, type LogTimes } from './line/log-line.js';
5
5
  export { parseGitHubActions, parseVercel } from './parsers.js';
package/dist/log/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  export { parseAnsi, stripAnsi } from './ansi.js';
2
+ export { levelSaid, } from './buffer.js';
2
3
  export { HelixHeadlessLogGroup } from './group/log-group.js';
3
4
  export { HelixHeadlessLogLine } from './line/log-line.js';
4
5
  export { parseGitHubActions, parseVercel } from './parsers.js';
@@ -13,8 +13,10 @@ export type LogTimes = 'absolute' | 'relative';
13
13
  * and tells it whether to show its number and its time.
14
14
  *
15
15
  * `level` says how much a line matters, and the styled tier draws `warn` and
16
- * `error` lines apart. The text is the line's own content, so you can mark it
17
- * up yourself.
16
+ * `error` lines apart. A line at `debug`, `warn` or `error` says its level in
17
+ * words before its text, so a reader moving through the log hears it as well
18
+ * as the color shows it. The text is the line's own content, so you can mark
19
+ * it up yourself.
18
20
  *
19
21
  * @since 0.200.0
20
22
  * @status experimental
@@ -22,6 +24,9 @@ export type LogTimes = 'absolute' | 'relative';
22
24
  *
23
25
  * @slot - The text of the line.
24
26
  *
27
+ * @csspart count - How many times a line written again straight after itself was written, where the log's `collapse` folds repeats. Hidden from screen readers, which hear `count-label`.
28
+ * @csspart count-label - What a screen reader hears for the count, such as "3 times". Visually hidden in the styled tier.
29
+ * @csspart level - What a screen reader hears before the text for a line at `debug`, `warn` or `error`, such as "Warning:". Visually hidden in the styled tier.
25
30
  * @csspart line - The row the number, the time, and the text sit in.
26
31
  * @csspart line-debug - The row of a line at `debug`.
27
32
  * @csspart line-error - The row of a line at `error`.
@@ -43,7 +48,7 @@ export type LogTimes = 'absolute' | 'relative';
43
48
  * @cssproperty --log-run-background-color - Written on a `run` with a background, in the same form as `--log-run-color`.
44
49
  * @cssproperty --log-run-color - Written on a `run` with a text color: one of the sixteen `--log-ansi-*` colors the log publishes, or the color itself.
45
50
  *
46
- * @cssstate filtered - The log's `query` does not match the line, so it draws nothing.
51
+ * @cssstate filtered - The log's `query` does not match the line, or its `level` is quieter than the log shows, so it draws nothing.
47
52
  */
48
53
  export declare class HelixHeadlessLogLine extends HelixElement {
49
54
  /** The key this family's words are registered under, its styled tag. */
@@ -52,8 +57,18 @@ export declare class HelixHeadlessLogLine extends HelixElement {
52
57
  at: number | string;
53
58
  /** How much the line matters. Absent is `info`. */
54
59
  level?: LogLevel;
60
+ /**
61
+ * What a screen reader hears before the text of a line at each level, for a
62
+ * page in another language, keyed by level, such as `{ warn: 'Warnung:' }`.
63
+ * A line at `info` or `log` hears nothing extra, and nor does a line whose
64
+ * text already starts with its words, such as `Error: Cannot find module`.
65
+ * As an attribute it is that object as JSON.
66
+ */
67
+ levelWords: Partial<Record<LogLevel, string>>;
55
68
  /** What a screen reader hears for the link on a line's number. `{number}` is the number. */
56
69
  lineLabel: string;
70
+ /** @internal Set by the log: how many times the line was written in a row, where `collapse` folds repeats. */
71
+ logCount: number;
57
72
  /** @internal Set by the log: whether the query hides the line. */
58
73
  logFiltered: boolean;
59
74
  /** @internal Set by the log: whether the line belongs to a step. */
@@ -80,11 +95,23 @@ export declare class HelixHeadlessLogLine extends HelixElement {
80
95
  logTimes: '' | LogTimes;
81
96
  /** @internal Set by the log: the text as styled runs, for a line drawn from data. */
82
97
  runs: readonly LogRun[] | undefined;
98
+ /**
99
+ * What a screen reader hears after a line written more than once in a row.
100
+ * `{count}` is how many times, in the locale.
101
+ */
102
+ timesLabel: string;
83
103
  /**
84
104
  * @internal Read by the log to find its lines among its children and inside
85
105
  * its groups, which is cheaper than `instanceof`.
86
106
  */
87
107
  readonly isLogLine = true;
108
+ /**
109
+ * How the text a page wrote starts, so a line whose text already says its
110
+ * level does not hear it twice. Read from the second update on, which a log
111
+ * asks for once it has hydrated, as the server has no children to read and
112
+ * the first render is the server's.
113
+ */
114
+ private written;
88
115
  protected willUpdate(changed: PropertyValues): void;
89
116
  /**
90
117
  * The number: a link where the log gave `line-href`, a button where the log
@@ -116,6 +143,10 @@ export declare class HelixHeadlessLogLine extends HelixElement {
116
143
  * ink.
117
144
  */
118
145
  private renderRuns;
146
+ /** The count of a folded line, shown as a number and heard as words. */
147
+ private renderCount;
148
+ /** Reads the new text where the line says a level, which it may already say. */
149
+ private readonly handleSlot;
119
150
  protected render(): TemplateResult | typeof nothing;
120
151
  }
121
152
  declare global {
@@ -1,8 +1,8 @@
1
1
  import { __decorate } from "tslib";
2
- import { customElement, formatWords, HelixElement, words, wordsLocale, } from '@fusion.dev/helix-core';
2
+ import { customElement, formatWords, HelixElement, words, wordsConverter, wordsLocale, } from '@fusion.dev/helix-core';
3
3
  import { html, nothing } from 'lit';
4
4
  import { property } from 'lit/decorators.js';
5
- import { timeOf } from '../buffer.js';
5
+ import { levelSaid, timeOf } from '../buffer.js';
6
6
  import { markMatches } from '../rows.js';
7
7
  const NAME = /^[a-z-]+$/;
8
8
  const elapsed = (seconds) => {
@@ -16,7 +16,13 @@ let HelixHeadlessLogLine = class HelixHeadlessLogLine extends HelixElement {
16
16
  constructor() {
17
17
  super(...arguments);
18
18
  this.at = '';
19
+ this.levelWords = {
20
+ debug: 'Debug:',
21
+ error: 'Error:',
22
+ warn: 'Warning:',
23
+ };
19
24
  this.lineLabel = 'Line {number}';
25
+ this.logCount = 1;
20
26
  this.logFiltered = false;
21
27
  this.logGrouped = false;
22
28
  this.logHref = '';
@@ -30,12 +36,22 @@ let HelixHeadlessLogLine = class HelixHeadlessLogLine extends HelixElement {
30
36
  this.logStop = false;
31
37
  this.logTimes = '';
32
38
  this.runs = undefined;
39
+ this.timesLabel = '{count} times';
33
40
  this.isLogLine = true;
41
+ this.written = '';
42
+ this.handleSlot = () => {
43
+ if (this.level !== undefined && this.levelWords[this.level]) {
44
+ this.requestUpdate();
45
+ }
46
+ };
34
47
  }
35
48
  static { this.words = 'helix-log'; }
36
49
  willUpdate(changed) {
37
50
  super.willUpdate(changed);
38
51
  this.toggleState('filtered', this.logFiltered);
52
+ if (this.hasUpdated && !this.runs) {
53
+ this.written = (this.textContent ?? '').trimStart().slice(0, 40);
54
+ }
39
55
  }
40
56
  renderNumber() {
41
57
  if (!this.logNumbers || this.logNumber <= 0) {
@@ -83,7 +99,7 @@ let HelixHeadlessLogLine = class HelixHeadlessLogLine extends HelixElement {
83
99
  return html `<time part="time" datetime=${new Date(time).toISOString()}>${reads}</time>`;
84
100
  }
85
101
  renderText() {
86
- return html `<slot>${this.runs ? this.renderRuns() : nothing}</slot>`;
102
+ return html `<slot @slotchange=${this.handleSlot}>${this.runs ? this.renderRuns() : nothing}</slot>`;
87
103
  }
88
104
  renderRuns() {
89
105
  return markMatches(this.runs ?? [], this.logQuery).map((piece) => {
@@ -106,14 +122,23 @@ let HelixHeadlessLogLine = class HelixHeadlessLogLine extends HelixElement {
106
122
  return piece.match ? html `<mark part="match">${run}</mark>` : run;
107
123
  });
108
124
  }
125
+ renderCount() {
126
+ if (this.logCount < 2) {
127
+ return nothing;
128
+ }
129
+ return html ` <span part="count" aria-hidden="true">${this.logCount}</span><span part="count-label"
130
+ >${formatWords(this, 'timesLabel', { count: this.logCount })}</span
131
+ >`;
132
+ }
109
133
  render() {
110
134
  if (this.logFiltered) {
111
135
  return nothing;
112
136
  }
113
137
  const level = this.level;
138
+ const said = levelSaid(level === undefined ? undefined : this.levelWords[level], this.runs ? this.runs.map((run) => run.text).join('') : this.written);
114
139
  return html `<div
115
- part="line${level ? { debug: ' line-debug', error: ' line-error', info: '', warn: ' line-warn' }[level] : ''}${this.logMarked ? ' line-marked' : ''}${this.logSelected ? ' line-selected' : ''}${this.logGrouped ? ' line-grouped' : ''}"
116
- >${this.renderNumber()}${this.renderTime()}<span part="text">${this.renderText()}</span></div>`;
140
+ part="line${level ? { debug: ' line-debug', error: ' line-error', info: '', log: '', warn: ' line-warn' }[level] : ''}${this.logMarked ? ' line-marked' : ''}${this.logSelected ? ' line-selected' : ''}${this.logGrouped ? ' line-grouped' : ''}"
141
+ >${this.renderNumber()}${this.renderTime()}${said ? html `<span part="level">${said} </span>` : nothing}<span part="text">${this.renderText()}</span>${this.renderCount()}</div>`;
117
142
  }
118
143
  };
119
144
  __decorate([
@@ -122,10 +147,17 @@ __decorate([
122
147
  __decorate([
123
148
  property({ reflect: true })
124
149
  ], HelixHeadlessLogLine.prototype, "level", void 0);
150
+ __decorate([
151
+ words,
152
+ property({ attribute: 'level-words', converter: wordsConverter })
153
+ ], HelixHeadlessLogLine.prototype, "levelWords", void 0);
125
154
  __decorate([
126
155
  words,
127
156
  property({ attribute: 'line-label' })
128
157
  ], HelixHeadlessLogLine.prototype, "lineLabel", void 0);
158
+ __decorate([
159
+ property({ attribute: false })
160
+ ], HelixHeadlessLogLine.prototype, "logCount", void 0);
129
161
  __decorate([
130
162
  property({ attribute: false })
131
163
  ], HelixHeadlessLogLine.prototype, "logFiltered", void 0);
@@ -165,6 +197,10 @@ __decorate([
165
197
  __decorate([
166
198
  property({ attribute: false })
167
199
  ], HelixHeadlessLogLine.prototype, "runs", void 0);
200
+ __decorate([
201
+ words,
202
+ property({ attribute: 'times-label' })
203
+ ], HelixHeadlessLogLine.prototype, "timesLabel", void 0);
168
204
  HelixHeadlessLogLine = __decorate([
169
205
  customElement('helix-headless-log-line')
170
206
  ], HelixHeadlessLogLine);
@@ -1,6 +1,6 @@
1
1
  import { HelixElement } from '@fusion.dev/helix-core';
2
2
  import { type PropertyValues, type TemplateResult } from 'lit';
3
- import { type LogGroup, type LogLine, type LogParser } from '../buffer.js';
3
+ import { type LogGroup, type LogLevel, type LogLine, type LogParser } from '../buffer.js';
4
4
  import type { LogTimes } from '../line/log-line.js';
5
5
  /** Text a log reads as it arrives: a stream, or anything `for await` reads. */
6
6
  export type LogSource = AsyncIterable<string | Uint8Array> | ReadableStream<string | Uint8Array>;
@@ -117,9 +117,12 @@ declare const HelixHeadlessLog_base: (new (...args: any[]) => import("@fusion.de
117
117
  * @csspart group-summary-running - The button of a step drawn from `groups` that is `running`.
118
118
  * @csspart group-summary-skipped - The button of a step drawn from `groups` that was `skipped`.
119
119
  * @csspart line - The row of a line drawn from data.
120
+ * @csspart line-count - How many times a folded line was written, under `collapse`. Hidden from screen readers, which hear `line-count-label`.
121
+ * @csspart line-count-label - What a screen reader hears for a folded line's count, such as "3 times". Visually hidden in the styled tier.
120
122
  * @csspart line-debug - The row of a line drawn from data at `debug`.
121
123
  * @csspart line-error - The row of a line drawn from data at `error`.
122
124
  * @csspart line-grouped - The row of a line drawn from data that belongs to a step.
125
+ * @csspart line-level - What a screen reader hears before the text of a line drawn from data at `debug`, `warn` or `error`. Visually hidden in the styled tier.
123
126
  * @csspart line-marked - The row of a line drawn from data that `mark-lines` names.
124
127
  * @csspart line-match - Text in a line drawn from data that matches `query`.
125
128
  * @csspart line-number - The number of a line drawn from data.
@@ -164,7 +167,7 @@ declare const HelixHeadlessLog_base: (new (...args: any[]) => import("@fusion.de
164
167
  *
165
168
  * @cssstate done - The run is over: `done` is set, or `source` ended.
166
169
  * @cssstate empty - The log has no lines.
167
- * @cssstate filtered - `query` is set, so only matching lines are shown.
170
+ * @cssstate filtered - `query` or `level` is set, so only the lines they keep are shown.
168
171
  * @cssstate pinned - The log follows its newest line.
169
172
  */
170
173
  export declare class HelixHeadlessLog extends HelixHeadlessLog_base {
@@ -176,6 +179,13 @@ export declare class HelixHeadlessLog extends HelixHeadlessLog_base {
176
179
  static words: string;
177
180
  /** How tall a row is assumed to be before one has been drawn, in pixels. */
178
181
  static readonly ESTIMATE = 20;
182
+ /**
183
+ * Folds a line written again straight after itself into it, with a count,
184
+ * as a browser's console does, so a loop writing one warning a thousand
185
+ * times is one line. For lines written or given as data, read as they
186
+ * arrive; numbers, `line-href` and the selection count a folded line once.
187
+ */
188
+ collapse: boolean;
179
189
  /**
180
190
  * Whether the log opens following its newest line, when `pinned` is not set.
181
191
  * `default-pinned="false"` opens it where it starts.
@@ -202,6 +212,7 @@ export declare class HelixHeadlessLog extends HelixHeadlessLog_base {
202
212
  errorLabel: string;
203
213
  /** The log's name, read by screen readers, where `label` is empty. */
204
214
  fallbackLabel: string;
215
+ set format(next: 'github-actions' | 'vercel' | undefined);
205
216
  /**
206
217
  * The format written text is in, read with a parser Helix ships:
207
218
  * `github-actions` reads a GitHub Actions log's workflow commands into steps
@@ -209,7 +220,6 @@ export declare class HelixHeadlessLog extends HelixHeadlessLog_base {
209
220
  * reads none. `parse` wins over it.
210
221
  */
211
222
  get format(): 'github-actions' | 'vercel' | undefined;
212
- set format(next: 'github-actions' | 'vercel' | undefined);
213
223
  /**
214
224
  * What a screen reader hears when a step finishes or fails. `{heading}` is
215
225
  * the step's heading and `{status}` is `done` or `failed`.
@@ -225,6 +235,13 @@ export declare class HelixHeadlessLog extends HelixHeadlessLog_base {
225
235
  groups: readonly LogGroup[];
226
236
  /** The log's name, read by screen readers. */
227
237
  label: string;
238
+ /**
239
+ * The quietest level shown: `info` hides `debug` and `log` lines, `warn`
240
+ * shows warnings and errors, and `error` errors only. Unset shows every line.
241
+ * Lines it hides are kept, so lowering it shows them again, and a step shows
242
+ * while it holds a line that shows.
243
+ */
244
+ level?: LogLevel;
228
245
  /**
229
246
  * The most lines kept, or 0 to keep them all. Past it the oldest lines are
230
247
  * let go, and the line above the log says how many.
@@ -382,6 +399,13 @@ export declare class HelixHeadlessLog extends HelixHeadlessLog_base {
382
399
  * after the last one is the line still being written.
383
400
  */
384
401
  write(chunk: string): void;
402
+ /**
403
+ * Adds one line as data, after the lines the log holds: its text, and its
404
+ * level, time and group where it has them. For lines that arrive one at a
405
+ * time with a level of their own, such as a console's, where `write` takes
406
+ * text.
407
+ */
408
+ writeLine(line: LogLine): void;
385
409
  /**
386
410
  * Lets every line go, and forgets which groups the reader opened, keeping
387
411
  * `groups`, `parse`, and `limit`.