@orkestrel/console 0.0.4 → 0.0.6

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.
@@ -452,6 +452,127 @@ function isConsoleError(value) {
452
452
  return value instanceof ConsoleError;
453
453
  }
454
454
  //#endregion
455
+ //#region src/core/Capture.ts
456
+ /**
457
+ * An observable console interceptor (AGENTS §13) — it takes control of the global `console.*` on
458
+ * the READ side. While `active`, every configured `console.x` call is captured as a frozen
459
+ * {@link CapturedMessage}, buffered (total + by level, bounded), emitted on `capture`, and — per
460
+ * options — mirrored to the real console and/or forwarded to a {@link SinkInterface}.
461
+ *
462
+ * @remarks
463
+ * - **Snapshot-at-start (the no-capture-loop principle).** `start()` snapshots the CURRENT
464
+ * `console[level]` for each configured {@link CaptureLevel}, then installs the wrappers. The
465
+ * mirror writes through that snapshot — so our OWN console sink output (the Logger / Reporter,
466
+ * which snapshot the real `console` at creation) is never recaptured: `Capture` catches
467
+ * THIRD-PARTY `console.*`, not our writes. Create your loggers BEFORE installing a capture.
468
+ * - **Idempotent + PROCESS-GLOBAL + NON-REENTRANT.** `start()` while already `active` is a no-op
469
+ * (never double-patches); `stop()` while inactive is a no-op. It patches the ONE global
470
+ * `console`, so at most ONE capture may be active at a time — running two concurrently
471
+ * interleaves their buffers and clobbers each other's restore.
472
+ * - **Bounded buffers.** `messages()` / `messages(level)` — the total buffer and each by-level
473
+ * bucket are each capped at `limit`
474
+ * (oldest dropped first), never unbounded — the same retention precedent as {@link Logger}.
475
+ * - **Lifecycle (§10).** `start` / `stop` toggle interception (emitting `start` / `stop`);
476
+ * `destroy()` stops (restoring `console`) then destroys the emitter.
477
+ *
478
+ * @example
479
+ * ```ts
480
+ * const capture = new Capture({ levels: ['warn', 'error'], mirror: true })
481
+ * capture.start()
482
+ * console.warn('third-party noise') // captured AND mirrored to the real console
483
+ * capture.messages('warn') // [{ level: 'warn', text: 'third-party noise', time: … }]
484
+ * capture.stop() // console.warn restored
485
+ * ```
486
+ */
487
+ var Capture = class {
488
+ #emitter;
489
+ #levels;
490
+ #mirror;
491
+ #sink;
492
+ #limit;
493
+ #messages = [];
494
+ #buckets = /* @__PURE__ */ new Map();
495
+ #originals = /* @__PURE__ */ new Map();
496
+ #active = false;
497
+ constructor(options) {
498
+ this.#emitter = new Emitter({
499
+ ...options?.on !== void 0 ? { on: options.on } : {},
500
+ ...options?.error !== void 0 ? { error: options.error } : {}
501
+ });
502
+ this.#levels = options?.levels ?? DEFAULT_CAPTURE_LEVELS;
503
+ this.#mirror = options?.mirror ?? false;
504
+ this.#sink = options?.sink;
505
+ this.#limit = options?.limit ?? 1e3;
506
+ for (const level of this.#levels) this.#buckets.set(level, []);
507
+ }
508
+ get emitter() {
509
+ return this.#emitter;
510
+ }
511
+ get active() {
512
+ return this.#active;
513
+ }
514
+ start() {
515
+ if (this.#active) return;
516
+ this.#active = true;
517
+ const target = console;
518
+ for (const level of this.#levels) {
519
+ const original = target[level];
520
+ this.#originals.set(level, original);
521
+ const mirror = original.bind(console);
522
+ target[level] = this.#captureCall.bind(this, level, mirror);
523
+ }
524
+ this.#emitter.emit("start");
525
+ }
526
+ stop() {
527
+ if (!this.#active) return;
528
+ this.#active = false;
529
+ const target = console;
530
+ for (const [level, original] of this.#originals) target[level] = original;
531
+ this.#originals.clear();
532
+ this.#emitter.emit("stop");
533
+ }
534
+ messages(level) {
535
+ if (level === void 0) return [...this.#messages];
536
+ return [...this.#buckets.get(level) ?? []];
537
+ }
538
+ clear() {
539
+ this.#messages.length = 0;
540
+ for (const bucket of this.#buckets.values()) bucket.length = 0;
541
+ }
542
+ destroy() {
543
+ this.stop();
544
+ this.#emitter.destroy();
545
+ }
546
+ #captureCall(level, mirror, ...args) {
547
+ this.#intercept(level, args, mirror);
548
+ }
549
+ #intercept(level, args, mirror) {
550
+ const message = this.#capture(level, args);
551
+ this.#retain(message);
552
+ this.#emitter.emit("capture", message);
553
+ if (this.#mirror) mirror(...args);
554
+ if (this.#sink !== void 0) try {
555
+ this.#sink.write(message.text, CAPTURE_LEVEL_MAP[level]);
556
+ } catch {}
557
+ }
558
+ #capture(level, args) {
559
+ return Object.freeze({
560
+ level,
561
+ text: formatArgs(args),
562
+ time: Date.now()
563
+ });
564
+ }
565
+ #retain(message) {
566
+ this.#push(this.#messages, message);
567
+ const bucket = this.#buckets.get(message.level);
568
+ if (bucket !== void 0) this.#push(bucket, message);
569
+ }
570
+ #push(buffer, message) {
571
+ buffer.push(message);
572
+ if (buffer.length > this.#limit) buffer.shift();
573
+ }
574
+ };
575
+ //#endregion
455
576
  //#region src/core/helpers.ts
456
577
  /**
457
578
  * Remove every ANSI escape sequence from `text`, returning the plain visible string.
@@ -977,271 +1098,116 @@ function renderBar(options) {
977
1098
  const filledCells = Math.round(fraction * track);
978
1099
  return `${`${paint(options.styler, repeatTo(fill, filledCells))}${repeatTo(empty, track - filledCells)}`} ${Math.round(fraction * 100)}% (${current}/${options.total})`;
979
1100
  }
980
- //#endregion
981
- //#region src/core/ANSIRenderer.ts
982
- /**
983
- * The cross-environment default {@link RendererInterface} — renders style DATA as ANSI
984
- * SGR escape codes, exactly as `Scheduler` is the `setTimeout` default for its seam. It
985
- * is the single styling output the whole console / terminal system uses in a terminal;
986
- * the browser `%c` / CSS renderer (the C-f branch) implements the SAME contract over
987
- * the SAME {@link Style}, so retargeting changes the renderer, never the style model.
988
- *
989
- * @remarks
990
- * - **Style is DATA in, SGR string out.** It reads the style's `foreground` /
991
- * `background` / `attributes` and emits one `ESC[…m` sequence whose parameters are the
992
- * mapped SGR numbers (foreground 30–37 / 90–97, background 40–47 / 100–107, attributes
993
- * 1 / 2 / 3 / 4 / 7 / 9), followed by `text`, terminated by the reset `ESC[0m`.
994
- * - **Multiple attributes compose** — their codes join with `;` in a single sequence
995
- * (`ESC[1;4;31m` for bold + underline + red), so one open and one reset wrap the run.
996
- * - **`default` and unset colors emit no code** — a `default` (or absent) `foreground` /
997
- * `background` leaves the terminal's own ink.
998
- * - **The empty style and the empty string pass through** — when there is nothing to
999
- * apply (no colors, no attributes) or `text` is `''`, `text` is returned VERBATIM with
1000
- * no escape codes, so an unstyled render never injects a stray reset.
1001
- * - **Stateless and event-free** — no fields, no events; safe to share one instance.
1002
- */
1003
- var ANSIRenderer = class {
1004
- /**
1005
- * Wrap `text` in the SGR codes for `style`. Returns `text` unchanged when the style
1006
- * is empty or `text` is `''`.
1007
- */
1008
- render(style, text) {
1009
- if (text === "") return text;
1010
- const codes = this.#codes(style);
1011
- if (codes.length === 0) return text;
1012
- return `${CSI}${codes.join(";")}m${text}${RESET}`;
1013
- }
1014
- #codes(style) {
1015
- const codes = [];
1016
- for (const attribute of style.attributes) codes.push(ATTRIBUTE_CODES[attribute]);
1017
- if (style.foreground !== void 0 && style.foreground !== "default") codes.push(FOREGROUND_CODES[style.foreground]);
1018
- if (style.background !== void 0 && style.background !== "default") codes.push(BACKGROUND_CODES[style.background]);
1019
- return codes;
1020
- }
1021
- };
1022
- //#endregion
1023
- //#region src/core/Styler.ts
1024
1101
  /**
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.
1102
+ * Run `fn` with the global `console.*` captured for its duration, returning the function's `value`
1103
+ * plus the {@link import('./types.js').CapturedMessage}s it logged the scoped, self-restoring
1104
+ * ergonomic form of {@link createCapture}.
1031
1105
  *
1032
- * @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}.
1106
+ * @param fn - The function to run under capture; may be sync (returns `T`) or async (returns
1107
+ * `Promise<T>`)
1108
+ * @param options - See {@link CaptureOptions} (`levels` / `mirror` / `sink` / `limit` / `on` /
1109
+ * `error`); the capture is started for the duration of `fn` regardless
1110
+ * @returns For a sync `fn`, a {@link CaptureResult}`<T>` (`{ value, messages }`); for an async
1111
+ * `fn`, a `Promise<CaptureResult<T>>` (awaited, then console restored)
1133
1112
  *
1134
1113
  * @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.
1114
+ * - **Always restores.** `start()` runs before `fn`; `stop()` runs in a `finally`, so `console` is
1115
+ * restored even if `fn` throws / rejects (the throw / rejection still propagates). The capture
1116
+ * is localcreated, used, and destroyed within the call.
1117
+ * - **Sync vs async.** A `fn` returning a `Promise` is detected and AWAITED before `stop()`, so
1118
+ * captures during the async work are included; a plain `fn` stops synchronously. The return type
1119
+ * follows `fn`'s (overloaded).
1120
+ * - **PROCESS-GLOBAL caveat.** Like {@link createCapture}, this patches the one global `console`.
1121
+ * Concurrent `withCapture` calls (or a `withCapture` around other capturing code) INTERLEAVE
1122
+ * each captures every `console.*` call in flight, and the inner `stop()` restores whatever the
1123
+ * outer had installed. Use it for sequential, scoped capture, not overlapping captures.
1149
1124
  *
1150
1125
  * @example
1151
1126
  * ```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()
1127
+ * import { withCapture } from '@src/core'
1128
+ *
1129
+ * const { value, messages } = withCapture(() => {
1130
+ * console.log('working')
1131
+ * return 42
1132
+ * })
1133
+ * value // 42
1134
+ * messages.map((m) => m.text) // ['working']
1135
+ *
1136
+ * // Async — awaited before console is restored.
1137
+ * const out = await withCapture(async () => {
1138
+ * console.warn('async noise')
1139
+ * return 'done'
1140
+ * })
1141
+ * out.value // 'done'
1142
+ * ```
1143
+ */
1144
+ function withCapture(fn, options) {
1145
+ const capture = new Capture(options);
1146
+ capture.start();
1147
+ try {
1148
+ const result = fn();
1149
+ if (result instanceof Promise) return result.then((value) => {
1150
+ const messages = capture.messages();
1151
+ capture.destroy();
1152
+ return {
1153
+ value,
1154
+ messages
1155
+ };
1156
+ }, (error) => {
1157
+ capture.destroy();
1158
+ throw error;
1235
1159
  });
1160
+ const messages = capture.messages();
1161
+ capture.destroy();
1162
+ return {
1163
+ value: result,
1164
+ messages
1165
+ };
1166
+ } catch (error) {
1167
+ capture.destroy();
1168
+ throw error;
1236
1169
  }
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);
1170
+ }
1171
+ //#endregion
1172
+ //#region src/core/ANSIRenderer.ts
1173
+ /**
1174
+ * The cross-environment default {@link RendererInterface} — renders style DATA as ANSI
1175
+ * SGR escape codes, exactly as `Scheduler` is the `setTimeout` default for its seam. It
1176
+ * is the single styling output the whole console / terminal system uses in a terminal;
1177
+ * the browser `%c` / CSS renderer (the C-f branch) implements the SAME contract over
1178
+ * the SAME {@link Style}, so retargeting changes the renderer, never the style model.
1179
+ *
1180
+ * @remarks
1181
+ * - **Style is DATA in, SGR string out.** It reads the style's `foreground` /
1182
+ * `background` / `attributes` and emits one `ESC[…m` sequence whose parameters are the
1183
+ * mapped SGR numbers (foreground 30–37 / 90–97, background 40–47 / 100–107, attributes
1184
+ * 1 / 2 / 3 / 4 / 7 / 9), followed by `text`, terminated by the reset `ESC[0m`.
1185
+ * - **Multiple attributes compose** — their codes join with `;` in a single sequence
1186
+ * (`ESC[1;4;31m` for bold + underline + red), so one open and one reset wrap the run.
1187
+ * - **`default` and unset colors emit no code** — a `default` (or absent) `foreground` /
1188
+ * `background` leaves the terminal's own ink.
1189
+ * - **The empty style and the empty string pass through** — when there is nothing to
1190
+ * apply (no colors, no attributes) or `text` is `''`, `text` is returned VERBATIM with
1191
+ * no escape codes, so an unstyled render never injects a stray reset.
1192
+ * - **Stateless and event-free** — no fields, no events; safe to share one instance.
1193
+ */
1194
+ var ANSIRenderer = class {
1195
+ /**
1196
+ * Wrap `text` in the SGR codes for `style`. Returns `text` unchanged when the style
1197
+ * is empty or `text` is `''`.
1198
+ */
1199
+ render(style, text) {
1200
+ if (text === "") return text;
1201
+ const codes = this.#codes(style);
1202
+ if (codes.length === 0) return text;
1203
+ return `${CSI}${codes.join(";")}m${text}${RESET}`;
1241
1204
  }
1242
- #push(buffer, message) {
1243
- buffer.push(message);
1244
- if (buffer.length > this.#limit) buffer.shift();
1205
+ #codes(style) {
1206
+ const codes = [];
1207
+ for (const attribute of style.attributes) codes.push(ATTRIBUTE_CODES[attribute]);
1208
+ if (style.foreground !== void 0 && style.foreground !== "default") codes.push(FOREGROUND_CODES[style.foreground]);
1209
+ if (style.background !== void 0 && style.background !== "default") codes.push(BACKGROUND_CODES[style.background]);
1210
+ return codes;
1245
1211
  }
1246
1212
  };
1247
1213
  //#endregion
@@ -1650,6 +1616,110 @@ var Spinner = class {
1650
1616
  }
1651
1617
  };
1652
1618
  //#endregion
1619
+ //#region src/core/Styler.ts
1620
+ /**
1621
+ * The fluent, composable styler — the consumer-facing API over the style engine. It
1622
+ * builds a {@link Style} (style as DATA) and renders it through an injected
1623
+ * {@link RendererInterface} (the ANSI default, or a browser `%c` renderer at C-f). Each
1624
+ * color / attribute accessor is immutable copy-on-write: it returns a NEW styler's
1625
+ * surface with the token added, so `styler.red.bold('hi')` composes without mutating,
1626
+ * and a base styler is freely reusable.
1627
+ *
1628
+ * @remarks
1629
+ * - **Callable surface.** A `Styler` is not itself callable; its {@link surface} getter
1630
+ * returns the {@link StylerInterface} — a render FUNCTION carrying the chainable
1631
+ * accessors. The accessors are installed as LAZY getters (`Object.defineProperties`),
1632
+ * so a chain materializes only the stylers it actually walks — never the full tree —
1633
+ * and the recursion terminates. The factory returns that surface; this class is the
1634
+ * engine behind it.
1635
+ * - **Immutable.** `#foreground` and `#attribute` return a fresh `Styler` (the style is
1636
+ * rebuilt, never mutated). A later color of the same channel WINS (last write); a
1637
+ * repeated attribute is idempotent (de-duplicated, order preserved).
1638
+ * - **`enabled` switch.** When `false`, the render function returns text VERBATIM — no
1639
+ * renderer call, no escape codes (for a non-TTY / `NO_COLOR` / piped output).
1640
+ * - **Event-free** — a pure styling primitive (AGENTS §13), like `Scheduler`.
1641
+ */
1642
+ var Styler = class Styler {
1643
+ #renderer;
1644
+ #enabled;
1645
+ #style;
1646
+ constructor(renderer, enabled, style) {
1647
+ this.#renderer = renderer;
1648
+ this.#enabled = enabled;
1649
+ this.#style = style;
1650
+ }
1651
+ /** The accumulated style DATA — the empty style on a base styler. */
1652
+ get style() {
1653
+ return this.#style;
1654
+ }
1655
+ /** Whether styling is applied; when `false`, the surface returns text unchanged. */
1656
+ get enabled() {
1657
+ return this.#enabled;
1658
+ }
1659
+ /**
1660
+ * The fluent {@link StylerInterface} value — a render function (`text => string`) with
1661
+ * `style`, `enabled`, and every {@link Color} / {@link Attribute} as a LAZY accessor
1662
+ * (each computes the next styler's surface only when read). This is what consumers
1663
+ * hold and call.
1664
+ *
1665
+ * @remarks
1666
+ * The accessors are defined as getters (not eagerly-merged values), so accessing one
1667
+ * builds exactly one child styler — the tree is never fully materialized and the
1668
+ * construction terminates. The assembled function is then narrowed to
1669
+ * {@link StylerInterface} through {@link #isSurface} (a real structural check), so no
1670
+ * type assertion is used (AGENTS §1 / §14 — narrow, never assert).
1671
+ */
1672
+ get surface() {
1673
+ const render = this.#render.bind(this);
1674
+ const descriptors = {
1675
+ style: {
1676
+ value: this.#style,
1677
+ enumerable: true
1678
+ },
1679
+ enabled: {
1680
+ value: this.#enabled,
1681
+ enumerable: true
1682
+ }
1683
+ };
1684
+ for (const color of COLORS) descriptors[color] = {
1685
+ get: this.#foregroundSurface.bind(this, color),
1686
+ enumerable: true
1687
+ };
1688
+ for (const attribute of ATTRIBUTES) descriptors[attribute] = {
1689
+ get: this.#attributeSurface.bind(this, attribute),
1690
+ enumerable: true
1691
+ };
1692
+ const surface = Object.defineProperties(render, descriptors);
1693
+ if (this.#isSurface(surface)) return surface;
1694
+ throw new ConsoleError("INVARIANT", "console: styler surface construction is incomplete");
1695
+ }
1696
+ #render(text) {
1697
+ return this.#enabled ? this.#renderer.render(this.#style, text) : text;
1698
+ }
1699
+ #foregroundSurface(color) {
1700
+ return this.#foreground(color).surface;
1701
+ }
1702
+ #attributeSurface(attribute) {
1703
+ return this.#attribute(attribute).surface;
1704
+ }
1705
+ #isSurface(value) {
1706
+ return typeof value === "function" && "style" in value && "enabled" in value && "red" in value && "bold" in value;
1707
+ }
1708
+ #foreground(color) {
1709
+ return new Styler(this.#renderer, this.#enabled, Object.freeze({
1710
+ ...this.#style,
1711
+ foreground: color
1712
+ }));
1713
+ }
1714
+ #attribute(attribute) {
1715
+ if (this.#style.attributes.includes(attribute)) return this;
1716
+ return new Styler(this.#renderer, this.#enabled, Object.freeze({
1717
+ ...this.#style,
1718
+ attributes: Object.freeze([...this.#style.attributes, attribute])
1719
+ }));
1720
+ }
1721
+ };
1722
+ //#endregion
1653
1723
  //#region src/core/factories.ts
1654
1724
  /**
1655
1725
  * Create the cross-environment default {@link RendererInterface} — the ANSI / SGR
@@ -1875,76 +1945,6 @@ function createCapture(options) {
1875
1945
  return new Capture(options);
1876
1946
  }
1877
1947
  /**
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
1948
  * Create a self-driving, observable {@link SpinnerInterface} — a live activity spinner. `start()`
1949
1949
  * arms a periodic timer that advances a glyph cycle, writing each `\r` + frame line to its sink and
1950
1950
  * emitting it on `frame`; `success` / `failure` commit a final `✔` / `✖` line. The leading `\r` is the
@@ -2112,6 +2112,6 @@ var Logger = class {
2112
2112
  }
2113
2113
  };
2114
2114
  //#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 };
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, 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 };
2116
2116
 
2117
2117
  //# sourceMappingURL=index.js.map