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