@orkestrel/console 0.0.6 → 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
  /**
@@ -640,6 +697,27 @@ function width(text) {
640
697
  return [...strip(text)].length;
641
698
  }
642
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
+ /**
643
721
  * Whether a record at `level` passes a logger gated at `threshold` — i.e. its severity is
644
722
  * at or above the threshold's.
645
723
  *
@@ -681,7 +759,7 @@ function formatTime(time) {
681
759
  *
682
760
  * @remarks
683
761
  * Layout: `{time} {LEVEL} {[name]} {message}{ data}` — the ISO timestamp (dimmed), the
684
- * 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),
685
763
  * the originating logger's `name` in brackets (omitted when absent), the message, and the
686
764
  * structured `data` appended as compact JSON (omitted when absent / empty). Coloring flows
687
765
  * through the injected `styler`, so a disabled styler yields a plain line and a browser
@@ -690,19 +768,24 @@ function formatTime(time) {
690
768
  *
691
769
  * @param record - The {@link LogRecord} to render
692
770
  * @param styler - The {@link StylerInterface} the labels are colored through
771
+ * @param theme - The {@link Theme} supplying the level and chrome roles
693
772
  * @returns The formatted, styled line (no trailing newline — the sink's target adds it)
694
773
  *
695
774
  * @example
696
775
  * ```ts
697
- * 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
+ * )
698
781
  * // '<dim>1970-01-01T00:00:00.000Z</> <yellow>WARN</> [fs] low disk'
699
782
  * ```
700
783
  */
701
- function formatRecord(record, styler) {
702
- const time = styler.dim(formatTime(record.time));
703
- const label = styler[LEVEL_COLORS[record.level]](record.level.toUpperCase());
704
- const name = record.name === void 0 ? "" : ` ${styler.dim(`[${record.name}]`)}`;
705
- 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))}`;
706
789
  return `${time} ${label}${name} ${record.message}${data}`;
707
790
  }
708
791
  /**
@@ -769,10 +852,12 @@ function formatDuration(ms) {
769
852
  *
770
853
  * @param styler - The {@link StylerInterface} to color with, or `undefined` for no styling
771
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
772
856
  * @returns `styler(text)` when a styler is given, else `text` unchanged
773
857
  */
774
- function paint(styler, text) {
775
- 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);
776
861
  }
777
862
  /**
778
863
  * Repeat `unit` until it fills exactly `count` VISIBLE columns, trimming a trailing partial
@@ -838,12 +923,12 @@ function cellAt(row, index) {
838
923
  function renderSeparator(options) {
839
924
  const total = options.width ?? 80;
840
925
  const fill = options.fill ?? "─";
841
- if (options.title === void 0) return paint(options.styler, repeatTo(fill, total));
842
- 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)} `;
843
928
  const room = total - width(options.title) - 2;
844
929
  if (room <= 0) return gapped;
845
930
  const left = Math.floor(room / 2);
846
- 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)}`;
847
932
  }
848
933
  /**
849
934
  * Render `content` framed in box-drawing characters, optionally captioned, width-aware so
@@ -875,18 +960,18 @@ function renderBox(options) {
875
960
  const budget = options.width === void 0 ? 0 : options.width - 2 - padding * 2;
876
961
  const inner = lines.reduce((max, line) => Math.max(max, width(line)), Math.max(0, titleRoom, budget));
877
962
  const gutter = " ".repeat(padding);
878
- const bar = paint(styler, chars.vertical);
963
+ const bar = paint(styler, chars.vertical, options.style);
879
964
  const span = inner + padding * 2;
880
965
  let top;
881
- 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);
882
967
  else {
883
968
  const caption = ` ${options.title} `;
884
969
  const room = span - width(caption);
885
- const lead = paint(styler, repeatTo(chars.horizontal, 1));
886
- const rest = room - 1 <= 0 ? "" : paint(styler, repeatTo(chars.horizontal, room - 1));
887
- 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)}`;
888
973
  }
889
- 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);
890
975
  const body = lines.map((line) => `${bar}${gutter}${align(line, inner)}${gutter}${bar}`);
891
976
  return [
892
977
  top,
@@ -921,14 +1006,14 @@ function renderTable(options) {
921
1006
  const columns = options.columns;
922
1007
  const widths = columns.map((column, index) => options.rows.reduce((max, row) => Math.max(max, width(cellAt(row, index))), width(column.label)));
923
1008
  const aligns = columns.map((column) => column.align ?? "left");
924
- const renderedRows = [columns.map((column) => paint(styler, column.label)), ...options.rows.map((row) => columns.map((_column, index) => cellAt(row, index)))].map((cells) => {
925
- 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);
926
1011
  return `${bar}${cells.map((cell, index) => ` ${align(cell, widths[index] ?? width(cell), aligns[index] ?? "left")} `).join(bar)}${bar}`;
927
1012
  });
928
1013
  const segments = widths.map((columnWidth) => repeatTo(chars.horizontal, columnWidth + 2));
929
- const top = paint(styler, `${chars.topLeft}${segments.join(chars.teeDown)}${chars.topRight}`);
930
- const rule = paint(styler, `${chars.teeRight}${segments.join(chars.cross)}${chars.teeLeft}`);
931
- 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);
932
1017
  return [
933
1018
  top,
934
1019
  ...renderedRows.slice(0, 1),
@@ -943,7 +1028,7 @@ function renderTable(options) {
943
1028
  *
944
1029
  * @remarks
945
1030
  * The `root` label is the unindented first line; its descendants are drawn beneath it with
946
- * {@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
947
1032
  * carried prefix using `│ ` under an ancestor that still has later siblings or ` ` under a
948
1033
  * last ancestor (so the guides line up exactly under the branch they descend from). Node
949
1034
  * labels are written as given (an already-styled label is honored); `options.styler` colors
@@ -961,7 +1046,11 @@ function renderTable(options) {
961
1046
  * ```
962
1047
  */
963
1048
  function renderTree(options) {
964
- 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");
965
1054
  }
966
1055
  /**
967
1056
  * Render the connector-prefixed lines for a {@link TreeNode} list — the recursive core
@@ -975,22 +1064,27 @@ function renderTree(options) {
975
1064
  *
976
1065
  * @param nodes - The sibling {@link TreeNode}s to render at this depth
977
1066
  * @param prefix - The guide/gap string carried in from the ancestor chain (`''` at the root)
978
- * @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`
979
1069
  * @returns The rendered lines for `nodes` and all their descendants
980
1070
  *
981
1071
  * @example
982
1072
  * ```ts
983
- * renderTreeChildren([{ label: 'a' }, { label: 'b' }], '')
1073
+ * renderTreeChildren([{ label: 'a' }, { label: 'b' }], '', { border: 'single' })
984
1074
  * // ['├─ a', '└─ b']
985
1075
  * ```
986
1076
  */
987
- 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} `;
988
1082
  const lines = [];
989
1083
  nodes.forEach((node, index) => {
990
1084
  const last = index === nodes.length - 1;
991
- lines.push(`${prefix}${paint(styler, last ? TREE_CHARS.corner : TREE_CHARS.branch)}${node.label}`);
992
- const carry = `${prefix}${paint(styler, last ? TREE_CHARS.gap : TREE_CHARS.guide)}`;
993
- 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));
994
1088
  });
995
1089
  return lines;
996
1090
  }
@@ -1097,7 +1191,7 @@ function renderBar(options) {
1097
1191
  const current = options.total <= 0 ? 0 : Math.max(0, Math.min(options.total, options.current));
1098
1192
  const fraction = options.total <= 0 ? 1 : current / options.total;
1099
1193
  const filledCells = Math.round(fraction * track);
1100
- 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})`;
1101
1195
  }
1102
1196
  /**
1103
1197
  * Run `fn` with the global `console.*` captured for its duration, returning the function's `value`
@@ -1221,17 +1315,19 @@ var ANSIRenderer = class {
1221
1315
  * @remarks
1222
1316
  * - **Registry (§9).** Loggers live in an insertion-ordered `Map` keyed by `name`.
1223
1317
  * `register(name, options?)` mints a {@link Logger} named `name` — the manager's default
1224
- * `level` / `sink` / `styler` / `limit` / `silent` flow in unless `options` OVERRIDES them
1318
+ * `level` / `sink` / `styler` / `theme` / `format` / `limit` / `silent` flow in unless
1319
+ * `options` OVERRIDES them
1225
1320
  * (`name` is always the registry key, so any `options.name` is ignored) — stores it (a
1226
1321
  * re-`register` of the same name OVERWRITES, last write wins), and returns it. `count` is
1227
1322
  * the map size, `logger(name)` looks one up, `loggers()` lists them in insertion order.
1228
1323
  * - **Removal (§9.2).** `remove()` clears ALL, `remove(name)` drops ONE (`true` if present),
1229
- * `remove(names)` drops a batch (`true` if any was removed). `clear()` empties the registry.
1324
+ * `remove(names)` drops a batch (`true` if any was removed).
1230
1325
  * (Removal does NOT `destroy` the returned loggers — a caller still holding one keeps using
1231
1326
  * it; the manager simply stops tracking it.)
1232
1327
  * - **Fan-out.** `debug` / `info` / `warn` / `error(message, data?)` forward the one call to
1233
- * EVERY registered logger; each gates / emits / writes per its own `level` and `sink`. A
1234
- * fan-out over an empty registry is a no-op.
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.
1235
1331
  * - **Event-free.** No emitter, no events — the manager is a pure registry; observability is
1236
1332
  * per-{@link Logger}.
1237
1333
  *
@@ -1249,12 +1345,16 @@ var LoggerManager = class {
1249
1345
  #level;
1250
1346
  #sink;
1251
1347
  #styler;
1348
+ #theme;
1349
+ #format;
1252
1350
  #limit;
1253
1351
  #silent;
1254
1352
  constructor(options) {
1255
1353
  this.#level = options?.level;
1256
1354
  this.#sink = options?.sink;
1257
1355
  this.#styler = options?.styler;
1356
+ this.#theme = options?.theme;
1357
+ this.#format = options?.format;
1258
1358
  this.#limit = options?.limit;
1259
1359
  this.#silent = options?.silent;
1260
1360
  }
@@ -1266,6 +1366,8 @@ var LoggerManager = class {
1266
1366
  ...this.#level !== void 0 ? { level: this.#level } : {},
1267
1367
  ...this.#sink !== void 0 ? { sink: this.#sink } : {},
1268
1368
  ...this.#styler !== void 0 ? { styler: this.#styler } : {},
1369
+ ...this.#theme !== void 0 ? { theme: this.#theme } : {},
1370
+ ...this.#format !== void 0 ? { format: this.#format } : {},
1269
1371
  ...this.#limit !== void 0 ? { limit: this.#limit } : {},
1270
1372
  ...this.#silent !== void 0 ? { silent: this.#silent } : {},
1271
1373
  ...options,
@@ -1304,9 +1406,6 @@ var LoggerManager = class {
1304
1406
  }
1305
1407
  return this.#loggers.delete(names);
1306
1408
  }
1307
- clear() {
1308
- this.#loggers.clear();
1309
- }
1310
1409
  };
1311
1410
  //#endregion
1312
1411
  //#region src/core/Progress.ts
@@ -1342,8 +1441,11 @@ var Progress = class {
1342
1441
  #emitter;
1343
1442
  #total;
1344
1443
  #width;
1444
+ #fill;
1445
+ #empty;
1345
1446
  #sink;
1346
1447
  #styler;
1448
+ #theme;
1347
1449
  #message;
1348
1450
  #current = 0;
1349
1451
  #active = true;
@@ -1355,8 +1457,11 @@ var Progress = class {
1355
1457
  });
1356
1458
  this.#total = options.total;
1357
1459
  this.#width = options.width ?? 30;
1460
+ this.#fill = options.fill;
1461
+ this.#empty = options.empty;
1358
1462
  this.#sink = options.sink ?? createConsoleSink();
1359
1463
  this.#styler = options.styler ?? createStyler();
1464
+ this.#theme = options.theme ?? DEFAULT_THEME;
1360
1465
  this.#message = options.message ?? "";
1361
1466
  }
1362
1467
  get emitter() {
@@ -1409,7 +1514,10 @@ var Progress = class {
1409
1514
  current: this.#current,
1410
1515
  total: this.#total,
1411
1516
  width: this.#width,
1412
- 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
1413
1521
  });
1414
1522
  const line = this.#message === "" ? bar : `${bar} ${this.#message}`;
1415
1523
  this.#sink.write(`\r${line}${final ? "\n" : ""}`, level);
@@ -1430,7 +1538,7 @@ var Progress = class {
1430
1538
  * capture (the capture chunk), no level retention (the logger). Just format + write.
1431
1539
  * - **`status` is a narrative OUTCOME, not a log level.** Its {@link StatusLevel} (`success` /
1432
1540
  * `error` / `warn` / `info`) is distinct from {@link import('./types.js').LogLevel}: an icon
1433
- * ({@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
1434
1542
  * error stream (the `level` hint forwarded to {@link SinkInterface.write}) — there is no
1435
1543
  * gating and no severity ordering.
1436
1544
  * - **Width-aware.** `section` (and a `box` with no explicit `width`) lay out to the reporter's
@@ -1450,49 +1558,45 @@ var Progress = class {
1450
1558
  var Reporter = class {
1451
1559
  #sink;
1452
1560
  #styler;
1561
+ #theme;
1453
1562
  #width;
1454
1563
  constructor(options) {
1455
1564
  this.#sink = options?.sink ?? createConsoleSink();
1456
1565
  this.#styler = options?.styler ?? createStyler();
1566
+ this.#theme = options?.theme ?? DEFAULT_THEME;
1457
1567
  this.#width = options?.width ?? 80;
1458
1568
  }
1459
1569
  section(title) {
1460
1570
  this.#sink.write(renderSeparator({
1461
1571
  title,
1462
1572
  width: this.#width,
1463
- styler: this.#styler.dim
1573
+ styler: this.#styler,
1574
+ style: this.#theme.chrome
1464
1575
  }));
1465
1576
  }
1466
1577
  step(message, position) {
1467
- 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}]`)} `;
1468
1579
  this.#sink.write(`${prefix}${message}`);
1469
1580
  }
1470
1581
  timing(label, ms) {
1471
- this.#sink.write(`${label} ${this.#styler.dim(`… ${formatDuration(ms)}`)}`);
1582
+ this.#sink.write(`${label} ${this.#styler.render(this.#theme.chrome, `… ${formatDuration(ms)}`)}`);
1472
1583
  }
1473
1584
  status(level, message) {
1474
- const color = this.#styler[STATUS_COLORS[level]];
1475
- 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)}`;
1476
1587
  this.#sink.write(line, level === "error" ? "error" : void 0);
1477
1588
  }
1478
1589
  table(options) {
1479
- this.#sink.write(renderTable({
1480
- styler: this.#styler.dim,
1481
- ...options
1482
- }));
1590
+ this.#sink.write(renderTable(this.#resolveStyle(options)));
1483
1591
  }
1484
1592
  tree(options) {
1485
- this.#sink.write(renderTree({
1486
- styler: this.#styler.dim,
1487
- ...options
1488
- }));
1593
+ this.#sink.write(renderTree(this.#resolveStyle(options)));
1489
1594
  }
1490
1595
  box(options) {
1491
- this.#sink.write(renderBox({
1596
+ this.#sink.write(renderBox(this.#resolveStyle({
1492
1597
  width: this.#width,
1493
- styler: this.#styler.dim,
1494
1598
  ...options
1495
- }));
1599
+ })));
1496
1600
  }
1497
1601
  line(text) {
1498
1602
  this.#sink.write(text);
@@ -1500,6 +1604,13 @@ var Reporter = class {
1500
1604
  blank(count = 1) {
1501
1605
  for (let index = 0; index < count; index += 1) this.#sink.write("");
1502
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
+ }
1503
1614
  };
1504
1615
  //#endregion
1505
1616
  //#region src/core/Spinner.ts
@@ -1523,7 +1634,7 @@ var Reporter = class {
1523
1634
  * - **Idempotent `start`.** A {@link start} while already `active` is a no-op (it never arms a second
1524
1635
  * timer).
1525
1636
  * - **Outcome lines.** {@link success} / {@link failure} clear the timer then write + emit a FINAL line —
1526
- * the {@link STATUS_ICONS} `✔` / `✖` (colored via {@link STATUS_COLORS}) + the message — terminated
1637
+ * the supplied theme status icon + style (`✔` / `✖` by default) + the message — terminated
1527
1638
  * by a newline (the activity is over; the line is committed, not overwritten). {@link failure} routes to
1528
1639
  * the sink's error stream.
1529
1640
  * - **Lifecycle (§10).** {@link stop} clears the timer and LEAVES the current line; {@link destroy}
@@ -1544,6 +1655,7 @@ var Spinner = class {
1544
1655
  #interval;
1545
1656
  #sink;
1546
1657
  #styler;
1658
+ #theme;
1547
1659
  #message;
1548
1660
  #handle;
1549
1661
  #index = 0;
@@ -1557,6 +1669,7 @@ var Spinner = class {
1557
1669
  this.#interval = options?.interval ?? 80;
1558
1670
  this.#sink = options?.sink ?? createConsoleSink();
1559
1671
  this.#styler = options?.styler ?? createStyler();
1672
+ this.#theme = options?.theme ?? DEFAULT_THEME;
1560
1673
  this.#message = options?.message ?? "";
1561
1674
  }
1562
1675
  get emitter() {
@@ -1602,13 +1715,13 @@ var Spinner = class {
1602
1715
  this.stop();
1603
1716
  const text = message ?? this.#message;
1604
1717
  if (message !== void 0) this.#message = message;
1605
- const color = this.#styler[STATUS_COLORS[level]];
1606
- 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)}`;
1607
1720
  this.#emitter.emit("frame", line);
1608
1721
  this.#sink.write(`\r${line}\n`, level === "error" ? "error" : void 0);
1609
1722
  }
1610
1723
  #line() {
1611
- const glyph = this.#styler.cyan(this.#frames[this.#index] ?? "");
1724
+ const glyph = this.#styler.render(this.#theme.accent, this.#frames[this.#index] ?? "");
1612
1725
  return this.#message === "" ? glyph : `${glyph} ${this.#message}`;
1613
1726
  }
1614
1727
  #paint(line) {
@@ -1636,6 +1749,9 @@ var Spinner = class {
1636
1749
  * - **Immutable.** `#foreground` and `#attribute` return a fresh `Styler` (the style is
1637
1750
  * rebuilt, never mutated). A later color of the same channel WINS (last write); a
1638
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.
1639
1755
  * - **`enabled` switch.** When `false`, the render function returns text VERBATIM — no
1640
1756
  * renderer call, no escape codes (for a non-TTY / `NO_COLOR` / piped output).
1641
1757
  * - **Event-free** — a pure styling primitive (AGENTS §13), like `Scheduler`.
@@ -1671,7 +1787,7 @@ var Styler = class Styler {
1671
1787
  * type assertion is used (AGENTS §1 / §14 — narrow, never assert).
1672
1788
  */
1673
1789
  get surface() {
1674
- const render = this.#render.bind(this);
1790
+ const callable = this.#render.bind(this);
1675
1791
  const descriptors = {
1676
1792
  style: {
1677
1793
  value: this.#style,
@@ -1680,6 +1796,10 @@ var Styler = class Styler {
1680
1796
  enabled: {
1681
1797
  value: this.#enabled,
1682
1798
  enumerable: true
1799
+ },
1800
+ render: {
1801
+ value: this.render.bind(this),
1802
+ enumerable: true
1683
1803
  }
1684
1804
  };
1685
1805
  for (const color of COLORS) descriptors[color] = {
@@ -1690,13 +1810,44 @@ var Styler = class Styler {
1690
1810
  get: this.#attributeSurface.bind(this, attribute),
1691
1811
  enumerable: true
1692
1812
  };
1693
- const surface = Object.defineProperties(render, descriptors);
1813
+ const surface = Object.defineProperties(callable, descriptors);
1694
1814
  if (this.#isSurface(surface)) return surface;
1695
1815
  throw new ConsoleError("INVARIANT", "console: styler surface construction is incomplete");
1696
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
+ }
1697
1839
  #render(text) {
1698
1840
  return this.#enabled ? this.#renderer.render(this.#style, text) : text;
1699
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
+ }
1700
1851
  #foregroundSurface(color) {
1701
1852
  return this.#foreground(color).surface;
1702
1853
  }
@@ -1704,7 +1855,7 @@ var Styler = class Styler {
1704
1855
  return this.#attribute(attribute).surface;
1705
1856
  }
1706
1857
  #isSurface(value) {
1707
- return typeof value === "function" && "style" in value && "enabled" in value && "red" in value && "bold" in value;
1858
+ return typeof value === "function" && "style" in value && "enabled" in value && "render" in value && "red" in value && "bold" in value;
1708
1859
  }
1709
1860
  #foreground(color) {
1710
1861
  return new Styler(this.#renderer, this.#enabled, Object.freeze({
@@ -1774,6 +1925,52 @@ function createStyler(options) {
1774
1925
  return new Styler(options?.renderer ?? new ANSIRenderer(), options?.enabled ?? true, EMPTY_STYLE).surface;
1775
1926
  }
1776
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
+ /**
1777
1974
  * Create the default {@link SinkInterface} — a console sink that routes by level and writes
1778
1975
  * through the `console` methods SNAPSHOTTED at creation. The default output target behind
1779
1976
  * {@link createLogger}.
@@ -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,7 +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.TREE_CHARS = TREE_CHARS;
2163
2364
  exports.align = align;
2164
2365
  exports.cellAt = cellAt;
2165
2366
  exports.createANSIRenderer = createANSIRenderer;
@@ -2171,10 +2372,12 @@ exports.createProgress = createProgress;
2171
2372
  exports.createReporter = createReporter;
2172
2373
  exports.createSpinner = createSpinner;
2173
2374
  exports.createStyler = createStyler;
2375
+ exports.createTheme = createTheme;
2174
2376
  exports.formatArgs = formatArgs;
2175
2377
  exports.formatDuration = formatDuration;
2176
2378
  exports.formatRecord = formatRecord;
2177
2379
  exports.formatTime = formatTime;
2380
+ exports.freezeStyle = freezeStyle;
2178
2381
  exports.isConsoleError = isConsoleError;
2179
2382
  exports.meetsLevel = meetsLevel;
2180
2383
  exports.paint = paint;