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