osdy-pi 1.11.9 → 1.11.10

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.
package/README.md CHANGED
@@ -63,7 +63,7 @@ Run **`/osdyConfig`** in interactive Pi to open the Osdy Control Center. The ten
63
63
  - TODO shows configured selection, current eligibility, and actually loaded SDK registration separately. Opt-in/out requires an idle session and a Cancel-first inline confirmation naming the target, owned Gentle filter effects, and reload. Successful selection closes/disposes the panel and releases its editor hold before reloading once; restart Pi if reload fails. Rejections stay in the panel. No installs or task-history changes occur; legacy `/osdy-pi todo on|off|status` remains available.
64
64
  - Agents shows concise **Joker / Gentle** choices without activating anything. With detail focus, visible lowercase-only **j Joker / g Gentle** letters open the existing confirmation directly; only available, unambiguous rows get shortcuts. **↑/↓ + Enter** remains the fallback. Profile and Agent selections survive category navigation; unavailable choices fall back to the current row. Letters never bypass category focus, confirmation, path input, loading, querying, or saving. Selection is idle-only and Cancel-first confirmed, naming the real target, possible Joker installation, owned agent filters, and preserved unrelated resources. Normal personal Pi only: `pi:dev` and isolated/override sessions are blocked, never redirected to personal settings. Success closes/disposes the panel and releases its editor hold before reloading once; restart Pi if reload fails. Legacy `/osdy-pi agents setup|on|off|status` is unchanged.
65
65
  - Theme lists Pi's available themes and marks the current theme. Explicit **Page x/N** pages contain at most **8 themes**, fewer in short viewports or with expanded help. **Page Up/Down** changes pages, **Home/End** reaches the first/last theme, and arrows cross page boundaries. Selection survives category navigation and resizing; resizing adjusts the page around the selected theme. A successful selection applies live and saves globally in the active Pi agent directory; project settings can override it at startup. Failures appear in the modal.
66
- - Header offers **osdy-theme / neon**; Mascot offers **current / Bts**; Editor offers **auto / extended / simple**, with the current preference marked. Editor also shows the effective mode: small terminals stay simple/native even with extended selected.
66
+ - Header offers **osdy-theme / neon**; Mascot offers **current / Bts / Osdy-Halloween**; Editor offers **auto / extended / simple**, with the current preference marked. Editor also shows the effective mode: small terminals stay simple/native even with extended selected.
67
67
  - Preferences save immediately through the same runtime and global Osdy settings store as `/osdy-pi` commands (`<agent-dir>/extensions/osdy-pi/settings.json`). Header/Mascot apply live without closing the panel. Editor replacement waits until all overlays close; if another overlay remains, the next existing responsive watcher tick applies the latest choice. The effective mode reports the actually mounted editor while queued. Opening a category only reads values; while Osdy is disabled, choices save for later without enabling it.
68
68
  - A selection shows **Saving** until persistence finishes and **Saved globally** only on success. Failed saves retain the live preference but report **could not be saved**; select again to retry. Application errors report **Not saved**, without claiming the runtime change succeeded. Duplicate selections are ignored while saving.
69
69
  - **Git** shows the current branch (or detached HEAD), changed-entry count/clean status, and the current widget position as a read-only session detail. Working-tree enablement applies and saves through the same owner as `/osdy-pi working-tree`; Git inspection never writes Git. `/osdy-pi diff` and position commands are unchanged.
@@ -300,7 +300,7 @@ Before rename or removal, close this Pi process when it uses the target and **ma
300
300
  | Area | Included behavior |
301
301
  | --- | --- |
302
302
  | Themes | 23 built-in themes, including Osdy, Kanagawa, Dracula, Gruvbox, Nord, Rosé Pine, Daniela Cute, Halloween, Halloween Killer, Catppuccin, Matrix, Lucent Orange, and three Spider-Man-inspired palettes |
303
- | Header and mascot | Independently selectable `osdy-theme`/`neon` headers and `current`/`bts` mascots |
303
+ | Header and mascot | Independently selectable `osdy-theme`/`neon` headers and `current`/`bts`/`osdy-halloween` mascots |
304
304
  | Messages | Native colored user cards without emojis and plain assistant Markdown with a separate assistant `🦝` row |
305
305
  | Input | Responsive auto editor by default, with selectable simple Pi-native or extended framed modes |
306
306
  | Status | Six activity labels with compact per-state glyphs and a shared theme-color pulse, responsive footer metrics, dynamic extension statuses, and Codex subscription quota with low-capacity emphasis |
@@ -366,10 +366,12 @@ Header and mascot choices are independent:
366
366
  | Element | Choices | Command |
367
367
  | --- | --- | --- |
368
368
  | Header | `osdy-theme` (default), `neon` | `/osdy-pi header osdy-theme|neon` |
369
- | Mascot | `current` (default), `bts` | `/osdy-pi mascot current|bts` |
369
+ | Mascot | `current` (default), `bts`, `osdy-halloween` | `/osdy-pi mascot current\|bts\|osdy-halloween` |
370
370
 
371
371
  Both commands update the UI immediately. Add `status` instead of a choice to inspect the current selection. Choices persist in the global Osdy Pi settings across reloads and sessions. The old top-level style commands and `classic` header aliases are no longer registered.
372
372
 
373
+ Choose **Osdy-Halloween** in `/osdyConfig` → **Mascot**, or run `/osdy-pi mascot osdy-halloween`. It dresses the same Current raccoon in ivory fangs and a high-collared dark/crimson vampire cape. Its glow follows the active theme's `accent`, `mdHeading`, and `mdLink` colors; no Halloween theme is required. Current remains the default, and Current/Bts artwork and version-1 settings compatibility are unchanged.
374
+
373
375
  The header also shows **`osdy-pi v<version>`** at the top right in the theme's accent color, read from the installed Osdy package—not Pi's host version or the workspace manifest. It uses an empty first row or adds a row without overwriting artwork; invalid/unreadable versions or labels wider than the terminal are omitted.
374
376
 
375
377
  In normal mode, the selected header and mascot render side by side when both fit. The Neon header derives its highlights from the active theme rather than using one fixed palette. Each mascot keeps its own tone map while its animated edge glow can resolve through theme colors. Header and mascot scaling remain independent, so any combination follows the same responsive layout rules.
@@ -407,7 +409,7 @@ The enabled state, editor mode, working-tree visibility preference, header, and
407
409
  | TODO selection | `/osdy-pi todo on\|off\|status` |
408
410
  | Session TODO (opt-in, starting in 1.5.0) | `/todos` (read-only) |
409
411
  | Header | `/osdy-pi header osdy-theme\|neon\|status` |
410
- | Mascot | `/osdy-pi mascot current\|bts\|status` |
412
+ | Mascot | `/osdy-pi mascot current\|bts\|osdy-halloween\|status` |
411
413
  | Editor | `/osdy-pi editor auto\|extended\|simple\|on\|off\|toggle\|status` |
412
414
  | Working tree | `/osdy-pi working-tree on\|off\|toggle\|status` |
413
415
  | Working tree | `/osdy-pi working-tree position top\|bottom\|status` |
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
2
2
  import { registerHooks } from "node:module";
3
3
  import test from "node:test";
4
4
  // @ts-expect-error Node's native TypeScript runner resolves test-only TypeScript source imports.
5
- import { HEADER_VARIANTS } from "./constants.ts";
5
+ import { HEADER_VARIANTS, mascotForChoice } from "./constants.ts";
6
6
  import type { SimpleTheme } from "./types.js";
7
7
 
8
8
  registerHooks({
@@ -19,7 +19,7 @@ registerHooks({
19
19
  });
20
20
 
21
21
  // @ts-expect-error Node's native TypeScript runner resolves test-only TypeScript source imports.
22
- const { colorAsciiCharacter } = await import("./animation.ts");
22
+ const { colorAsciiCharacter, animateAsciiLineWithToneMap } = await import("./animation.ts");
23
23
 
24
24
  class RecordingTheme implements SimpleTheme {
25
25
  readonly calls: Array<{ name: string; text: string }> = [];
@@ -74,6 +74,33 @@ void test("Neon renders main and dark pink through bounded accent styling", () =
74
74
  assert.equal(`${main}${dark.join("")}`.includes("\u001B[48;"), false);
75
75
  });
76
76
 
77
+ void test("Osdy-Halloween resolves edge glow against the live theme in static and animated frames", () => {
78
+ const { art, tonePalette } = mascotForChoice("osdy-halloween");
79
+ assert.deepEqual([tonePalette.p, tonePalette.c, tonePalette.v], ["accent", "mdHeading", "mdLink"]);
80
+ let themeId = "first";
81
+ const calls: string[] = [];
82
+ const theme: SimpleTheme = { fg(name, text) { calls.push(name); return `<${themeId}:${name}:${text}>`; } };
83
+ for (const style of ["static", "animated"] as const) {
84
+ for (const frame of [0, 7, 28]) {
85
+ const render = () => art.mascot.map((line, row) =>
86
+ animateAsciiLineWithToneMap(line, art.toneMap[row] ?? "", row, frame, theme, tonePalette, style)).join("\n");
87
+ themeId = "first";
88
+ const first = render();
89
+ themeId = "second";
90
+ const second = render();
91
+ assert.notEqual(first, second);
92
+ assert.equal(first.replaceAll("first:", "second:"), second, "only theme-resolved colors change");
93
+ assert.ok(second.includes("\u001B[38;2;"), "fixed body/costume foreground colors remain");
94
+ assert.ok(!second.includes("\u001B[48;"), "transparent background");
95
+ }
96
+ }
97
+ assert.deepEqual([...new Set(calls)].sort(), ["accent", "mdHeading", "mdLink"].sort());
98
+ for (let row = 0; row < art.mascot.length; row++) {
99
+ const occupied = Array.from(art.mascot[row] ?? "").flatMap((glyph, column) => glyph === " " ? [] : [column]);
100
+ assert.deepEqual(occupied.slice(-3).reverse().map((column) => art.toneMap[row]?.[column]), ["p", "c", "v"].slice(0, occupied.length));
101
+ }
102
+ });
103
+
77
104
  void test("Neon never maps a tone to mdHeading or mdLink", () => {
78
105
  for (const color of Object.values(neonPalette())) {
79
106
  if (typeof color === "string") {
@@ -457,6 +457,52 @@ function addRightEdgeGlow(art: MascotArt): MascotArt {
457
457
  };
458
458
  }
459
459
 
460
+ // Transparent overlay at Current's mouth/shoulders (row 17 onward).
461
+ // ▓ = dark cape, ▒ = crimson lining, █/▼ = ivory fangs. Keep the face and tail.
462
+ const VAMPIRE_COSTUME = [
463
+ " ▓ ▓",
464
+ " ▓▒▓ █ █ ▓▒▒▓",
465
+ " ▓▒▒▒▓ ▼ ▼ ▓▒▒▒▒▓",
466
+ " ▓▒▒▒▒▒▒▓ ▓▒▒▒▒▒▒▓",
467
+ " ▓▓▒▒▒▒▒▓▓ ▓▓▒▒▒▒▒▓▓",
468
+ " ▓▓▓▒▒▒▓▓ ▓▓▒▒▒▓▓▓",
469
+ " ▓▓▓▒▒▒▓▓ ▓▓▒▒▓▓▓",
470
+ " ▓▓▓▒▒▒▓▓ ▓▓▒▒▒▓▓",
471
+ " ▓▓▓▒▒▒▓▓ ▓▓▒▒▓▓",
472
+ " ▓▓▓▒▒▒▓▓ ▓▓▒▒▓▓",
473
+ " ▓▓▓▒▒▒▓▓ ▓▓▒▒▓▓",
474
+ " ▓▓▓▒▒▒▓▓ ▓▓▒▒▓▓",
475
+ " ▓▓▓▒▒▒▓▓ ▓▓▒▒▓▓",
476
+ " ▓▓▓▓▒▒▓▓ ▓▓▒▒▓▓",
477
+ " ▓▓▓▓▒▒▓▓ ▓▓▒▓▓",
478
+ " ▓▓▓▓▓▓ ▓▓▓",
479
+ ] as const;
480
+
481
+ function addVampireCostume(art: MascotArt): MascotArt {
482
+ const rows = art.mascot.map((line, row) => {
483
+ const glyphs = Array.from(line);
484
+ // Reserve b for lining; retain the few original eye glints in ivory h.
485
+ const tones = Array.from((art.toneMap[row] ?? "").replaceAll("b", "h"));
486
+ for (const [column, glyph] of Array.from(VAMPIRE_COSTUME[row - 17] ?? "").entries()) {
487
+ if (glyph === " ") continue;
488
+ glyphs[column] = glyph;
489
+ if (glyph === "▓") tones[column] = "d";
490
+ else if (glyph === "▒") tones[column] = "b";
491
+ else tones[column] = "h";
492
+ }
493
+ return { mascot: glyphs.join(""), toneMap: tones.join("") };
494
+ });
495
+ return { mascot: rows.map((row) => row.mascot), toneMap: rows.map((row) => row.toneMap) };
496
+ }
497
+
498
+ const HALLOWEEN_MASCOT_TONES: MascotTonePalette = {
499
+ ...HTML_MASCOT_TONES,
500
+ b: "#9F2342",
501
+ };
502
+ const HALLOWEEN_MASCOT = addRightEdgeGlow(addVampireCostume({
503
+ mascot: HTML_MASCOT,
504
+ toneMap: HTML_MASCOT_MAP,
505
+ }));
460
506
  const ROSE_MASCOT = addRightEdgeGlow({
461
507
  mascot: HTML_MASCOT,
462
508
  toneMap: HTML_MASCOT_MAP,
@@ -468,6 +514,7 @@ const BTS_MASCOT_ART = addRightEdgeGlow({
468
514
  for (const [art, palette] of [
469
515
  [ROSE_MASCOT, HTML_MASCOT_TONES],
470
516
  [BTS_MASCOT_ART, BTS_MASCOT_TONES],
517
+ [HALLOWEEN_MASCOT, HALLOWEEN_MASCOT_TONES],
471
518
  ] as const) {
472
519
  assertRawHexPalette(palette);
473
520
  assertMascotStructure(art, palette);
@@ -481,6 +528,7 @@ export type MascotConfig = {
481
528
  export const MASCOTS: Record<MascotChoice, MascotConfig> = {
482
529
  current: { art: ROSE_MASCOT, tonePalette: HTML_MASCOT_TONES },
483
530
  bts: { art: BTS_MASCOT_ART, tonePalette: BTS_MASCOT_TONES },
531
+ "osdy-halloween": { art: HALLOWEEN_MASCOT, tonePalette: HALLOWEEN_MASCOT_TONES },
484
532
  };
485
533
 
486
534
  export function mascotForChoice(choice: MascotChoice): MascotConfig {
@@ -21,7 +21,7 @@ const snapshot: VisualPreferenceSnapshot = { enabled: true, headerVariant: "osdy
21
21
  void test("preference rows expose every catalog value and exactly one current indicator", () => {
22
22
  for (const [category, field, values] of [
23
23
  ["Header", "headerVariant", ["osdy-theme", "neon"]],
24
- ["Mascot", "mascot", ["current", "bts"]],
24
+ ["Mascot", "mascot", ["current", "bts", "osdy-halloween"]],
25
25
  ["Editor", "editorMode", ["auto", "extended", "simple"]],
26
26
  ] as const) {
27
27
  for (const value of values) {
@@ -30,7 +30,9 @@ void test("preference rows expose every catalog value and exactly one current in
30
30
  assert.deepEqual(detail.rows.map((row) => "value" in row.action ? row.action.value : undefined), values);
31
31
  assert.equal(detail.rows.filter((row) => row.current).length, 1);
32
32
  assert.equal(detail.rows.find((row) => row.current)?.action.kind, category.toLowerCase());
33
- assert.match(detail.summary, new RegExp(`Current: ${value === "bts" ? "Bts" : value}`));
33
+ const label = value === "bts" ? "Bts" : value === "osdy-halloween" ? "Osdy-Halloween" : value;
34
+ assert.match(detail.summary, new RegExp(`Current: ${label}`));
35
+ assert.equal(detail.rows.find((row) => row.current)?.label, label);
34
36
  }
35
37
  }
36
38
  });
@@ -17,7 +17,11 @@ export interface ControlCenterPreferences {
17
17
  }
18
18
 
19
19
  export function visualPreferenceLabel(action: VisualPreferenceAction): string {
20
- return action.kind === "mascot" && action.value === "bts" ? "Bts" : action.value;
20
+ if (action.kind === "mascot") {
21
+ if (action.value === "bts") return "Bts";
22
+ if (action.value === "osdy-halloween") return "Osdy-Halloween";
23
+ }
24
+ return action.value;
21
25
  }
22
26
 
23
27
  /** Explicit view adaptation only; application and persistence belong to runtime. */
@@ -336,7 +336,7 @@ void test("inline preferences show all supported values and live current/effecti
336
336
  const f = preferenceFixture();
337
337
  for (const [category, choices, current] of [
338
338
  ["Header", ["osdy-theme", "neon"], "osdy-theme"],
339
- ["Mascot", ["current", "Bts"], "current"],
339
+ ["Mascot", ["current", "Bts", "Osdy-Halloween"], "current"],
340
340
  ["Editor", ["auto", "extended", "simple"], "auto"],
341
341
  ] as const) {
342
342
  f.panel.handleInput(down);
@@ -350,6 +350,20 @@ void test("inline preferences show all supported values and live current/effecti
350
350
  assert.deepEqual(f.applied, []);
351
351
  });
352
352
 
353
+ void test("Mascot selects Osdy-Halloween and reports its exact saved label", async () => {
354
+ const f = preferenceFixture();
355
+ f.panel.handleInput(down);
356
+ f.panel.handleInput(down);
357
+ f.panel.handleInput(right);
358
+ f.panel.handleInput("\x1b[F");
359
+ f.panel.handleInput("\r");
360
+ assert.deepEqual(f.applied, [{ kind: "mascot", value: "osdy-halloween" }]);
361
+ assert.match(f.panel.render(100).join("\n"), /Saving.*Osdy-Halloween/);
362
+ f.complete(true);
363
+ await Promise.resolve();
364
+ assert.match(f.panel.render(100).join("\n"), /Saved globally: Osdy-Halloween/);
365
+ });
366
+
353
367
  void test("changing categories resets selection to their current row, even after a longer theme list", () => {
354
368
  const f = preferenceFixture();
355
369
  f.panel.handleInput(right);
@@ -427,7 +441,7 @@ void test("inline preference rendering stays bounded at narrow widths and differ
427
441
  f.panel.handleInput(down);
428
442
  f.panel.handleInput(right);
429
443
  f.panel.handleInput("\x1b[F");
430
- assert.match(f.panel.render(48).join("\n"), new RegExp(`> ${["neon", "Bts", "simple"][category]}`));
444
+ assert.match(f.panel.render(48).join("\n"), new RegExp(`> ${["neon", "Osdy-Halloween", "simple"][category]}`));
431
445
  for (const height of [1, 4, 7, 9, 18]) {
432
446
  f.resize(height);
433
447
  for (const width of [1, 8, 24, 48, 80]) {
@@ -97,7 +97,7 @@ void test("editor settings normalize only supported versioned modes", () => {
97
97
  });
98
98
 
99
99
  void test("mascot choices preserve the version-one current default, migrate raccoon to Bts, and persist Bts", async () => {
100
- assert.deepEqual(MASCOT_CHOICES, ["current", "bts"]);
100
+ assert.deepEqual(MASCOT_CHOICES, ["current", "bts", "osdy-halloween"]);
101
101
  assert.equal(
102
102
  normalizeEditorSettings({ version: 1, mascot: "invalid" }).mascot,
103
103
  "current",
@@ -129,6 +129,18 @@ void test("mascot choices preserve the version-one current default, migrate racc
129
129
  assert.equal((await store.load()).mascot, "bts");
130
130
  });
131
131
 
132
+ void test("Osdy-Halloween round-trips through a fresh version-one settings store", async () => {
133
+ const fileSystem = new MemoryEditorSettingsFileSystem();
134
+ const path = "/agent/extensions/osdy-pi/settings.json";
135
+ const settings = normalizeEditorSettings({ version: 1, mascot: "osdy-halloween" });
136
+ assert.equal(settings.mascot, "osdy-halloween");
137
+ await createEditorSettingsStore(path, fileSystem).save(settings);
138
+ const persisted: unknown = JSON.parse(fileSystem.content ?? "{}");
139
+ assert.deepEqual(persisted, { ...settings, version: 1 });
140
+ assert.deepEqual(await createEditorSettingsStore(path, fileSystem).load(), settings);
141
+ assert.equal(normalizeEditorSettings({ version: 2, mascot: "osdy-halloween" }).mascot, "current");
142
+ });
143
+
132
144
  void test("enabled defaults safely and persists globally", async () => {
133
145
  assert.equal(
134
146
  normalizeEditorSettings({ version: 1, editorMode: "simple" }).enabled,
@@ -152,6 +152,7 @@ void test("shared visual actions apply immediately, save the complete settings,
152
152
  for (const enabled of [true, false]) {
153
153
  for (const action of [
154
154
  { kind: "header", value: "neon" }, { kind: "mascot", value: "bts" },
155
+ { kind: "mascot", value: "osdy-halloween" },
155
156
  { kind: "editor", value: "simple" }, { kind: "editor", value: "extended" },
156
157
  { kind: "editor", value: "auto" },
157
158
  ] as const satisfies readonly VisualPreferenceAction[]) {
@@ -903,6 +904,7 @@ void test("registered legacy commands and osdyConfig share live values and exist
903
904
  for (const [args, expected, field, value] of [
904
905
  ["header neon", "osdy-pi header: neon", "headerVariant", "neon"],
905
906
  ["mascot bts", "osdy-pi mascot: Bts", "mascot", "bts"],
907
+ ["mascot osdy-halloween", "osdy-pi mascot: Osdy-Halloween", "mascot", "osdy-halloween"],
906
908
  ["editor off", "osdy-pi editor mode: simple", "editorMode", "simple"],
907
909
  ["editor on", "osdy-pi editor mode: extended", "editorMode", "extended"],
908
910
  ["editor toggle", "osdy-pi editor mode: simple", "editorMode", "simple"],
@@ -916,7 +918,9 @@ void test("registered legacy commands and osdyConfig share live values and exist
916
918
  assert.equal(writes.at(-1)?.workingTreeEnabled, false);
917
919
  assert.equal(notices.at(-1)?.message, "osdy-pi working tree disabled");
918
920
  const before = writes.length;
919
- for (const args of ["working-tree status", "header status", "mascot status", "editor status", "header invalid", "mascot bts extra", "editor simple extra"]) await legacy.handler(args, ctx);
921
+ await legacy.handler("mascot status", ctx);
922
+ assert.deepEqual(notices.at(-1), { message: "osdy-pi mascot: Osdy-Halloween", level: "info" });
923
+ for (const args of ["working-tree status", "header status", "mascot status", "editor status", "header invalid", "mascot bts extra", "mascot osdy-halloween extra", "mascot invalid", "editor simple extra"]) await legacy.handler(args, ctx);
920
924
  assert.equal(writes.length, before, "status and invalid inputs never save");
921
925
  const beforeModalGitCalls = gitCalls.length;
922
926
  const beforeModalNotices = notices.length;
@@ -1348,15 +1352,19 @@ void test("uninstall command is offered and wired through guarded Pi CLI removal
1348
1352
  assert.match(source, /pi\.exec\("pi", \["remove", source/);
1349
1353
  });
1350
1354
 
1351
- void test("mascot command completes current, Bts, and status while persisting with immediate refresh", () => {
1355
+ void test("mascot command completes all three choices and status while persisting with immediate refresh", () => {
1352
1356
  assert.deepEqual(getOsdyCommandCompletions("mascot"), [
1353
1357
  { value: "mascot current", label: "mascot current" },
1354
1358
  { value: "mascot bts", label: "mascot bts" },
1359
+ { value: "mascot osdy-halloween", label: "mascot osdy-halloween" },
1355
1360
  { value: "mascot status", label: "mascot status" },
1356
1361
  ]);
1357
1362
  assert.deepEqual(getOsdyCommandCompletions("mascot b"), [
1358
1363
  { value: "mascot bts", label: "mascot bts" },
1359
1364
  ]);
1365
+ assert.deepEqual(getOsdyCommandCompletions("mascot o"), [
1366
+ { value: "mascot osdy-halloween", label: "mascot osdy-halloween" },
1367
+ ]);
1360
1368
  assert.deepEqual(getOsdyCommandCompletions("mascot d"), []);
1361
1369
 
1362
1370
  const source = readFileSync(new URL("./runtime.ts", import.meta.url), "utf8");
@@ -128,6 +128,7 @@ function isMascotChoice(value: string): value is MascotChoice {
128
128
  }
129
129
 
130
130
  function mascotLabel(mascot: MascotChoice): string {
131
+ if (mascot === "osdy-halloween") return "Osdy-Halloween";
131
132
  return mascot === "bts" ? "Bts" : mascot;
132
133
  }
133
134
 
@@ -44,7 +44,7 @@ export const HEADER_VARIANT_CHOICES = ["osdy-theme", "neon"] as const;
44
44
 
45
45
  export type HeaderVariant = (typeof HEADER_VARIANT_CHOICES)[number];
46
46
 
47
- export const MASCOT_CHOICES = ["current", "bts"] as const;
47
+ export const MASCOT_CHOICES = ["current", "bts", "osdy-halloween"] as const;
48
48
 
49
49
  export type MascotChoice = (typeof MASCOT_CHOICES)[number];
50
50
 
@@ -1,4 +1,5 @@
1
1
  import assert from "node:assert/strict";
2
+ import { createHash } from "node:crypto";
2
3
  import { existsSync, readFileSync } from "node:fs";
3
4
  import { registerHooks } from "node:module";
4
5
  import { fileURLToPath } from "node:url";
@@ -244,12 +245,57 @@ void test("selected headers render through foreground-only tone-map animation",
244
245
  void test("mascot selection wires Bts art through the responsive renderer", () => {
245
246
  const ui = readFileSync(new URL("./ui.ts", import.meta.url), "utf8");
246
247
 
247
- assert.deepEqual(Object.keys(MASCOTS), ["current", "bts"]);
248
+ assert.deepEqual(Object.keys(MASCOTS), ["current", "bts", "osdy-halloween"]);
248
249
  assert.ok(mascotForChoice("bts"));
249
250
  assert.match(ui, /const mascot = mascotForChoice\(state\.mascot\);/);
250
251
  assert.match(ui, /scaleMascot\(\s*mascot\.art,/);
251
252
  });
252
253
 
254
+ void test("Current and Bts retain their original glyphs, tone maps, and palettes", () => {
255
+ for (const [choice, fingerprint] of [
256
+ ["current", "5d399dc6d6cf7f88dce300ca224d157e23e21186b1991a4f57b65fe60c34d8ac"],
257
+ ["bts", "e2040efe00cf709b799834d5183ddae3273cfb515b8dfa0f63ae1b9c5def71ec"],
258
+ ] as const) {
259
+ assert.equal(createHash("sha256").update(JSON.stringify(MASCOTS[choice])).digest("hex"), fingerprint);
260
+ }
261
+ });
262
+
263
+ void test("Osdy-Halloween preserves Current's face and tail beneath a bounded vampire costume", () => {
264
+ const { art, tonePalette } = mascotForChoice("osdy-halloween");
265
+ const current = MASCOTS.current.art;
266
+ assert.deepEqual(art.mascot.slice(0, 17), current.mascot.slice(0, 17), "same ears, mask, eyes and snout");
267
+ for (let row = 21; row < current.mascot.length; row++) {
268
+ assert.equal(art.mascot[row]?.slice(44), current.mascot[row]?.slice(44), "original striped tail");
269
+ }
270
+ assert.notDeepEqual(art.mascot.slice(17), current.mascot.slice(17));
271
+ const fangs = art.mascot.slice(17, 20).join("\n");
272
+ assert.match(fangs, /▼[^▼\n]+▼/, "two ivory fang tips separated by the original muzzle");
273
+ assert.equal([...fangs].filter((glyph) => glyph === "▼").length, 2, "one-column fang tips");
274
+ assert.equal([...fangs].filter((glyph) => glyph === "█").length, 2, "one-column stems keep fangs two rows tall");
275
+ assert.equal(art.mascot.length, current.mascot.length);
276
+ assert.deepEqual(art.mascot.map((row) => row.length), current.mascot.map((row) => row.length));
277
+ assert.equal(art.toneMap[17]?.[8], "d", "raised collar has a dark outer edge");
278
+ assert.equal(art.toneMap[20]?.[12], "b", "collar has crimson lining");
279
+ assert.equal(tonePalette.b, "#9F2342");
280
+ assert.equal(tonePalette.d, MASCOTS.current.tonePalette.d);
281
+ for (const [width, rows] of [[100, 100], [48, 28], [32, 20], [1, 1]] as const) {
282
+ const scaled = scaleMascot(art, width, rows);
283
+ assert.ok(scaled.mascot.length <= rows);
284
+ assert.equal(scaled.mascot.length, scaled.toneMap.length);
285
+ if (width >= 32) assert.match(scaled.mascot.join("\n"), /▼+[^▼\n]+▼+/, "both fangs survive responsive sampling");
286
+ for (let row = 0; row < scaled.mascot.length; row++) {
287
+ const glyphs = Array.from(scaled.mascot[row] ?? "");
288
+ const tones = Array.from(scaled.toneMap[row] ?? "");
289
+ assert.ok(visibleWidth(scaled.mascot[row] ?? "") <= width);
290
+ assert.equal(glyphs.length, tones.length);
291
+ for (let column = 0; column < glyphs.length; column++) {
292
+ if (glyphs[column] !== " ") assert.ok(Object.keys(tonePalette).includes(tones[column] ?? ""));
293
+ if (glyphs[column] === "▼") assert.equal(tones[column], "h", "fangs retain ivory rather than glow/lining tones");
294
+ }
295
+ }
296
+ }
297
+ });
298
+
253
299
  void test("Bts retains the 60 by 26 raccoon glyph structure and transparent tone cells", () => {
254
300
  const art = MASCOTS.bts.art;
255
301
  assert.equal(art.mascot.length, 26);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "osdy-pi",
3
- "version": "1.11.9",
3
+ "version": "1.11.10",
4
4
  "description": "Osdy Pi package: themes, header, and custom editor for Pi.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -83,7 +83,7 @@
83
83
  "typescript": "^5.9.3",
84
84
  "typescript-eslint": "^8.46.3"
85
85
  },
86
- "releaseSource": "5e7c5215302d92dc19cf7aedea5d8b767cebd76a",
86
+ "releaseSource": "96f670c11788dfb1a336c664a266b8581546ff59",
87
87
  "repository": {
88
88
  "type": "git",
89
89
  "url": "git+https://github.com/OsdyOrtiz/Osdy-Pi.git"