@groeponline/pi-wishcraft 1.10.0 → 1.12.0

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/CHANGELOG.md CHANGED
@@ -2,6 +2,21 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [1.12.0] - 2026-09-25
6
+
7
+ ### Added
8
+ - Recovered the unmatched vNext gallery motions (ember-trail, lantern-glow, fat-band, and the rest that were not already in the catalog) and the extreme-motion rail candidates (braille wave, boids, chevrons, shimmer, seismograph, fat-band). Assign motion `fat-band` to paint the topographic streaming rail; the default sweep stays a single status row.
9
+ - Deck skills: `w` opens the multi-step new-skill wizard (name, description, template, triggers) and writes `SKILL.md`. `n` remains the one-field create.
10
+ - Appearance route search (`/`) filters presets, palettes, motions, and motion levels. Enter persists a structural base, a streaming motion, or a motion level.
11
+
12
+ ## [1.11.0] - 2026-09-20
13
+
14
+ ### Fixed
15
+ - Deck navigation (reported by Joep): ↑↓ walking the route column is now sticky. Previously ↓ arriving at a list route (Skills, Appearance, Motion, Ideas) was silently consumed by the center list, so walking down the left column appeared to jump sideways into the right pane mid-walk. Focus only crosses columns on an explicit ←/tab (nav) or → (list).
16
+
17
+ ### Changed
18
+ - Motion sweeps traverse on an eased ping-pong instead of a wrapping step: the head decelerates at the rail edges, turns, and glides back — no teleport from the last cell to the first. Each motion's `ease` curve (`linear` / `pulse` / `breathe`) now actually shapes traversal velocity; fractional head positions drive a truecolor trail ramp (hot head cooling to the dim track) replacing the four discrete color steps, and ember/heat heads flicker with the welcome lantern's breathe-plus-ripple curve. Gallery previews render the same eased traversal.
19
+
5
20
  ## [1.10.0] - 2026-09-13
6
21
 
7
22
  ### Fixed
@@ -55,6 +55,13 @@ idle / ready 0 FPS
55
55
 
56
56
  Terminal one-shots (`success`, `warning`, `error`) settle their semantic state back to `idle/ready` when the final frame completes. This prevents a completed agent run from leaving `done` painted indefinitely without keeping a background timer alive.
57
57
 
58
+ Active sweeps traverse the rail on an eased ping-pong: the head decelerates at
59
+ the edges, turns, and glides back instead of teleporting from the last cell to
60
+ the first. Each motion's `ease` curve (`linear`, `pulse`, `breathe`) shapes the
61
+ traversal, the wake cools through a truecolor ramp from the hot head to the
62
+ dim track, and ember/heat heads flicker with the lantern's breathe-plus-ripple
63
+ curve. ASCII terminals keep the plain directional comet.
64
+
58
65
  Reduced/functional/off motion policies may suppress frames entirely; status text must still communicate the state. `NO_COLOR`, screen-reader flags and ASCII fallback are first-class inputs to the same policy.
59
66
 
60
67
  ## Fault isolation
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groeponline/pi-wishcraft",
3
- "version": "1.10.0",
3
+ "version": "1.12.0",
4
4
  "description": "Operator cockpit for Pi: live powerline status, searchable skills, idea queue, sticky Bash, hooks, policy controls, and session UX.",
5
5
  "type": "module",
6
6
  "files": [
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Map the skill catalog onto workbench rows (preview, health, sparkline).
3
+ * The usage ledger stores a count, not a histogram, so the series is that count.
4
+ */
5
+
6
+ import {
7
+ collectSkillDoctorInputs,
8
+ diagnoseSkills,
9
+ type SkillDoctorRow,
10
+ } from "./skill-doctor.ts";
11
+ import {
12
+ readSkillBody,
13
+ type SkillEntry,
14
+ type SkillUsage,
15
+ } from "./skill-registry.ts";
16
+ import { parseSkillTriggers, type WorkbenchSkill } from "./workbench.ts";
17
+
18
+ function usageSeriesOf(usage: SkillUsage | undefined): number[] {
19
+ if (!usage || usage.count <= 0) return [];
20
+ return [usage.count];
21
+ }
22
+
23
+ export function workbenchSkillFromEntry(
24
+ entry: SkillEntry,
25
+ usage: ReadonlyMap<string, SkillUsage>,
26
+ doctorByName?: ReadonlyMap<string, SkillDoctorRow>,
27
+ body = readSkillBody(entry.filePath),
28
+ ): WorkbenchSkill {
29
+ const used = usage.get(entry.name);
30
+ const row = doctorByName?.get(entry.name);
31
+ return {
32
+ name: entry.name,
33
+ description: entry.description,
34
+ category: entry.category,
35
+ warning: entry.warning,
36
+ usageCount: used?.count ?? 0,
37
+ usageSeries: usageSeriesOf(used),
38
+ health: row?.status ?? (entry.warning ? "warn" : "ok"),
39
+ bodyPreview: body,
40
+ triggers: parseSkillTriggers(entry.name, body),
41
+ filePath: entry.filePath,
42
+ };
43
+ }
44
+
45
+ export function loadWorkbenchSkills(cwd: string = process.cwd()): WorkbenchSkill[] {
46
+ const inputs = collectSkillDoctorInputs(cwd);
47
+ const rank: Record<SkillDoctorRow["status"], number> = { fail: 0, warn: 1, ok: 2 };
48
+ const doctorByName = new Map<string, SkillDoctorRow>();
49
+ for (const row of diagnoseSkills(inputs.entries, inputs.usage, inputs.contents)) {
50
+ const prev = doctorByName.get(row.skill);
51
+ if (!prev || rank[row.status] < rank[prev.status]) doctorByName.set(row.skill, row);
52
+ }
53
+ return inputs.entries.map((entry) =>
54
+ workbenchSkillFromEntry(entry, inputs.usage, doctorByName, inputs.contents.get(entry.filePath)),
55
+ );
56
+ }
57
+
58
+ export function doctorRowsBySkill(rows: readonly SkillDoctorRow[]): Map<string, SkillDoctorRow> {
59
+ return new Map(rows.map((row) => [row.skill, row]));
60
+ }
@@ -0,0 +1,337 @@
1
+ /**
2
+ * Skill workbench: split-pane list + metadata + health + sparkline + preview,
3
+ * plus an inline new-skill wizard and workflow visualization.
4
+ * Recovered from the closed vNext operator draft and wired to the Deck.
5
+ */
6
+
7
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
8
+ import { join } from "node:path";
9
+ import type { Theme } from "@earendil-works/pi-coding-agent";
10
+ import { truncateToWidth } from "@earendil-works/pi-tui";
11
+ import { getAgentPath } from "../../paths/agent-dirs.ts";
12
+ import { invalidateSkillCache } from "./skill-registry.ts";
13
+ import {
14
+ SKILL_TEMPLATE_IDS,
15
+ SkillNameError,
16
+ isSkillTemplateId,
17
+ renderSkillTemplate,
18
+ sanitizeSkillName,
19
+ type SkillTemplateId,
20
+ } from "./skill-templates.ts";
21
+ import type { SkillDoctorStatus } from "./skill-doctor.ts";
22
+
23
+ export interface WorkbenchSkill {
24
+ name: string;
25
+ description: string;
26
+ category: string;
27
+ warning?: string;
28
+ usageCount: number;
29
+ usageSeries?: number[];
30
+ health?: SkillDoctorStatus;
31
+ bodyPreview?: string;
32
+ triggers?: string[];
33
+ filePath?: string;
34
+ }
35
+
36
+ export const WIZARD_STEPS = [
37
+ "name",
38
+ "description",
39
+ "template",
40
+ "triggers",
41
+ "confirm",
42
+ ] as const;
43
+
44
+ export type WizardStep = (typeof WIZARD_STEPS)[number];
45
+
46
+ export interface SkillWizardState {
47
+ step: WizardStep;
48
+ name: string;
49
+ description: string;
50
+ template: SkillTemplateId;
51
+ triggers: string;
52
+ error: string | null;
53
+ }
54
+
55
+ export type WizardInputResult =
56
+ | { type: "update"; wizard: SkillWizardState }
57
+ | { type: "close" }
58
+ | { type: "save"; wizard: SkillWizardState };
59
+
60
+ export function createSkillWizard(): SkillWizardState {
61
+ return {
62
+ step: "name",
63
+ name: "",
64
+ description: "",
65
+ template: "standard",
66
+ triggers: "",
67
+ error: null,
68
+ };
69
+ }
70
+
71
+ export function wizardStepIndex(step: WizardStep): number {
72
+ return WIZARD_STEPS.indexOf(step);
73
+ }
74
+
75
+ export function applyWizardInput(state: SkillWizardState, data: string): SkillWizardState {
76
+ if (data === "escape" || data === "\x1b") return state;
77
+ if (data === "backspace" || data === "\x7f" || data === "\b") {
78
+ return editWizardField(state, (value) => value.slice(0, -1));
79
+ }
80
+ if (data === "tab" || data === "enter" || data === "\r" || data === "\n") {
81
+ return advanceWizard(state);
82
+ }
83
+ if (data.length === 1 && data >= " " && data <= "~") {
84
+ return editWizardField(state, (value) => value + data);
85
+ }
86
+ return state;
87
+ }
88
+
89
+ export function handleWizardKey(wizard: SkillWizardState, data: string): WizardInputResult {
90
+ if (data === "escape" || data === "\x1b") {
91
+ if (wizard.step === "name") return { type: "close" };
92
+ return { type: "update", wizard: retreatWizard(wizard) };
93
+ }
94
+ if ((data === "[" || data === "]") && wizard.step === "template") {
95
+ return {
96
+ type: "update",
97
+ wizard: cycleWizardTemplate(wizard, data === "]" ? 1 : -1),
98
+ };
99
+ }
100
+ if ((data === "enter" || data === "\r" || data === "\n") && wizardIsComplete(wizard)) {
101
+ return { type: "save", wizard };
102
+ }
103
+ return { type: "update", wizard: applyWizardInput(wizard, data) };
104
+ }
105
+
106
+ function editWizardField(
107
+ state: SkillWizardState,
108
+ edit: (value: string) => string,
109
+ ): SkillWizardState {
110
+ switch (state.step) {
111
+ case "name":
112
+ return { ...state, name: edit(state.name), error: null };
113
+ case "description":
114
+ return { ...state, description: edit(state.description), error: null };
115
+ case "triggers":
116
+ return { ...state, triggers: edit(state.triggers), error: null };
117
+ case "template": {
118
+ const next = edit(state.template).trim();
119
+ return {
120
+ ...state,
121
+ template: isSkillTemplateId(next) ? next : state.template,
122
+ error: null,
123
+ };
124
+ }
125
+ case "confirm":
126
+ return state;
127
+ default: {
128
+ const exhaustive: never = state.step;
129
+ return exhaustive;
130
+ }
131
+ }
132
+ }
133
+
134
+ export function cycleWizardTemplate(state: SkillWizardState, direction = 1): SkillWizardState {
135
+ const index = SKILL_TEMPLATE_IDS.indexOf(state.template);
136
+ const next =
137
+ SKILL_TEMPLATE_IDS[(index + direction + SKILL_TEMPLATE_IDS.length) % SKILL_TEMPLATE_IDS.length]!;
138
+ return { ...state, template: next, error: null };
139
+ }
140
+
141
+ export function advanceWizard(state: SkillWizardState): SkillWizardState {
142
+ if (state.step === "name") {
143
+ try {
144
+ const name = sanitizeSkillName(state.name);
145
+ return { ...state, name, error: null, step: "description" };
146
+ } catch (error) {
147
+ return {
148
+ ...state,
149
+ error: error instanceof Error ? error.message : "Invalid skill name",
150
+ };
151
+ }
152
+ }
153
+ if (state.step === "description") {
154
+ if (!state.description.trim()) {
155
+ return { ...state, error: "Description is required" };
156
+ }
157
+ return { ...state, step: "template", error: null };
158
+ }
159
+ if (state.step === "template") return { ...state, step: "triggers", error: null };
160
+ if (state.step === "triggers") return { ...state, step: "confirm", error: null };
161
+ return state;
162
+ }
163
+
164
+ export function retreatWizard(state: SkillWizardState): SkillWizardState {
165
+ const index = wizardStepIndex(state.step);
166
+ if (index <= 0) return state;
167
+ return { ...state, step: WIZARD_STEPS[index - 1]!, error: null };
168
+ }
169
+
170
+ export function wizardIsComplete(state: SkillWizardState): boolean {
171
+ return state.step === "confirm" && !state.error && Boolean(state.name);
172
+ }
173
+
174
+ export function parseSkillTriggers(name: string, body: string): string[] {
175
+ const listed: string[] = [];
176
+ const heading = body.match(/## Triggers\s*\n+([\s\S]*?)(?=\n## |\s*$)/i);
177
+ if (heading?.[1]) {
178
+ for (const line of heading[1].split("\n")) {
179
+ const item = line.replace(/^\s*[-*]\s+/, "").trim();
180
+ if (item) listed.push(item);
181
+ }
182
+ }
183
+ return listed.length > 0 ? listed : [name, `$${name}`];
184
+ }
185
+
186
+ export function composeWizardSkill(state: SkillWizardState): string {
187
+ const name = sanitizeSkillName(state.name);
188
+ const body = renderSkillTemplate(state.template, name);
189
+ const triggers = state.triggers
190
+ .split(",")
191
+ .map((item) => item.trim())
192
+ .filter(Boolean);
193
+ const triggerBlock =
194
+ triggers.length > 0
195
+ ? `\n## Triggers\n\n${triggers.map((item) => `- ${item}`).join("\n")}\n`
196
+ : "";
197
+ if (!state.description.trim()) return `${body}${triggerBlock}`;
198
+ return (
199
+ body.replace(/description: .*/, `description: ${state.description.trim()}`) + triggerBlock
200
+ );
201
+ }
202
+
203
+ export function writeWizardSkill(
204
+ state: SkillWizardState,
205
+ skillsRoot: string = getAgentPath("skills"),
206
+ ): { filePath: string } {
207
+ const name = sanitizeSkillName(state.name);
208
+ const dir = join(skillsRoot, name);
209
+ const filePath = join(dir, "SKILL.md");
210
+ if (existsSync(filePath)) {
211
+ throw new SkillNameError(`Skill already exists: ${name}`);
212
+ }
213
+ mkdirSync(dir, { recursive: true });
214
+ writeFileSync(filePath, composeWizardSkill({ ...state, name }), "utf8");
215
+ invalidateSkillCache();
216
+ return { filePath };
217
+ }
218
+
219
+ export function renderUsageSparkline(counts: number[], width = 12): string {
220
+ const blocks = [" ", "▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"];
221
+ if (width <= 0) return "";
222
+ if (counts.length === 0) return "·".repeat(width);
223
+ const series =
224
+ counts.length >= width
225
+ ? counts.slice(counts.length - width)
226
+ : [...Array(width - counts.length).fill(0), ...counts];
227
+ const max = Math.max(1, ...series);
228
+ return series
229
+ .map((value) => {
230
+ const index = Math.round((value / max) * (blocks.length - 1));
231
+ return blocks[index] ?? " ";
232
+ })
233
+ .join("");
234
+ }
235
+
236
+ export function renderSkillWorkflow(skill: WorkbenchSkill): string[] {
237
+ const health = skill.health ?? (skill.warning ? "warn" : "ok");
238
+ const triggers = skill.triggers?.length ? skill.triggers.join(", ") : "name / $skill";
239
+ return [
240
+ `discover(${skill.category}) → health(${health}) → usage(${skill.usageCount}x) → insert`,
241
+ `triggers: ${triggers}`,
242
+ ];
243
+ }
244
+
245
+ export function renderSkillWizard(theme: Theme, width: number, state: SkillWizardState): string[] {
246
+ const title = theme.fg(
247
+ "accent",
248
+ `NEW SKILL WIZARD · step ${wizardStepIndex(state.step) + 1}/${WIZARD_STEPS.length}`,
249
+ );
250
+ const lines = [
251
+ title,
252
+ `Name: ${state.name || "…"}`,
253
+ `Description: ${state.description || "…"}`,
254
+ `Template: ${state.template}`,
255
+ `Triggers: ${state.triggers || "(none)"}`,
256
+ "",
257
+ `Editing: ${state.step}`,
258
+ ];
259
+ if (state.error) lines.push(theme.fg("warning", state.error));
260
+ if (state.step === "confirm") {
261
+ lines.push("Enter saves · esc cancels");
262
+ lines.push(...composeWizardSkill(state).split("\n").slice(0, 6));
263
+ } else {
264
+ lines.push("type · enter next · [ ] template · backspace edit");
265
+ }
266
+ return lines.map((line) => truncateToWidth(line, width, "…", true));
267
+ }
268
+
269
+ export function renderSkillWorkbench(
270
+ theme: Theme,
271
+ width: number,
272
+ skills: readonly WorkbenchSkill[],
273
+ selected: number,
274
+ wizard: SkillWizardState | null,
275
+ ): string[] {
276
+ if (wizard) return renderSkillWizard(theme, width, wizard);
277
+ if (skills.length === 0) {
278
+ return [
279
+ theme.fg("warning", "No skills installed"),
280
+ "Press n or ctrl+n to open the new-skill wizard",
281
+ ].map((line) => truncateToWidth(line, width, "…", true));
282
+ }
283
+
284
+ const index = Math.max(0, Math.min(selected, skills.length - 1));
285
+ const skill = skills[index]!;
286
+ const leftW = Math.max(12, Math.floor(width * 0.32));
287
+ const midW = Math.max(16, Math.floor(width * 0.36));
288
+ const rightW = Math.max(12, width - leftW - midW - 2);
289
+ const windowSize = 8;
290
+ const start = Math.max(
291
+ 0,
292
+ Math.min(index - Math.floor(windowSize / 2), Math.max(0, skills.length - windowSize)),
293
+ );
294
+ const visible = skills.slice(start, start + windowSize);
295
+
296
+ const list = [
297
+ theme.fg(
298
+ "accent",
299
+ skills.length > windowSize ? `SKILLS ${index + 1}/${skills.length}` : "SKILLS",
300
+ ),
301
+ ...visible.map((entry, i) => {
302
+ const actual = start + i;
303
+ const marker = actual === index ? "→" : " ";
304
+ const warn = entry.warning ? " !" : "";
305
+ return `${marker} ${entry.name}${warn}`;
306
+ }),
307
+ ];
308
+ const meta = [
309
+ theme.fg("accent", "METADATA"),
310
+ skill.name,
311
+ skill.description || "(no description)",
312
+ `health: ${skill.health ?? (skill.warning ? "warn" : "ok")}`,
313
+ `usage: ${skill.usageCount}x ${renderUsageSparkline(skill.usageSeries ?? [skill.usageCount])}`,
314
+ ...renderSkillWorkflow(skill),
315
+ ];
316
+ const preview = [
317
+ theme.fg("accent", "PREVIEW"),
318
+ ...(skill.bodyPreview || "(empty body)").split("\n").slice(0, 6),
319
+ ];
320
+
321
+ const rows = Math.max(list.length, meta.length, preview.length);
322
+ const lines: string[] = [];
323
+ for (let i = 0; i < rows; i++) {
324
+ const left = padPlain(list[i] ?? "", leftW);
325
+ const mid = padPlain(meta[i] ?? "", midW);
326
+ const right = padPlain(preview[i] ?? "", rightW);
327
+ lines.push(truncateToWidth(`${left} ${mid} ${right}`, width, "…", true));
328
+ }
329
+ lines.push(truncateToWidth("n new skill · ↑↓ select · enter insert", width, "…", true));
330
+ return lines;
331
+ }
332
+
333
+ function padPlain(text: string, width: number): string {
334
+ const plain = text.replace(/\x1b\[[0-9;]*m/g, "");
335
+ if (plain.length >= width) return truncateToWidth(text, width, "…", true);
336
+ return text + " ".repeat(width - plain.length);
337
+ }
@@ -0,0 +1,227 @@
1
+ /**
2
+ * Search-first appearance catalog recovered from the vNext operator draft.
3
+ * The Deck appearance route filters this catalog; preset, motion, and level
4
+ * hits persist through the existing appearance writers.
5
+ */
6
+
7
+ import { PRESETS } from "../../../config/presets.ts";
8
+ import { getStructuralPreset } from "../../../config/structural-presets.ts";
9
+ import {
10
+ STRUCTURAL_PRESET_NAMES,
11
+ type AppearanceMixConfig,
12
+ type StructuralPresetName,
13
+ } from "../../../config/types.ts";
14
+ import { describeMotionLevel, isMotionLevel } from "../../../motion/accessibility.ts";
15
+ import { MOTION_CATALOG } from "../../../motion/catalog.ts";
16
+ import type { MotionLevel } from "../../../motion/types.ts";
17
+
18
+ export const APPEARANCE_PANES = [
19
+ "presets",
20
+ "palette",
21
+ "signal",
22
+ "glyphs",
23
+ "motion",
24
+ "layout",
25
+ "accessibility",
26
+ ] as const;
27
+
28
+ export type AppearancePane = (typeof APPEARANCE_PANES)[number];
29
+
30
+ export type AppearanceHitKind =
31
+ | "preset"
32
+ | "palette"
33
+ | "motion"
34
+ | "level"
35
+ | "layout"
36
+ | "glyphs"
37
+ | "setting";
38
+
39
+ export interface AppearanceSearchHit {
40
+ id: string;
41
+ label: string;
42
+ group: string;
43
+ keywords: string;
44
+ kind: AppearanceHitKind;
45
+ value: string;
46
+ }
47
+
48
+ const LAYOUT_PRESETS = Object.keys(PRESETS);
49
+ const LEVELS: MotionLevel[] = ["full", "reduced", "functional", "off"];
50
+
51
+ export function appearanceCatalog(): AppearanceSearchHit[] {
52
+ const hits: AppearanceSearchHit[] = [];
53
+ for (const name of STRUCTURAL_PRESET_NAMES) {
54
+ const preset = getStructuralPreset(name);
55
+ hits.push({
56
+ id: `base:${name}`,
57
+ label: `${preset.displayName} base`,
58
+ group: "Presets",
59
+ keywords: `${name} ${preset.displayName} ${preset.description} preset`,
60
+ kind: "preset",
61
+ value: name,
62
+ });
63
+ hits.push({
64
+ id: `palette:${name}`,
65
+ label: `${preset.displayName} palette`,
66
+ group: "Palette",
67
+ keywords: `${name} palette tokens colors`,
68
+ kind: "palette",
69
+ value: name,
70
+ });
71
+ }
72
+ for (const motion of MOTION_CATALOG) {
73
+ hits.push({
74
+ id: `motion:${motion.id}`,
75
+ label: motion.name,
76
+ group: "Motion",
77
+ keywords: `${motion.id} ${motion.name} ${motion.category} ${motion.description}`,
78
+ kind: "motion",
79
+ value: motion.id,
80
+ });
81
+ }
82
+ for (const level of LEVELS) {
83
+ hits.push({
84
+ id: `level:${level}`,
85
+ label: `Motion ${level}`,
86
+ group: "Accessibility",
87
+ keywords: `motion level ${level} ${describeMotionLevel(level)} accessibility`,
88
+ kind: "level",
89
+ value: level,
90
+ });
91
+ }
92
+ for (const name of LAYOUT_PRESETS) {
93
+ hits.push({
94
+ id: `layout:${name}`,
95
+ label: `Layout ${name}`,
96
+ group: "Layout",
97
+ keywords: `layout preset ${name}`,
98
+ kind: "layout",
99
+ value: name,
100
+ });
101
+ }
102
+ hits.push(
103
+ {
104
+ id: "glyphs:ascii",
105
+ label: "ASCII glyphs",
106
+ group: "Glyphs",
107
+ keywords: "glyphs ascii fallback",
108
+ kind: "glyphs",
109
+ value: "ascii",
110
+ },
111
+ {
112
+ id: "glyphs:nerd",
113
+ label: "Nerd glyphs",
114
+ group: "Glyphs",
115
+ keywords: "glyphs nerd font",
116
+ kind: "glyphs",
117
+ value: "nerd",
118
+ },
119
+ {
120
+ id: "setting:no-color",
121
+ label: "Respect NO_COLOR",
122
+ group: "Accessibility",
123
+ keywords: "no_color no color ansi",
124
+ kind: "setting",
125
+ value: "noColor",
126
+ },
127
+ );
128
+ return hits;
129
+ }
130
+
131
+ export function searchAppearanceConfig(query: string): AppearanceSearchHit[] {
132
+ const q = query.trim().toLowerCase();
133
+ const catalog = appearanceCatalog();
134
+ if (!q) return catalog;
135
+ return catalog.filter((hit) => {
136
+ const hay = `${hit.label} ${hit.group} ${hit.keywords} ${hit.value}`.toLowerCase();
137
+ return hay.includes(q);
138
+ });
139
+ }
140
+
141
+ export function applyAppearanceHit(
142
+ current: AppearanceMixConfig,
143
+ hit: AppearanceSearchHit,
144
+ ): AppearanceMixConfig {
145
+ switch (hit.kind) {
146
+ case "preset":
147
+ return { ...current, base: hit.value as StructuralPresetName };
148
+ case "palette":
149
+ return { ...current, palette: hit.value as StructuralPresetName };
150
+ case "motion":
151
+ return {
152
+ ...current,
153
+ motion: {
154
+ ...(typeof current.motion === "object" ? current.motion : {}),
155
+ streaming: hit.value,
156
+ },
157
+ };
158
+ case "glyphs":
159
+ return {
160
+ ...current,
161
+ glyphs: hit.value === "ascii" ? "vellum" : "hexforge",
162
+ };
163
+ case "level":
164
+ case "layout":
165
+ case "setting":
166
+ return current;
167
+ default: {
168
+ const exhaustive: never = hit.kind;
169
+ return exhaustive;
170
+ }
171
+ }
172
+ }
173
+
174
+ export function applyAppearanceSelection(
175
+ current: AppearanceMixConfig,
176
+ pane: AppearancePane,
177
+ value: string,
178
+ ): AppearanceMixConfig {
179
+ if ((pane === "presets" || pane === "palette" || pane === "signal" || pane === "glyphs") && isStructural(value)) {
180
+ if (pane === "presets") return { ...current, base: value };
181
+ if (pane === "palette") return { ...current, palette: value };
182
+ if (pane === "signal") return { ...current, signalLayout: value };
183
+ return { ...current, glyphs: value };
184
+ }
185
+ if (pane === "motion") {
186
+ return {
187
+ ...current,
188
+ motion: {
189
+ ...(typeof current.motion === "object" ? current.motion : {}),
190
+ streaming: value,
191
+ },
192
+ };
193
+ }
194
+ if (pane === "accessibility" && isMotionLevel(value)) return current;
195
+ if (pane === "layout") return current;
196
+ return current;
197
+ }
198
+
199
+ function isStructural(value: string): value is StructuralPresetName {
200
+ return (STRUCTURAL_PRESET_NAMES as readonly string[]).includes(value);
201
+ }
202
+
203
+ export function paneOptions(pane: AppearancePane): string[] {
204
+ switch (pane) {
205
+ case "presets":
206
+ case "palette":
207
+ case "signal":
208
+ case "glyphs":
209
+ return [...STRUCTURAL_PRESET_NAMES];
210
+ case "motion":
211
+ return MOTION_CATALOG.map((motion) => motion.id);
212
+ case "layout":
213
+ return [...LAYOUT_PRESETS];
214
+ case "accessibility":
215
+ return [...LEVELS];
216
+ default: {
217
+ const exhaustive: never = pane;
218
+ return exhaustive;
219
+ }
220
+ }
221
+ }
222
+
223
+ export function nextAppearancePane(pane: AppearancePane, direction = 1): AppearancePane {
224
+ const index = APPEARANCE_PANES.indexOf(pane);
225
+ const next = (index + direction + APPEARANCE_PANES.length) % APPEARANCE_PANES.length;
226
+ return APPEARANCE_PANES[next] ?? "presets";
227
+ }