@orkestrel/console 0.0.5 → 0.0.7

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.
@@ -287,17 +287,6 @@ var STATUS_LEVELS = Object.freeze([
287
287
  "info"
288
288
  ]);
289
289
  /**
290
- * The tree connectors {@link import('./helpers.js').renderTree} draws — the `├─` branch (a
291
- * non-last child), the `└─` corner (the last child), the `│ ` guide (carried down through an
292
- * earlier branch's descendants), and the ` ` gap (under a last branch). Frozen.
293
- */
294
- var TREE_CHARS = Object.freeze({
295
- branch: "├─ ",
296
- corner: "└─ ",
297
- guide: "│ ",
298
- gap: " "
299
- });
300
- /**
301
290
  * The default visible column width for the width-aware renderers — the separator rule and a
302
291
  * {@link import('./helpers.js').renderBox} with no explicit `width`, and the reporter's
303
292
  * `section` rule. A sane terminal default (80 columns); a caller overrides it per-call or via
@@ -413,6 +402,74 @@ var BAR_EMPTY = "░";
413
402
  * track is one inline element, not a full-width rule.
414
403
  */
415
404
  var DEFAULT_BAR_WIDTH = 30;
405
+ /**
406
+ * The default {@link Theme} — every role bound to its default {@link Style}, deeply frozen.
407
+ * The base {@link import('./factories.js').createTheme} merges over, and the theme every
408
+ * entity uses when none is supplied.
409
+ *
410
+ * @remarks
411
+ * - `levels` — each {@link LogLevel} label in its {@link LEVEL_COLORS} color, no attributes.
412
+ * - `statuses` — each {@link StatusLevel}'s {@link STATUS_ICONS} glyph in its
413
+ * {@link STATUS_COLORS} color.
414
+ * - `accent` — `cyan`: the spinner glyph, the progress fill, a step prefix.
415
+ * - `chrome` — `dim`: separators, box / table / tree frames, and a log line's timestamp /
416
+ * name / data surround. A color-free attribute, so chrome recedes on any background.
417
+ */
418
+ var DEFAULT_THEME = Object.freeze({
419
+ levels: Object.freeze({
420
+ debug: Object.freeze({
421
+ foreground: LEVEL_COLORS.debug,
422
+ attributes: EMPTY_STYLE.attributes
423
+ }),
424
+ info: Object.freeze({
425
+ foreground: LEVEL_COLORS.info,
426
+ attributes: EMPTY_STYLE.attributes
427
+ }),
428
+ warn: Object.freeze({
429
+ foreground: LEVEL_COLORS.warn,
430
+ attributes: EMPTY_STYLE.attributes
431
+ }),
432
+ error: Object.freeze({
433
+ foreground: LEVEL_COLORS.error,
434
+ attributes: EMPTY_STYLE.attributes
435
+ })
436
+ }),
437
+ statuses: Object.freeze({
438
+ success: Object.freeze({
439
+ icon: STATUS_ICONS.success,
440
+ style: Object.freeze({
441
+ foreground: STATUS_COLORS.success,
442
+ attributes: EMPTY_STYLE.attributes
443
+ })
444
+ }),
445
+ error: Object.freeze({
446
+ icon: STATUS_ICONS.error,
447
+ style: Object.freeze({
448
+ foreground: STATUS_COLORS.error,
449
+ attributes: EMPTY_STYLE.attributes
450
+ })
451
+ }),
452
+ warn: Object.freeze({
453
+ icon: STATUS_ICONS.warn,
454
+ style: Object.freeze({
455
+ foreground: STATUS_COLORS.warn,
456
+ attributes: EMPTY_STYLE.attributes
457
+ })
458
+ }),
459
+ info: Object.freeze({
460
+ icon: STATUS_ICONS.info,
461
+ style: Object.freeze({
462
+ foreground: STATUS_COLORS.info,
463
+ attributes: EMPTY_STYLE.attributes
464
+ })
465
+ })
466
+ }),
467
+ accent: Object.freeze({
468
+ foreground: "cyan",
469
+ attributes: EMPTY_STYLE.attributes
470
+ }),
471
+ chrome: Object.freeze({ attributes: Object.freeze(["dim"]) })
472
+ });
416
473
  //#endregion
417
474
  //#region src/core/errors.ts
418
475
  /**
@@ -452,6 +509,127 @@ function isConsoleError(value) {
452
509
  return value instanceof ConsoleError;
453
510
  }
454
511
  //#endregion
512
+ //#region src/core/Capture.ts
513
+ /**
514
+ * An observable console interceptor (AGENTS §13) — it takes control of the global `console.*` on
515
+ * the READ side. While `active`, every configured `console.x` call is captured as a frozen
516
+ * {@link CapturedMessage}, buffered (total + by level, bounded), emitted on `capture`, and — per
517
+ * options — mirrored to the real console and/or forwarded to a {@link SinkInterface}.
518
+ *
519
+ * @remarks
520
+ * - **Snapshot-at-start (the no-capture-loop principle).** `start()` snapshots the CURRENT
521
+ * `console[level]` for each configured {@link CaptureLevel}, then installs the wrappers. The
522
+ * mirror writes through that snapshot — so our OWN console sink output (the Logger / Reporter,
523
+ * which snapshot the real `console` at creation) is never recaptured: `Capture` catches
524
+ * THIRD-PARTY `console.*`, not our writes. Create your loggers BEFORE installing a capture.
525
+ * - **Idempotent + PROCESS-GLOBAL + NON-REENTRANT.** `start()` while already `active` is a no-op
526
+ * (never double-patches); `stop()` while inactive is a no-op. It patches the ONE global
527
+ * `console`, so at most ONE capture may be active at a time — running two concurrently
528
+ * interleaves their buffers and clobbers each other's restore.
529
+ * - **Bounded buffers.** `messages()` / `messages(level)` — the total buffer and each by-level
530
+ * bucket are each capped at `limit`
531
+ * (oldest dropped first), never unbounded — the same retention precedent as {@link Logger}.
532
+ * - **Lifecycle (§10).** `start` / `stop` toggle interception (emitting `start` / `stop`);
533
+ * `destroy()` stops (restoring `console`) then destroys the emitter.
534
+ *
535
+ * @example
536
+ * ```ts
537
+ * const capture = new Capture({ levels: ['warn', 'error'], mirror: true })
538
+ * capture.start()
539
+ * console.warn('third-party noise') // captured AND mirrored to the real console
540
+ * capture.messages('warn') // [{ level: 'warn', text: 'third-party noise', time: … }]
541
+ * capture.stop() // console.warn restored
542
+ * ```
543
+ */
544
+ var Capture = class {
545
+ #emitter;
546
+ #levels;
547
+ #mirror;
548
+ #sink;
549
+ #limit;
550
+ #messages = [];
551
+ #buckets = /* @__PURE__ */ new Map();
552
+ #originals = /* @__PURE__ */ new Map();
553
+ #active = false;
554
+ constructor(options) {
555
+ this.#emitter = new Emitter({
556
+ ...options?.on !== void 0 ? { on: options.on } : {},
557
+ ...options?.error !== void 0 ? { error: options.error } : {}
558
+ });
559
+ this.#levels = options?.levels ?? DEFAULT_CAPTURE_LEVELS;
560
+ this.#mirror = options?.mirror ?? false;
561
+ this.#sink = options?.sink;
562
+ this.#limit = options?.limit ?? 1e3;
563
+ for (const level of this.#levels) this.#buckets.set(level, []);
564
+ }
565
+ get emitter() {
566
+ return this.#emitter;
567
+ }
568
+ get active() {
569
+ return this.#active;
570
+ }
571
+ start() {
572
+ if (this.#active) return;
573
+ this.#active = true;
574
+ const target = console;
575
+ for (const level of this.#levels) {
576
+ const original = target[level];
577
+ this.#originals.set(level, original);
578
+ const mirror = original.bind(console);
579
+ target[level] = this.#captureCall.bind(this, level, mirror);
580
+ }
581
+ this.#emitter.emit("start");
582
+ }
583
+ stop() {
584
+ if (!this.#active) return;
585
+ this.#active = false;
586
+ const target = console;
587
+ for (const [level, original] of this.#originals) target[level] = original;
588
+ this.#originals.clear();
589
+ this.#emitter.emit("stop");
590
+ }
591
+ messages(level) {
592
+ if (level === void 0) return [...this.#messages];
593
+ return [...this.#buckets.get(level) ?? []];
594
+ }
595
+ clear() {
596
+ this.#messages.length = 0;
597
+ for (const bucket of this.#buckets.values()) bucket.length = 0;
598
+ }
599
+ destroy() {
600
+ this.stop();
601
+ this.#emitter.destroy();
602
+ }
603
+ #captureCall(level, mirror, ...args) {
604
+ this.#intercept(level, args, mirror);
605
+ }
606
+ #intercept(level, args, mirror) {
607
+ const message = this.#capture(level, args);
608
+ this.#retain(message);
609
+ this.#emitter.emit("capture", message);
610
+ if (this.#mirror) mirror(...args);
611
+ if (this.#sink !== void 0) try {
612
+ this.#sink.write(message.text, CAPTURE_LEVEL_MAP[level]);
613
+ } catch {}
614
+ }
615
+ #capture(level, args) {
616
+ return Object.freeze({
617
+ level,
618
+ text: formatArgs(args),
619
+ time: Date.now()
620
+ });
621
+ }
622
+ #retain(message) {
623
+ this.#push(this.#messages, message);
624
+ const bucket = this.#buckets.get(message.level);
625
+ if (bucket !== void 0) this.#push(bucket, message);
626
+ }
627
+ #push(buffer, message) {
628
+ buffer.push(message);
629
+ if (buffer.length > this.#limit) buffer.shift();
630
+ }
631
+ };
632
+ //#endregion
455
633
  //#region src/core/helpers.ts
456
634
  /**
457
635
  * Remove every ANSI escape sequence from `text`, returning the plain visible string.
@@ -518,6 +696,27 @@ function width(text) {
518
696
  return [...strip(text)].length;
519
697
  }
520
698
  /**
699
+ * Snapshot and deeply freeze one {@link Style} value.
700
+ *
701
+ * @param style - The caller-owned style to snapshot
702
+ * @returns A frozen style record with an independently frozen attributes list
703
+ *
704
+ * @remarks
705
+ * The record spread captures accessor values once and preserves each present color channel.
706
+ * Copying `attributes` prevents later mutation of a caller-owned list from changing the result.
707
+ *
708
+ * @example
709
+ * ```ts
710
+ * freezeStyle({ foreground: 'red', attributes: ['bold'] })
711
+ * ```
712
+ */
713
+ function freezeStyle(style) {
714
+ return Object.freeze({
715
+ ...style,
716
+ attributes: Object.freeze([...style.attributes])
717
+ });
718
+ }
719
+ /**
521
720
  * Whether a record at `level` passes a logger gated at `threshold` — i.e. its severity is
522
721
  * at or above the threshold's.
523
722
  *
@@ -559,7 +758,7 @@ function formatTime(time) {
559
758
  *
560
759
  * @remarks
561
760
  * Layout: `{time} {LEVEL} {[name]} {message}{ data}` — the ISO timestamp (dimmed), the
562
- * upper-cased level label (colored by {@link LEVEL_COLORS} styling ORTHOGONAL to level),
761
+ * upper-cased level label (rendered through the theme's level role),
563
762
  * the originating logger's `name` in brackets (omitted when absent), the message, and the
564
763
  * structured `data` appended as compact JSON (omitted when absent / empty). Coloring flows
565
764
  * through the injected `styler`, so a disabled styler yields a plain line and a browser
@@ -568,19 +767,24 @@ function formatTime(time) {
568
767
  *
569
768
  * @param record - The {@link LogRecord} to render
570
769
  * @param styler - The {@link StylerInterface} the labels are colored through
770
+ * @param theme - The {@link Theme} supplying the level and chrome roles
571
771
  * @returns The formatted, styled line (no trailing newline — the sink's target adds it)
572
772
  *
573
773
  * @example
574
774
  * ```ts
575
- * formatRecord({ level: 'warn', message: 'low disk', time: 0, name: 'fs' }, createStyler())
775
+ * formatRecord(
776
+ * { level: 'warn', message: 'low disk', time: 0, name: 'fs' },
777
+ * createStyler(),
778
+ * DEFAULT_THEME,
779
+ * )
576
780
  * // '<dim>1970-01-01T00:00:00.000Z</> <yellow>WARN</> [fs] low disk'
577
781
  * ```
578
782
  */
579
- function formatRecord(record, styler) {
580
- const time = styler.dim(formatTime(record.time));
581
- const label = styler[LEVEL_COLORS[record.level]](record.level.toUpperCase());
582
- const name = record.name === void 0 ? "" : ` ${styler.dim(`[${record.name}]`)}`;
583
- const data = record.data === void 0 || Object.keys(record.data).length === 0 ? "" : ` ${styler.dim(JSON.stringify(record.data))}`;
783
+ function formatRecord(record, styler, theme) {
784
+ const time = styler.render(theme.chrome, formatTime(record.time));
785
+ const label = styler.render(theme.levels[record.level], record.level.toUpperCase());
786
+ const name = record.name === void 0 ? "" : ` ${styler.render(theme.chrome, `[${record.name}]`)}`;
787
+ const data = record.data === void 0 || Object.keys(record.data).length === 0 ? "" : ` ${styler.render(theme.chrome, JSON.stringify(record.data))}`;
584
788
  return `${time} ${label}${name} ${record.message}${data}`;
585
789
  }
586
790
  /**
@@ -647,10 +851,12 @@ function formatDuration(ms) {
647
851
  *
648
852
  * @param styler - The {@link StylerInterface} to color with, or `undefined` for no styling
649
853
  * @param text - The glyphs / text to color
854
+ * @param style - An optional {@link Style} to render by value instead of the styler's chain
650
855
  * @returns `styler(text)` when a styler is given, else `text` unchanged
651
856
  */
652
- function paint(styler, text) {
653
- return styler === void 0 ? text : styler(text);
857
+ function paint(styler, text, style) {
858
+ if (styler === void 0) return text;
859
+ return style === void 0 ? styler(text) : styler.render(style, text);
654
860
  }
655
861
  /**
656
862
  * Repeat `unit` until it fills exactly `count` VISIBLE columns, trimming a trailing partial
@@ -716,12 +922,12 @@ function cellAt(row, index) {
716
922
  function renderSeparator(options) {
717
923
  const total = options.width ?? 80;
718
924
  const fill = options.fill ?? "─";
719
- if (options.title === void 0) return paint(options.styler, repeatTo(fill, total));
720
- const gapped = ` ${paint(options.styler, options.title)} `;
925
+ if (options.title === void 0) return paint(options.styler, repeatTo(fill, total), options.style);
926
+ const gapped = ` ${paint(options.styler, options.title, options.style)} `;
721
927
  const room = total - width(options.title) - 2;
722
928
  if (room <= 0) return gapped;
723
929
  const left = Math.floor(room / 2);
724
- return `${paint(options.styler, repeatTo(fill, left))}${gapped}${paint(options.styler, repeatTo(fill, room - left))}`;
930
+ return `${paint(options.styler, repeatTo(fill, left), options.style)}${gapped}${paint(options.styler, repeatTo(fill, room - left), options.style)}`;
725
931
  }
726
932
  /**
727
933
  * Render `content` framed in box-drawing characters, optionally captioned, width-aware so
@@ -753,18 +959,18 @@ function renderBox(options) {
753
959
  const budget = options.width === void 0 ? 0 : options.width - 2 - padding * 2;
754
960
  const inner = lines.reduce((max, line) => Math.max(max, width(line)), Math.max(0, titleRoom, budget));
755
961
  const gutter = " ".repeat(padding);
756
- const bar = paint(styler, chars.vertical);
962
+ const bar = paint(styler, chars.vertical, options.style);
757
963
  const span = inner + padding * 2;
758
964
  let top;
759
- if (options.title === void 0) top = paint(styler, `${chars.topLeft}${repeatTo(chars.horizontal, span)}${chars.topRight}`);
965
+ if (options.title === void 0) top = paint(styler, `${chars.topLeft}${repeatTo(chars.horizontal, span)}${chars.topRight}`, options.style);
760
966
  else {
761
967
  const caption = ` ${options.title} `;
762
968
  const room = span - width(caption);
763
- const lead = paint(styler, repeatTo(chars.horizontal, 1));
764
- const rest = room - 1 <= 0 ? "" : paint(styler, repeatTo(chars.horizontal, room - 1));
765
- top = `${paint(styler, chars.topLeft)}${lead}${paint(styler, caption)}${rest}${paint(styler, chars.topRight)}`;
969
+ const lead = paint(styler, repeatTo(chars.horizontal, 1), options.style);
970
+ const rest = room - 1 <= 0 ? "" : paint(styler, repeatTo(chars.horizontal, room - 1), options.style);
971
+ top = `${paint(styler, chars.topLeft, options.style)}${lead}${paint(styler, caption, options.style)}${rest}${paint(styler, chars.topRight, options.style)}`;
766
972
  }
767
- const bottom = paint(styler, `${chars.bottomLeft}${repeatTo(chars.horizontal, inner + padding * 2)}${chars.bottomRight}`);
973
+ const bottom = paint(styler, `${chars.bottomLeft}${repeatTo(chars.horizontal, inner + padding * 2)}${chars.bottomRight}`, options.style);
768
974
  const body = lines.map((line) => `${bar}${gutter}${align(line, inner)}${gutter}${bar}`);
769
975
  return [
770
976
  top,
@@ -799,14 +1005,14 @@ function renderTable(options) {
799
1005
  const columns = options.columns;
800
1006
  const widths = columns.map((column, index) => options.rows.reduce((max, row) => Math.max(max, width(cellAt(row, index))), width(column.label)));
801
1007
  const aligns = columns.map((column) => column.align ?? "left");
802
- const renderedRows = [columns.map((column) => paint(styler, column.label)), ...options.rows.map((row) => columns.map((_column, index) => cellAt(row, index)))].map((cells) => {
803
- const bar = paint(styler, chars.vertical);
1008
+ const renderedRows = [columns.map((column) => paint(styler, column.label, options.style)), ...options.rows.map((row) => columns.map((_column, index) => cellAt(row, index)))].map((cells) => {
1009
+ const bar = paint(styler, chars.vertical, options.style);
804
1010
  return `${bar}${cells.map((cell, index) => ` ${align(cell, widths[index] ?? width(cell), aligns[index] ?? "left")} `).join(bar)}${bar}`;
805
1011
  });
806
1012
  const segments = widths.map((columnWidth) => repeatTo(chars.horizontal, columnWidth + 2));
807
- const top = paint(styler, `${chars.topLeft}${segments.join(chars.teeDown)}${chars.topRight}`);
808
- const rule = paint(styler, `${chars.teeRight}${segments.join(chars.cross)}${chars.teeLeft}`);
809
- const bottom = paint(styler, `${chars.bottomLeft}${segments.join(chars.teeUp)}${chars.bottomRight}`);
1013
+ const top = paint(styler, `${chars.topLeft}${segments.join(chars.teeDown)}${chars.topRight}`, options.style);
1014
+ const rule = paint(styler, `${chars.teeRight}${segments.join(chars.cross)}${chars.teeLeft}`, options.style);
1015
+ const bottom = paint(styler, `${chars.bottomLeft}${segments.join(chars.teeUp)}${chars.bottomRight}`, options.style);
810
1016
  return [
811
1017
  top,
812
1018
  ...renderedRows.slice(0, 1),
@@ -821,7 +1027,7 @@ function renderTable(options) {
821
1027
  *
822
1028
  * @remarks
823
1029
  * The `root` label is the unindented first line; its descendants are drawn beneath it with
824
- * {@link TREE_CHARS} — `├─ ` before each child but the last, `└─ ` before the last, and the
1030
+ * {@link BORDER_CHARS} — `├─ ` before each child but the last, `└─ ` before the last, and the
825
1031
  * carried prefix using `│ ` under an ancestor that still has later siblings or ` ` under a
826
1032
  * last ancestor (so the guides line up exactly under the branch they descend from). Node
827
1033
  * labels are written as given (an already-styled label is honored); `options.styler` colors
@@ -839,7 +1045,11 @@ function renderTable(options) {
839
1045
  * ```
840
1046
  */
841
1047
  function renderTree(options) {
842
- return [options.root.label, ...renderTreeChildren(options.root.children ?? [], "", options.styler)].join("\n");
1048
+ const border = options.border ?? "single";
1049
+ return [options.root.label, ...renderTreeChildren(options.root.children ?? [], "", {
1050
+ ...options,
1051
+ border
1052
+ })].join("\n");
843
1053
  }
844
1054
  /**
845
1055
  * Render the connector-prefixed lines for a {@link TreeNode} list — the recursive core
@@ -853,22 +1063,27 @@ function renderTree(options) {
853
1063
  *
854
1064
  * @param nodes - The sibling {@link TreeNode}s to render at this depth
855
1065
  * @param prefix - The guide/gap string carried in from the ancestor chain (`''` at the root)
856
- * @param styler - The {@link StylerInterface} connectors are colored through, when supplied
1066
+ * @param options - The required `border` selection plus optional connector `styler` and
1067
+ * by-value `style`
857
1068
  * @returns The rendered lines for `nodes` and all their descendants
858
1069
  *
859
1070
  * @example
860
1071
  * ```ts
861
- * renderTreeChildren([{ label: 'a' }, { label: 'b' }], '')
1072
+ * renderTreeChildren([{ label: 'a' }, { label: 'b' }], '', { border: 'single' })
862
1073
  * // ['├─ a', '└─ b']
863
1074
  * ```
864
1075
  */
865
- function renderTreeChildren(nodes, prefix, styler) {
1076
+ function renderTreeChildren(nodes, prefix, options) {
1077
+ const chars = BORDER_CHARS[options.border];
1078
+ const branch = `${chars.teeRight}${chars.horizontal} `;
1079
+ const corner = `${chars.bottomLeft}${chars.horizontal} `;
1080
+ const guide = `${chars.vertical} `;
866
1081
  const lines = [];
867
1082
  nodes.forEach((node, index) => {
868
1083
  const last = index === nodes.length - 1;
869
- lines.push(`${prefix}${paint(styler, last ? TREE_CHARS.corner : TREE_CHARS.branch)}${node.label}`);
870
- const carry = `${prefix}${paint(styler, last ? TREE_CHARS.gap : TREE_CHARS.guide)}`;
871
- lines.push(...renderTreeChildren(node.children ?? [], carry, styler));
1084
+ lines.push(`${prefix}${paint(options.styler, last ? corner : branch, options.style)}${node.label}`);
1085
+ const carry = `${prefix}${paint(options.styler, last ? " " : guide, options.style)}`;
1086
+ lines.push(...renderTreeChildren(node.children ?? [], carry, options));
872
1087
  });
873
1088
  return lines;
874
1089
  }
@@ -975,7 +1190,77 @@ function renderBar(options) {
975
1190
  const current = options.total <= 0 ? 0 : Math.max(0, Math.min(options.total, options.current));
976
1191
  const fraction = options.total <= 0 ? 1 : current / options.total;
977
1192
  const filledCells = Math.round(fraction * track);
978
- return `${`${paint(options.styler, repeatTo(fill, filledCells))}${repeatTo(empty, track - filledCells)}`} ${Math.round(fraction * 100)}% (${current}/${options.total})`;
1193
+ return `${`${paint(options.styler, repeatTo(fill, filledCells), options.style)}${repeatTo(empty, track - filledCells)}`} ${Math.round(fraction * 100)}% (${current}/${options.total})`;
1194
+ }
1195
+ /**
1196
+ * Run `fn` with the global `console.*` captured for its duration, returning the function's `value`
1197
+ * plus the {@link import('./types.js').CapturedMessage}s it logged — the scoped, self-restoring
1198
+ * ergonomic form of {@link createCapture}.
1199
+ *
1200
+ * @param fn - The function to run under capture; may be sync (returns `T`) or async (returns
1201
+ * `Promise<T>`)
1202
+ * @param options - See {@link CaptureOptions} (`levels` / `mirror` / `sink` / `limit` / `on` /
1203
+ * `error`); the capture is started for the duration of `fn` regardless
1204
+ * @returns For a sync `fn`, a {@link CaptureResult}`<T>` (`{ value, messages }`); for an async
1205
+ * `fn`, a `Promise<CaptureResult<T>>` (awaited, then console restored)
1206
+ *
1207
+ * @remarks
1208
+ * - **Always restores.** `start()` runs before `fn`; `stop()` runs in a `finally`, so `console` is
1209
+ * restored even if `fn` throws / rejects (the throw / rejection still propagates). The capture
1210
+ * is local — created, used, and destroyed within the call.
1211
+ * - **Sync vs async.** A `fn` returning a `Promise` is detected and AWAITED before `stop()`, so
1212
+ * captures during the async work are included; a plain `fn` stops synchronously. The return type
1213
+ * follows `fn`'s (overloaded).
1214
+ * - **PROCESS-GLOBAL caveat.** Like {@link createCapture}, this patches the one global `console`.
1215
+ * Concurrent `withCapture` calls (or a `withCapture` around other capturing code) INTERLEAVE —
1216
+ * each captures every `console.*` call in flight, and the inner `stop()` restores whatever the
1217
+ * outer had installed. Use it for sequential, scoped capture, not overlapping captures.
1218
+ *
1219
+ * @example
1220
+ * ```ts
1221
+ * import { withCapture } from '@src/core'
1222
+ *
1223
+ * const { value, messages } = withCapture(() => {
1224
+ * console.log('working')
1225
+ * return 42
1226
+ * })
1227
+ * value // 42
1228
+ * messages.map((m) => m.text) // ['working']
1229
+ *
1230
+ * // Async — awaited before console is restored.
1231
+ * const out = await withCapture(async () => {
1232
+ * console.warn('async noise')
1233
+ * return 'done'
1234
+ * })
1235
+ * out.value // 'done'
1236
+ * ```
1237
+ */
1238
+ function withCapture(fn, options) {
1239
+ const capture = new Capture(options);
1240
+ capture.start();
1241
+ try {
1242
+ const result = fn();
1243
+ if (result instanceof Promise) return result.then((value) => {
1244
+ const messages = capture.messages();
1245
+ capture.destroy();
1246
+ return {
1247
+ value,
1248
+ messages
1249
+ };
1250
+ }, (error) => {
1251
+ capture.destroy();
1252
+ throw error;
1253
+ });
1254
+ const messages = capture.messages();
1255
+ capture.destroy();
1256
+ return {
1257
+ value: result,
1258
+ messages
1259
+ };
1260
+ } catch (error) {
1261
+ capture.destroy();
1262
+ throw error;
1263
+ }
979
1264
  }
980
1265
  //#endregion
981
1266
  //#region src/core/ANSIRenderer.ts
@@ -1020,274 +1305,55 @@ var ANSIRenderer = class {
1020
1305
  }
1021
1306
  };
1022
1307
  //#endregion
1023
- //#region src/core/Styler.ts
1308
+ //#region src/core/LoggerManager.ts
1024
1309
  /**
1025
- * The fluent, composable styler the consumer-facing API over the style engine. It
1026
- * builds a {@link Style} (style as DATA) and renders it through an injected
1027
- * {@link RendererInterface} (the ANSI default, or a browser `%c` renderer at C-f). Each
1028
- * color / attribute accessor is immutable copy-on-write: it returns a NEW styler's
1029
- * surface with the token added, so `styler.red.bold('hi')` composes without mutating,
1030
- * and a base styler is freely reusable.
1310
+ * An event-free registry of named {@link Logger}s plus a convenience fan-out the §9
1311
+ * manager over the logging layer (a registry, never observable itself; each {@link Logger}
1312
+ * owns its own `emitter`).
1031
1313
  *
1032
1314
  * @remarks
1033
- * - **Callable surface.** A `Styler` is not itself callable; its {@link surface} getter
1034
- * returns the {@link StylerInterface} a render FUNCTION carrying the chainable
1035
- * accessors. The accessors are installed as LAZY getters (`Object.defineProperties`),
1036
- * so a chain materializes only the stylers it actually walks — never the full tree —
1037
- * and the recursion terminates. The factory returns that surface; this class is the
1038
- * engine behind it.
1039
- * - **Immutable.** `#foreground` and `#attribute` return a fresh `Styler` (the style is
1040
- * rebuilt, never mutated). A later color of the same channel WINS (last write); a
1041
- * repeated attribute is idempotent (de-duplicated, order preserved).
1042
- * - **`enabled` switch.** When `false`, the render function returns text VERBATIM no
1043
- * renderer call, no escape codes (for a non-TTY / `NO_COLOR` / piped output).
1044
- * - **Event-free** a pure styling primitive (AGENTS §13), like `Scheduler`.
1045
- */
1046
- var Styler = class Styler {
1047
- #renderer;
1048
- #enabled;
1049
- #style;
1050
- constructor(renderer, enabled, style) {
1051
- this.#renderer = renderer;
1052
- this.#enabled = enabled;
1053
- this.#style = style;
1054
- }
1055
- /** The accumulated style DATA the empty style on a base styler. */
1056
- get style() {
1057
- return this.#style;
1058
- }
1059
- /** Whether styling is applied; when `false`, the surface returns text unchanged. */
1060
- get enabled() {
1061
- return this.#enabled;
1062
- }
1063
- /**
1064
- * The fluent {@link StylerInterface} value — a render function (`text => string`) with
1065
- * `style`, `enabled`, and every {@link Color} / {@link Attribute} as a LAZY accessor
1066
- * (each computes the next styler's surface only when read). This is what consumers
1067
- * hold and call.
1068
- *
1069
- * @remarks
1070
- * The accessors are defined as getters (not eagerly-merged values), so accessing one
1071
- * builds exactly one child styler — the tree is never fully materialized and the
1072
- * construction terminates. The assembled function is then narrowed to
1073
- * {@link StylerInterface} through {@link #isSurface} (a real structural check), so no
1074
- * type assertion is used (AGENTS §1 / §14 — narrow, never assert).
1075
- */
1076
- get surface() {
1077
- const render = this.#render.bind(this);
1078
- const descriptors = {
1079
- style: {
1080
- value: this.#style,
1081
- enumerable: true
1082
- },
1083
- enabled: {
1084
- value: this.#enabled,
1085
- enumerable: true
1086
- }
1087
- };
1088
- for (const color of COLORS) descriptors[color] = {
1089
- get: this.#foregroundSurface.bind(this, color),
1090
- enumerable: true
1091
- };
1092
- for (const attribute of ATTRIBUTES) descriptors[attribute] = {
1093
- get: this.#attributeSurface.bind(this, attribute),
1094
- enumerable: true
1095
- };
1096
- const surface = Object.defineProperties(render, descriptors);
1097
- if (this.#isSurface(surface)) return surface;
1098
- throw new ConsoleError("INVARIANT", "console: styler surface construction is incomplete");
1099
- }
1100
- #render(text) {
1101
- return this.#enabled ? this.#renderer.render(this.#style, text) : text;
1102
- }
1103
- #foregroundSurface(color) {
1104
- return this.#foreground(color).surface;
1105
- }
1106
- #attributeSurface(attribute) {
1107
- return this.#attribute(attribute).surface;
1108
- }
1109
- #isSurface(value) {
1110
- return typeof value === "function" && "style" in value && "enabled" in value && "red" in value && "bold" in value;
1111
- }
1112
- #foreground(color) {
1113
- return new Styler(this.#renderer, this.#enabled, Object.freeze({
1114
- ...this.#style,
1115
- foreground: color
1116
- }));
1117
- }
1118
- #attribute(attribute) {
1119
- if (this.#style.attributes.includes(attribute)) return this;
1120
- return new Styler(this.#renderer, this.#enabled, Object.freeze({
1121
- ...this.#style,
1122
- attributes: Object.freeze([...this.#style.attributes, attribute])
1123
- }));
1124
- }
1125
- };
1126
- //#endregion
1127
- //#region src/core/Capture.ts
1128
- /**
1129
- * An observable console interceptor (AGENTS §13) — it takes control of the global `console.*` on
1130
- * the READ side. While `active`, every configured `console.x` call is captured as a frozen
1131
- * {@link CapturedMessage}, buffered (total + by level, bounded), emitted on `capture`, and — per
1132
- * options — mirrored to the real console and/or forwarded to a {@link SinkInterface}.
1133
- *
1134
- * @remarks
1135
- * - **Snapshot-at-start (the no-capture-loop principle).** `start()` snapshots the CURRENT
1136
- * `console[level]` for each configured {@link CaptureLevel}, then installs the wrappers. The
1137
- * mirror writes through that snapshot — so our OWN console sink output (the Logger / Reporter,
1138
- * which snapshot the real `console` at creation) is never recaptured: `Capture` catches
1139
- * THIRD-PARTY `console.*`, not our writes. Create your loggers BEFORE installing a capture.
1140
- * - **Idempotent + PROCESS-GLOBAL + NON-REENTRANT.** `start()` while already `active` is a no-op
1141
- * (never double-patches); `stop()` while inactive is a no-op. It patches the ONE global
1142
- * `console`, so at most ONE capture may be active at a time — running two concurrently
1143
- * interleaves their buffers and clobbers each other's restore.
1144
- * - **Bounded buffers.** `messages()` / `messages(level)` — the total buffer and each by-level
1145
- * bucket are each capped at `limit`
1146
- * (oldest dropped first), never unbounded — the same retention precedent as {@link Logger}.
1147
- * - **Lifecycle (§10).** `start` / `stop` toggle interception (emitting `start` / `stop`);
1148
- * `destroy()` stops (restoring `console`) then destroys the emitter.
1149
- *
1150
- * @example
1151
- * ```ts
1152
- * const capture = new Capture({ levels: ['warn', 'error'], mirror: true })
1153
- * capture.start()
1154
- * console.warn('third-party noise') // captured AND mirrored to the real console
1155
- * capture.messages('warn') // [{ level: 'warn', text: 'third-party noise', time: … }]
1156
- * capture.stop() // console.warn restored
1157
- * ```
1158
- */
1159
- var Capture = class {
1160
- #emitter;
1161
- #levels;
1162
- #mirror;
1163
- #sink;
1164
- #limit;
1165
- #messages = [];
1166
- #buckets = /* @__PURE__ */ new Map();
1167
- #originals = /* @__PURE__ */ new Map();
1168
- #active = false;
1169
- constructor(options) {
1170
- this.#emitter = new Emitter({
1171
- ...options?.on !== void 0 ? { on: options.on } : {},
1172
- ...options?.error !== void 0 ? { error: options.error } : {}
1173
- });
1174
- this.#levels = options?.levels ?? DEFAULT_CAPTURE_LEVELS;
1175
- this.#mirror = options?.mirror ?? false;
1176
- this.#sink = options?.sink;
1177
- this.#limit = options?.limit ?? 1e3;
1178
- for (const level of this.#levels) this.#buckets.set(level, []);
1179
- }
1180
- get emitter() {
1181
- return this.#emitter;
1182
- }
1183
- get active() {
1184
- return this.#active;
1185
- }
1186
- start() {
1187
- if (this.#active) return;
1188
- this.#active = true;
1189
- const target = console;
1190
- for (const level of this.#levels) {
1191
- const original = target[level];
1192
- this.#originals.set(level, original);
1193
- const mirror = original.bind(console);
1194
- target[level] = this.#captureCall.bind(this, level, mirror);
1195
- }
1196
- this.#emitter.emit("start");
1197
- }
1198
- stop() {
1199
- if (!this.#active) return;
1200
- this.#active = false;
1201
- const target = console;
1202
- for (const [level, original] of this.#originals) target[level] = original;
1203
- this.#originals.clear();
1204
- this.#emitter.emit("stop");
1205
- }
1206
- messages(level) {
1207
- if (level === void 0) return [...this.#messages];
1208
- return [...this.#buckets.get(level) ?? []];
1209
- }
1210
- clear() {
1211
- this.#messages.length = 0;
1212
- for (const bucket of this.#buckets.values()) bucket.length = 0;
1213
- }
1214
- destroy() {
1215
- this.stop();
1216
- this.#emitter.destroy();
1217
- }
1218
- #captureCall(level, mirror, ...args) {
1219
- this.#intercept(level, args, mirror);
1220
- }
1221
- #intercept(level, args, mirror) {
1222
- const message = this.#capture(level, args);
1223
- this.#retain(message);
1224
- this.#emitter.emit("capture", message);
1225
- if (this.#mirror) mirror(...args);
1226
- if (this.#sink !== void 0) try {
1227
- this.#sink.write(message.text, CAPTURE_LEVEL_MAP[level]);
1228
- } catch {}
1229
- }
1230
- #capture(level, args) {
1231
- return Object.freeze({
1232
- level,
1233
- text: formatArgs(args),
1234
- time: Date.now()
1235
- });
1236
- }
1237
- #retain(message) {
1238
- this.#push(this.#messages, message);
1239
- const bucket = this.#buckets.get(message.level);
1240
- if (bucket !== void 0) this.#push(bucket, message);
1241
- }
1242
- #push(buffer, message) {
1243
- buffer.push(message);
1244
- if (buffer.length > this.#limit) buffer.shift();
1245
- }
1246
- };
1247
- //#endregion
1248
- //#region src/core/LoggerManager.ts
1249
- /**
1250
- * An event-free registry of named {@link Logger}s plus a convenience fan-out — the §9
1251
- * manager over the logging layer (a registry, never observable itself; each {@link Logger}
1252
- * owns its own `emitter`).
1253
- *
1254
- * @remarks
1255
- * - **Registry (§9).** Loggers live in an insertion-ordered `Map` keyed by `name`.
1256
- * `register(name, options?)` mints a {@link Logger} named `name` — the manager's default
1257
- * `level` / `sink` / `styler` / `limit` / `silent` flow in unless `options` OVERRIDES them
1258
- * (`name` is always the registry key, so any `options.name` is ignored) — stores it (a
1259
- * re-`register` of the same name OVERWRITES, last write wins), and returns it. `count` is
1260
- * the map size, `logger(name)` looks one up, `loggers()` lists them in insertion order.
1261
- * - **Removal (§9.2).** `remove()` clears ALL, `remove(name)` drops ONE (`true` if present),
1262
- * `remove(names)` drops a batch (`true` if any was removed). `clear()` empties the registry.
1263
- * (Removal does NOT `destroy` the returned loggers — a caller still holding one keeps using
1264
- * it; the manager simply stops tracking it.)
1265
- * - **Fan-out.** `debug` / `info` / `warn` / `error(message, data?)` forward the one call to
1266
- * EVERY registered logger; each gates / emits / writes per its own `level` and `sink`. A
1267
- * fan-out over an empty registry is a no-op.
1268
- * - **Event-free.** No emitter, no events — the manager is a pure registry; observability is
1269
- * per-{@link Logger}.
1270
- *
1271
- * @example
1272
- * ```ts
1273
- * const manager = new LoggerManager({ level: 'warn' })
1274
- * manager.register('http') // inherits the `warn` default
1275
- * manager.register('db', { level: 'debug' }) // overrides to `debug`
1276
- * manager.warn('slow', { ms: 900 }) // fans out to both loggers
1277
- * manager.count // 2
1278
- * ```
1315
+ * - **Registry (§9).** Loggers live in an insertion-ordered `Map` keyed by `name`.
1316
+ * `register(name, options?)` mints a {@link Logger} named `name` the manager's default
1317
+ * `level` / `sink` / `styler` / `theme` / `format` / `limit` / `silent` flow in unless
1318
+ * `options` OVERRIDES them
1319
+ * (`name` is always the registry key, so any `options.name` is ignored) stores it (a
1320
+ * re-`register` of the same name OVERWRITES, last write wins), and returns it. `count` is
1321
+ * the map size, `logger(name)` looks one up, `loggers()` lists them in insertion order.
1322
+ * - **Removal (§9.2).** `remove()` clears ALL, `remove(name)` drops ONE (`true` if present),
1323
+ * `remove(names)` drops a batch (`true` if any was removed).
1324
+ * (Removal does NOT `destroy` the returned loggers a caller still holding one keeps using
1325
+ * it; the manager simply stops tracking it.)
1326
+ * - **Fan-out.** `debug` / `info` / `warn` / `error(message, data?)` forward the one call to
1327
+ * every registered logger in insertion order; each gates / emits / writes per its own `level`
1328
+ * and `sink`. A formatter throw is a programmer error and propagates, stopping the remaining
1329
+ * loggers for that call. A fan-out over an empty registry is a no-op.
1330
+ * - **Event-free.** No emitter, no events — the manager is a pure registry; observability is
1331
+ * per-{@link Logger}.
1332
+ *
1333
+ * @example
1334
+ * ```ts
1335
+ * const manager = new LoggerManager({ level: 'warn' })
1336
+ * manager.register('http') // inherits the `warn` default
1337
+ * manager.register('db', { level: 'debug' }) // overrides to `debug`
1338
+ * manager.warn('slow', { ms: 900 }) // fans out to both loggers
1339
+ * manager.count // 2
1340
+ * ```
1279
1341
  */
1280
1342
  var LoggerManager = class {
1281
1343
  #loggers = /* @__PURE__ */ new Map();
1282
1344
  #level;
1283
1345
  #sink;
1284
1346
  #styler;
1347
+ #theme;
1348
+ #format;
1285
1349
  #limit;
1286
1350
  #silent;
1287
1351
  constructor(options) {
1288
1352
  this.#level = options?.level;
1289
1353
  this.#sink = options?.sink;
1290
1354
  this.#styler = options?.styler;
1355
+ this.#theme = options?.theme;
1356
+ this.#format = options?.format;
1291
1357
  this.#limit = options?.limit;
1292
1358
  this.#silent = options?.silent;
1293
1359
  }
@@ -1299,6 +1365,8 @@ var LoggerManager = class {
1299
1365
  ...this.#level !== void 0 ? { level: this.#level } : {},
1300
1366
  ...this.#sink !== void 0 ? { sink: this.#sink } : {},
1301
1367
  ...this.#styler !== void 0 ? { styler: this.#styler } : {},
1368
+ ...this.#theme !== void 0 ? { theme: this.#theme } : {},
1369
+ ...this.#format !== void 0 ? { format: this.#format } : {},
1302
1370
  ...this.#limit !== void 0 ? { limit: this.#limit } : {},
1303
1371
  ...this.#silent !== void 0 ? { silent: this.#silent } : {},
1304
1372
  ...options,
@@ -1337,9 +1405,6 @@ var LoggerManager = class {
1337
1405
  }
1338
1406
  return this.#loggers.delete(names);
1339
1407
  }
1340
- clear() {
1341
- this.#loggers.clear();
1342
- }
1343
1408
  };
1344
1409
  //#endregion
1345
1410
  //#region src/core/Progress.ts
@@ -1375,8 +1440,11 @@ var Progress = class {
1375
1440
  #emitter;
1376
1441
  #total;
1377
1442
  #width;
1443
+ #fill;
1444
+ #empty;
1378
1445
  #sink;
1379
1446
  #styler;
1447
+ #theme;
1380
1448
  #message;
1381
1449
  #current = 0;
1382
1450
  #active = true;
@@ -1388,8 +1456,11 @@ var Progress = class {
1388
1456
  });
1389
1457
  this.#total = options.total;
1390
1458
  this.#width = options.width ?? 30;
1459
+ this.#fill = options.fill;
1460
+ this.#empty = options.empty;
1391
1461
  this.#sink = options.sink ?? createConsoleSink();
1392
1462
  this.#styler = options.styler ?? createStyler();
1463
+ this.#theme = options.theme ?? DEFAULT_THEME;
1393
1464
  this.#message = options.message ?? "";
1394
1465
  }
1395
1466
  get emitter() {
@@ -1442,7 +1513,10 @@ var Progress = class {
1442
1513
  current: this.#current,
1443
1514
  total: this.#total,
1444
1515
  width: this.#width,
1445
- styler: this.#styler.cyan
1516
+ ...this.#fill === void 0 ? {} : { fill: this.#fill },
1517
+ ...this.#empty === void 0 ? {} : { empty: this.#empty },
1518
+ styler: this.#styler,
1519
+ style: this.#theme.accent
1446
1520
  });
1447
1521
  const line = this.#message === "" ? bar : `${bar} ${this.#message}`;
1448
1522
  this.#sink.write(`\r${line}${final ? "\n" : ""}`, level);
@@ -1463,7 +1537,7 @@ var Progress = class {
1463
1537
  * capture (the capture chunk), no level retention (the logger). Just format + write.
1464
1538
  * - **`status` is a narrative OUTCOME, not a log level.** Its {@link StatusLevel} (`success` /
1465
1539
  * `error` / `warn` / `info`) is distinct from {@link import('./types.js').LogLevel}: an icon
1466
- * ({@link STATUS_ICONS}) + a color ({@link STATUS_COLORS}), with `error` routed to the sink's
1540
+ * supplied theme status icon + style, with `error` routed to the sink's
1467
1541
  * error stream (the `level` hint forwarded to {@link SinkInterface.write}) — there is no
1468
1542
  * gating and no severity ordering.
1469
1543
  * - **Width-aware.** `section` (and a `box` with no explicit `width`) lay out to the reporter's
@@ -1483,49 +1557,45 @@ var Progress = class {
1483
1557
  var Reporter = class {
1484
1558
  #sink;
1485
1559
  #styler;
1560
+ #theme;
1486
1561
  #width;
1487
1562
  constructor(options) {
1488
1563
  this.#sink = options?.sink ?? createConsoleSink();
1489
1564
  this.#styler = options?.styler ?? createStyler();
1565
+ this.#theme = options?.theme ?? DEFAULT_THEME;
1490
1566
  this.#width = options?.width ?? 80;
1491
1567
  }
1492
1568
  section(title) {
1493
1569
  this.#sink.write(renderSeparator({
1494
1570
  title,
1495
1571
  width: this.#width,
1496
- styler: this.#styler.dim
1572
+ styler: this.#styler,
1573
+ style: this.#theme.chrome
1497
1574
  }));
1498
1575
  }
1499
1576
  step(message, position) {
1500
- const prefix = position === void 0 ? "" : `${this.#styler.cyan(`[${position.index}/${position.total}]`)} `;
1577
+ const prefix = position === void 0 ? "" : `${this.#styler.render(this.#theme.accent, `[${position.index}/${position.total}]`)} `;
1501
1578
  this.#sink.write(`${prefix}${message}`);
1502
1579
  }
1503
1580
  timing(label, ms) {
1504
- this.#sink.write(`${label} ${this.#styler.dim(`… ${formatDuration(ms)}`)}`);
1581
+ this.#sink.write(`${label} ${this.#styler.render(this.#theme.chrome, `… ${formatDuration(ms)}`)}`);
1505
1582
  }
1506
1583
  status(level, message) {
1507
- const color = this.#styler[STATUS_COLORS[level]];
1508
- const line = `${color(STATUS_ICONS[level])} ${color(message)}`;
1584
+ const status = this.#theme.statuses[level];
1585
+ const line = `${this.#styler.render(status.style, status.icon)} ${this.#styler.render(status.style, message)}`;
1509
1586
  this.#sink.write(line, level === "error" ? "error" : void 0);
1510
1587
  }
1511
1588
  table(options) {
1512
- this.#sink.write(renderTable({
1513
- styler: this.#styler.dim,
1514
- ...options
1515
- }));
1589
+ this.#sink.write(renderTable(this.#resolveStyle(options)));
1516
1590
  }
1517
1591
  tree(options) {
1518
- this.#sink.write(renderTree({
1519
- styler: this.#styler.dim,
1520
- ...options
1521
- }));
1592
+ this.#sink.write(renderTree(this.#resolveStyle(options)));
1522
1593
  }
1523
1594
  box(options) {
1524
- this.#sink.write(renderBox({
1595
+ this.#sink.write(renderBox(this.#resolveStyle({
1525
1596
  width: this.#width,
1526
- styler: this.#styler.dim,
1527
1597
  ...options
1528
- }));
1598
+ })));
1529
1599
  }
1530
1600
  line(text) {
1531
1601
  this.#sink.write(text);
@@ -1533,6 +1603,13 @@ var Reporter = class {
1533
1603
  blank(count = 1) {
1534
1604
  for (let index = 0; index < count; index += 1) this.#sink.write("");
1535
1605
  }
1606
+ #resolveStyle(options) {
1607
+ return {
1608
+ styler: this.#styler,
1609
+ ...options.styler === void 0 && options.style === void 0 ? { style: this.#theme.chrome } : {},
1610
+ ...options
1611
+ };
1612
+ }
1536
1613
  };
1537
1614
  //#endregion
1538
1615
  //#region src/core/Spinner.ts
@@ -1556,7 +1633,7 @@ var Reporter = class {
1556
1633
  * - **Idempotent `start`.** A {@link start} while already `active` is a no-op (it never arms a second
1557
1634
  * timer).
1558
1635
  * - **Outcome lines.** {@link success} / {@link failure} clear the timer then write + emit a FINAL line —
1559
- * the {@link STATUS_ICONS} `✔` / `✖` (colored via {@link STATUS_COLORS}) + the message — terminated
1636
+ * the supplied theme status icon + style (`✔` / `✖` by default) + the message — terminated
1560
1637
  * by a newline (the activity is over; the line is committed, not overwritten). {@link failure} routes to
1561
1638
  * the sink's error stream.
1562
1639
  * - **Lifecycle (§10).** {@link stop} clears the timer and LEAVES the current line; {@link destroy}
@@ -1577,6 +1654,7 @@ var Spinner = class {
1577
1654
  #interval;
1578
1655
  #sink;
1579
1656
  #styler;
1657
+ #theme;
1580
1658
  #message;
1581
1659
  #handle;
1582
1660
  #index = 0;
@@ -1590,6 +1668,7 @@ var Spinner = class {
1590
1668
  this.#interval = options?.interval ?? 80;
1591
1669
  this.#sink = options?.sink ?? createConsoleSink();
1592
1670
  this.#styler = options?.styler ?? createStyler();
1671
+ this.#theme = options?.theme ?? DEFAULT_THEME;
1593
1672
  this.#message = options?.message ?? "";
1594
1673
  }
1595
1674
  get emitter() {
@@ -1635,13 +1714,13 @@ var Spinner = class {
1635
1714
  this.stop();
1636
1715
  const text = message ?? this.#message;
1637
1716
  if (message !== void 0) this.#message = message;
1638
- const color = this.#styler[STATUS_COLORS[level]];
1639
- const line = `${color(STATUS_ICONS[level])} ${color(text)}`;
1717
+ const status = this.#theme.statuses[level];
1718
+ const line = `${this.#styler.render(status.style, status.icon)} ${this.#styler.render(status.style, text)}`;
1640
1719
  this.#emitter.emit("frame", line);
1641
1720
  this.#sink.write(`\r${line}\n`, level === "error" ? "error" : void 0);
1642
1721
  }
1643
1722
  #line() {
1644
- const glyph = this.#styler.cyan(this.#frames[this.#index] ?? "");
1723
+ const glyph = this.#styler.render(this.#theme.accent, this.#frames[this.#index] ?? "");
1645
1724
  return this.#message === "" ? glyph : `${glyph} ${this.#message}`;
1646
1725
  }
1647
1726
  #paint(line) {
@@ -1650,6 +1729,148 @@ var Spinner = class {
1650
1729
  }
1651
1730
  };
1652
1731
  //#endregion
1732
+ //#region src/core/Styler.ts
1733
+ /**
1734
+ * The fluent, composable styler — the consumer-facing API over the style engine. It
1735
+ * builds a {@link Style} (style as DATA) and renders it through an injected
1736
+ * {@link RendererInterface} (the ANSI default, or a browser `%c` renderer at C-f). Each
1737
+ * color / attribute accessor is immutable copy-on-write: it returns a NEW styler's
1738
+ * surface with the token added, so `styler.red.bold('hi')` composes without mutating,
1739
+ * and a base styler is freely reusable.
1740
+ *
1741
+ * @remarks
1742
+ * - **Callable surface.** A `Styler` is not itself callable; its {@link surface} getter
1743
+ * returns the {@link StylerInterface} — a render FUNCTION carrying the chainable
1744
+ * accessors. The accessors are installed as LAZY getters (`Object.defineProperties`),
1745
+ * so a chain materializes only the stylers it actually walks — never the full tree —
1746
+ * and the recursion terminates. The factory returns that surface; this class is the
1747
+ * engine behind it.
1748
+ * - **Immutable.** `#foreground` and `#attribute` return a fresh `Styler` (the style is
1749
+ * rebuilt, never mutated). A later color of the same channel WINS (last write); a
1750
+ * repeated attribute is idempotent (de-duplicated, order preserved).
1751
+ * - **Styling by value.** `render(style, text)` merges a {@link Style} over the accumulated
1752
+ * one and renders that — the same precedence a chain applies, reached with DATA instead of
1753
+ * accessor names. It is how a {@link import('./types.js').Theme} role is drawn.
1754
+ * - **`enabled` switch.** When `false`, the render function returns text VERBATIM — no
1755
+ * renderer call, no escape codes (for a non-TTY / `NO_COLOR` / piped output).
1756
+ * - **Event-free** — a pure styling primitive (AGENTS §13), like `Scheduler`.
1757
+ */
1758
+ var Styler = class Styler {
1759
+ #renderer;
1760
+ #enabled;
1761
+ #style;
1762
+ constructor(renderer, enabled, style) {
1763
+ this.#renderer = renderer;
1764
+ this.#enabled = enabled;
1765
+ this.#style = style;
1766
+ }
1767
+ /** The accumulated style DATA — the empty style on a base styler. */
1768
+ get style() {
1769
+ return this.#style;
1770
+ }
1771
+ /** Whether styling is applied; when `false`, the surface returns text unchanged. */
1772
+ get enabled() {
1773
+ return this.#enabled;
1774
+ }
1775
+ /**
1776
+ * The fluent {@link StylerInterface} value — a render function (`text => string`) with
1777
+ * `style`, `enabled`, and every {@link Color} / {@link Attribute} as a LAZY accessor
1778
+ * (each computes the next styler's surface only when read). This is what consumers
1779
+ * hold and call.
1780
+ *
1781
+ * @remarks
1782
+ * The accessors are defined as getters (not eagerly-merged values), so accessing one
1783
+ * builds exactly one child styler — the tree is never fully materialized and the
1784
+ * construction terminates. The assembled function is then narrowed to
1785
+ * {@link StylerInterface} through {@link #isSurface} (a real structural check), so no
1786
+ * type assertion is used (AGENTS §1 / §14 — narrow, never assert).
1787
+ */
1788
+ get surface() {
1789
+ const callable = this.#render.bind(this);
1790
+ const descriptors = {
1791
+ style: {
1792
+ value: this.#style,
1793
+ enumerable: true
1794
+ },
1795
+ enabled: {
1796
+ value: this.#enabled,
1797
+ enumerable: true
1798
+ },
1799
+ render: {
1800
+ value: this.render.bind(this),
1801
+ enumerable: true
1802
+ }
1803
+ };
1804
+ for (const color of COLORS) descriptors[color] = {
1805
+ get: this.#foregroundSurface.bind(this, color),
1806
+ enumerable: true
1807
+ };
1808
+ for (const attribute of ATTRIBUTES) descriptors[attribute] = {
1809
+ get: this.#attributeSurface.bind(this, attribute),
1810
+ enumerable: true
1811
+ };
1812
+ const surface = Object.defineProperties(callable, descriptors);
1813
+ if (this.#isSurface(surface)) return surface;
1814
+ throw new ConsoleError("INVARIANT", "console: styler surface construction is incomplete");
1815
+ }
1816
+ /**
1817
+ * Render `text` in `style` merged OVER the accumulated style — the by-value door beside
1818
+ * the accessor chain, and how a {@link import('./types.js').Theme} role is applied.
1819
+ *
1820
+ * @param style - The style to overlay; its colors win over the accumulated ones and its
1821
+ * attributes join them (de-duplicated, the accumulated ones first)
1822
+ * @param text - The text to wrap
1823
+ * @returns The rendered text — verbatim when `enabled` is `false`, and (by the
1824
+ * {@link RendererInterface} contract) when the merged style or `text` is empty
1825
+ *
1826
+ * @example
1827
+ * ```ts
1828
+ * import { createStyler, DEFAULT_THEME } from '@src/core'
1829
+ *
1830
+ * const styler = createStyler()
1831
+ * styler.render(DEFAULT_THEME.levels.warn, 'WARN') // yellow
1832
+ * styler.bold.render(DEFAULT_THEME.chrome, '│') // dim, over the accumulated bold
1833
+ * ```
1834
+ */
1835
+ render(style, text) {
1836
+ return this.#enabled ? this.#renderer.render(this.#merge(style), text) : text;
1837
+ }
1838
+ #render(text) {
1839
+ return this.#enabled ? this.#renderer.render(this.#style, text) : text;
1840
+ }
1841
+ #merge(style) {
1842
+ const attributes = [...this.#style.attributes];
1843
+ for (const attribute of style.attributes) if (!attributes.includes(attribute)) attributes.push(attribute);
1844
+ return Object.freeze({
1845
+ ...this.#style,
1846
+ ...style,
1847
+ attributes: Object.freeze(attributes)
1848
+ });
1849
+ }
1850
+ #foregroundSurface(color) {
1851
+ return this.#foreground(color).surface;
1852
+ }
1853
+ #attributeSurface(attribute) {
1854
+ return this.#attribute(attribute).surface;
1855
+ }
1856
+ #isSurface(value) {
1857
+ return typeof value === "function" && "style" in value && "enabled" in value && "render" in value && "red" in value && "bold" in value;
1858
+ }
1859
+ #foreground(color) {
1860
+ return new Styler(this.#renderer, this.#enabled, Object.freeze({
1861
+ ...this.#style,
1862
+ foreground: color
1863
+ }));
1864
+ }
1865
+ #attribute(attribute) {
1866
+ if (this.#style.attributes.includes(attribute)) return this;
1867
+ return new Styler(this.#renderer, this.#enabled, Object.freeze({
1868
+ ...this.#style,
1869
+ attributes: Object.freeze([...this.#style.attributes, attribute])
1870
+ }));
1871
+ }
1872
+ };
1873
+ //#endregion
1653
1874
  //#region src/core/factories.ts
1654
1875
  /**
1655
1876
  * Create the cross-environment default {@link RendererInterface} — the ANSI / SGR
@@ -1703,6 +1924,52 @@ function createStyler(options) {
1703
1924
  return new Styler(options?.renderer ?? new ANSIRenderer(), options?.enabled ?? true, EMPTY_STYLE).surface;
1704
1925
  }
1705
1926
  /**
1927
+ * Create a {@link Theme} — the app-wide semantic style vocabulary, merged role by role over
1928
+ * {@link DEFAULT_THEME}. Hand one theme to a logger / reporter / spinner / progress and every
1929
+ * surface speaks it; omit `options` for the defaults.
1930
+ *
1931
+ * @param options - See {@link ThemeOptions}
1932
+ * @returns A frozen {@link Theme}
1933
+ *
1934
+ * @remarks
1935
+ * - **Merges per ROLE, not per theme.** An omitted role keeps its default, and `levels` /
1936
+ * `statuses` merge per ENTRY — `{ levels: { warn: … } }` restyles the `warn` label and
1937
+ * leaves `debug` / `info` / `error` untouched.
1938
+ * - **Frozen and shareable.** The factory snapshots and deep-freezes every style leaf. The
1939
+ * returned theme and its `levels` / `statuses` records are frozen. Each status record is also
1940
+ * copied and frozen. One theme is therefore safely shared across every entity.
1941
+ *
1942
+ * @example
1943
+ * ```ts
1944
+ * import { createStyler, createTheme } from '@src/core'
1945
+ *
1946
+ * const styler = createStyler()
1947
+ * const theme = createTheme({
1948
+ * levels: { warn: styler.brightYellow.bold.style }, // only the warn label changes
1949
+ * accent: styler.magenta.style, // spinner glyph, progress fill, step prefix
1950
+ * })
1951
+ * theme.levels.error // still the default red
1952
+ * ```
1953
+ */
1954
+ function createTheme(options) {
1955
+ const levels = { ...DEFAULT_THEME.levels };
1956
+ for (const level of LEVELS) levels[level] = freezeStyle(options?.levels?.[level] ?? DEFAULT_THEME.levels[level]);
1957
+ const statuses = { ...DEFAULT_THEME.statuses };
1958
+ for (const status of STATUS_LEVELS) {
1959
+ const source = options?.statuses?.[status] ?? DEFAULT_THEME.statuses[status];
1960
+ statuses[status] = Object.freeze({
1961
+ icon: source.icon,
1962
+ style: freezeStyle(source.style)
1963
+ });
1964
+ }
1965
+ return Object.freeze({
1966
+ levels: Object.freeze(levels),
1967
+ statuses: Object.freeze(statuses),
1968
+ accent: freezeStyle(options?.accent ?? DEFAULT_THEME.accent),
1969
+ chrome: freezeStyle(options?.chrome ?? DEFAULT_THEME.chrome)
1970
+ });
1971
+ }
1972
+ /**
1706
1973
  * Create the default {@link SinkInterface} — a console sink that routes by level and writes
1707
1974
  * through the `console` methods SNAPSHOTTED at creation. The default output target behind
1708
1975
  * {@link createLogger}.
@@ -1875,76 +2142,6 @@ function createCapture(options) {
1875
2142
  return new Capture(options);
1876
2143
  }
1877
2144
  /**
1878
- * Run `fn` with the global `console.*` captured for its duration, returning the function's `value`
1879
- * plus the {@link import('./types.js').CapturedMessage}s it logged — the scoped, self-restoring
1880
- * ergonomic form of {@link createCapture}.
1881
- *
1882
- * @param fn - The function to run under capture; may be sync (returns `T`) or async (returns
1883
- * `Promise<T>`)
1884
- * @param options - See {@link CaptureOptions} (`levels` / `mirror` / `sink` / `limit` / `on` /
1885
- * `error`); the capture is started for the duration of `fn` regardless
1886
- * @returns For a sync `fn`, a {@link CaptureResult}`<T>` (`{ value, messages }`); for an async
1887
- * `fn`, a `Promise<CaptureResult<T>>` (awaited, then console restored)
1888
- *
1889
- * @remarks
1890
- * - **Always restores.** `start()` runs before `fn`; `stop()` runs in a `finally`, so `console` is
1891
- * restored even if `fn` throws / rejects (the throw / rejection still propagates). The capture
1892
- * is local — created, used, and destroyed within the call.
1893
- * - **Sync vs async.** A `fn` returning a `Promise` is detected and AWAITED before `stop()`, so
1894
- * captures during the async work are included; a plain `fn` stops synchronously. The return type
1895
- * follows `fn`'s (overloaded).
1896
- * - **PROCESS-GLOBAL caveat.** Like {@link createCapture}, this patches the one global `console`.
1897
- * Concurrent `withCapture` calls (or a `withCapture` around other capturing code) INTERLEAVE —
1898
- * each captures every `console.*` call in flight, and the inner `stop()` restores whatever the
1899
- * outer had installed. Use it for sequential, scoped capture, not overlapping captures.
1900
- *
1901
- * @example
1902
- * ```ts
1903
- * import { withCapture } from '@src/core'
1904
- *
1905
- * const { value, messages } = withCapture(() => {
1906
- * console.log('working')
1907
- * return 42
1908
- * })
1909
- * value // 42
1910
- * messages.map((m) => m.text) // ['working']
1911
- *
1912
- * // Async — awaited before console is restored.
1913
- * const out = await withCapture(async () => {
1914
- * console.warn('async noise')
1915
- * return 'done'
1916
- * })
1917
- * out.value // 'done'
1918
- * ```
1919
- */
1920
- function withCapture(fn, options) {
1921
- const capture = new Capture(options);
1922
- capture.start();
1923
- try {
1924
- const result = fn();
1925
- if (result instanceof Promise) return result.then((value) => {
1926
- const messages = capture.messages();
1927
- capture.destroy();
1928
- return {
1929
- value,
1930
- messages
1931
- };
1932
- }, (error) => {
1933
- capture.destroy();
1934
- throw error;
1935
- });
1936
- const messages = capture.messages();
1937
- capture.destroy();
1938
- return {
1939
- value: result,
1940
- messages
1941
- };
1942
- } catch (error) {
1943
- capture.destroy();
1944
- throw error;
1945
- }
1946
- }
1947
- /**
1948
2145
  * Create a self-driving, observable {@link SpinnerInterface} — a live activity spinner. `start()`
1949
2146
  * arms a periodic timer that advances a glyph cycle, writing each `\r` + frame line to its sink and
1950
2147
  * emitting it on `frame`; `success` / `failure` commit a final `✔` / `✖` line. The leading `\r` is the
@@ -2045,6 +2242,8 @@ var Logger = class {
2045
2242
  #level;
2046
2243
  #sink;
2047
2244
  #styler;
2245
+ #theme;
2246
+ #format;
2048
2247
  #limit;
2049
2248
  #silent;
2050
2249
  #entries = [];
@@ -2058,6 +2257,8 @@ var Logger = class {
2058
2257
  if (options?.name !== void 0) this.name = options.name;
2059
2258
  this.#sink = options?.sink ?? createConsoleSink();
2060
2259
  this.#styler = options?.styler ?? createStyler();
2260
+ this.#theme = options?.theme ?? DEFAULT_THEME;
2261
+ this.#format = options?.format ?? formatRecord;
2061
2262
  this.#limit = options?.limit ?? 1e3;
2062
2263
  this.#silent = options?.silent ?? false;
2063
2264
  }
@@ -2095,7 +2296,7 @@ var Logger = class {
2095
2296
  this.#retain(record);
2096
2297
  this.#emitter.emit("entry", record);
2097
2298
  if (this.#silent) return;
2098
- this.#sink.write(formatRecord(record, this.#styler), level);
2299
+ this.#sink.write(this.#format(record, this.#styler, this.#theme), level);
2099
2300
  }
2100
2301
  #record(level, message, data) {
2101
2302
  return Object.freeze({
@@ -2112,6 +2313,6 @@ var Logger = class {
2112
2313
  }
2113
2314
  };
2114
2315
  //#endregion
2115
- export { ANSIRenderer, ANSI_PATTERN, ATTRIBUTES, ATTRIBUTE_CODES, BACKGROUND_CODES, BAR_EMPTY, BAR_FILL, BEL, BORDER_CHARS, CAPTURE_LEVELS, CAPTURE_LEVEL_MAP, COLORS, CONTROL_PATTERN, CSI, Capture, ConsoleError, DEFAULT_ALIGN, DEFAULT_BAR_WIDTH, DEFAULT_BORDER, DEFAULT_CAPTURE_LEVELS, DEFAULT_CAPTURE_LIMIT, DEFAULT_LOG_LEVEL, DEFAULT_LOG_LIMIT, DEFAULT_PADDING, DEFAULT_SPINNER_INTERVAL, DEFAULT_WIDTH, EMPTY_STYLE, ESC, FOREGROUND_CODES, LEVELS, LEVEL_COLORS, LEVEL_SEVERITY, Logger, LoggerManager, Progress, RESET, RESET_CODE, Reporter, SECOND_MS, SEPARATOR_FILL, SEPARATOR_TITLE_GAP, SPINNER_FRAMES, STATUS_COLORS, STATUS_ICONS, STATUS_LEVELS, Spinner, Styler, TREE_CHARS, align, cellAt, createANSIRenderer, createCapture, createConsoleSink, createLogger, createLoggerManager, createProgress, createReporter, createSpinner, createStyler, formatArgs, formatDuration, formatRecord, formatTime, isConsoleError, meetsLevel, paint, renderBar, renderBox, renderSeparator, renderTable, renderTree, renderTreeChildren, repeatTo, stringifyValue, strip, stripControls, width, withCapture };
2316
+ export { ANSIRenderer, ANSI_PATTERN, ATTRIBUTES, ATTRIBUTE_CODES, BACKGROUND_CODES, BAR_EMPTY, BAR_FILL, BEL, BORDER_CHARS, CAPTURE_LEVELS, CAPTURE_LEVEL_MAP, COLORS, CONTROL_PATTERN, CSI, Capture, ConsoleError, DEFAULT_ALIGN, DEFAULT_BAR_WIDTH, DEFAULT_BORDER, DEFAULT_CAPTURE_LEVELS, DEFAULT_CAPTURE_LIMIT, DEFAULT_LOG_LEVEL, DEFAULT_LOG_LIMIT, DEFAULT_PADDING, DEFAULT_SPINNER_INTERVAL, DEFAULT_THEME, DEFAULT_WIDTH, EMPTY_STYLE, ESC, FOREGROUND_CODES, LEVELS, LEVEL_COLORS, LEVEL_SEVERITY, Logger, LoggerManager, Progress, RESET, RESET_CODE, Reporter, SECOND_MS, SEPARATOR_FILL, SEPARATOR_TITLE_GAP, SPINNER_FRAMES, STATUS_COLORS, STATUS_ICONS, STATUS_LEVELS, Spinner, align, cellAt, createANSIRenderer, createCapture, createConsoleSink, createLogger, createLoggerManager, createProgress, createReporter, createSpinner, createStyler, createTheme, formatArgs, formatDuration, formatRecord, formatTime, freezeStyle, isConsoleError, meetsLevel, paint, renderBar, renderBox, renderSeparator, renderTable, renderTree, renderTreeChildren, repeatTo, stringifyValue, strip, stripControls, width, withCapture };
2116
2317
 
2117
2318
  //# sourceMappingURL=index.js.map