@haruhimemoe/ui 0.6.0 → 0.8.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.
Files changed (70) hide show
  1. package/CHANGELOG.md +29 -1
  2. package/README.md +143 -33
  3. package/dist/components/basics/Disclosure.d.ts +1 -1
  4. package/dist/components/basics/Disclosure.js +2 -2
  5. package/dist/components/filters/RangeSlider.d.ts +1 -1
  6. package/dist/components/filters/RangeSlider.js +6 -6
  7. package/dist/components/filters/chipStyles.d.ts +2 -2
  8. package/dist/components/filters/chipStyles.js +3 -2
  9. package/dist/components/forms/CharCounter.d.ts +4 -2
  10. package/dist/components/forms/CharCounter.js +5 -3
  11. package/dist/components/forms/Checkbox.d.ts +1 -1
  12. package/dist/components/forms/Checkbox.js +3 -1
  13. package/dist/components/forms/FieldFrame.d.ts +1 -1
  14. package/dist/components/forms/FieldFrame.js +1 -1
  15. package/dist/components/forms/RadioGroup.d.ts +1 -1
  16. package/dist/components/forms/RadioGroup.js +4 -2
  17. package/dist/components/forms/ReportDisclosure.d.ts +1 -1
  18. package/dist/components/forms/ReportDisclosure.js +10 -6
  19. package/dist/components/forms/fieldStyles.d.ts +5 -6
  20. package/dist/components/forms/fieldStyles.js +6 -7
  21. package/dist/components/osu/BeatmapStats.d.ts +1 -1
  22. package/dist/components/osu/BeatmapStats.js +2 -1
  23. package/dist/components/osu/starColors.d.ts +14 -4
  24. package/dist/components/osu/starColors.js +39 -2
  25. package/dist/components/palette/CommandPalette.d.ts +21 -0
  26. package/dist/components/palette/CommandPalette.js +315 -0
  27. package/dist/components/palette/CommandPaletteButton.d.ts +21 -0
  28. package/dist/components/palette/CommandPaletteButton.js +26 -0
  29. package/dist/components/palette/PaletteFooter.d.ts +21 -0
  30. package/dist/components/palette/PaletteFooter.js +10 -0
  31. package/dist/components/palette/PaletteInput.d.ts +30 -0
  32. package/dist/components/palette/PaletteInput.js +23 -0
  33. package/dist/components/palette/PaletteList.d.ts +29 -0
  34. package/dist/components/palette/PaletteList.js +44 -0
  35. package/dist/components/palette/PaletteRow.d.ts +30 -0
  36. package/dist/components/palette/PaletteRow.js +33 -0
  37. package/dist/components/palette/calc.d.ts +28 -0
  38. package/dist/components/palette/calc.js +226 -0
  39. package/dist/components/palette/fuzzy.d.ts +51 -0
  40. package/dist/components/palette/fuzzy.js +138 -0
  41. package/dist/components/palette/hotkeys.d.ts +57 -0
  42. package/dist/components/palette/hotkeys.js +111 -0
  43. package/dist/components/palette/paletteEvents.d.ts +22 -0
  44. package/dist/components/palette/paletteEvents.js +22 -0
  45. package/dist/components/palette/platform.d.ts +13 -0
  46. package/dist/components/palette/platform.js +21 -0
  47. package/dist/components/palette/recents.d.ts +48 -0
  48. package/dist/components/palette/recents.js +85 -0
  49. package/dist/components/palette/rows.d.ts +63 -0
  50. package/dist/components/palette/rows.js +136 -0
  51. package/dist/components/palette/siteCommands.d.ts +38 -0
  52. package/dist/components/palette/siteCommands.js +152 -0
  53. package/dist/components/palette/store.d.ts +94 -0
  54. package/dist/components/palette/store.js +135 -0
  55. package/dist/components/palette/types.d.ts +102 -0
  56. package/dist/components/palette/types.js +11 -0
  57. package/dist/components/palette/useProviderSearch.d.ts +28 -0
  58. package/dist/components/palette/useProviderSearch.js +68 -0
  59. package/dist/components/shell/HeaderMenu.d.ts +1 -1
  60. package/dist/components/shell/HeaderMenu.js +2 -2
  61. package/dist/components/shell/NavItem.d.ts +1 -1
  62. package/dist/components/shell/NavItem.js +2 -2
  63. package/dist/components/shell/SiteFooter.d.ts +9 -4
  64. package/dist/components/shell/SiteFooter.js +4 -2
  65. package/dist/components/tables/Table.d.ts +5 -3
  66. package/dist/components/tables/Table.js +15 -2
  67. package/dist/index.d.ts +8 -1
  68. package/dist/index.js +8 -1
  69. package/dist/theme.css +4 -4
  70. package/package.json +8 -3
@@ -0,0 +1,57 @@
1
+ /**
2
+ * @file src/components/palette/hotkeys.ts
3
+ * @desc Shortcut text ("mod+k", "?", a chord "g p") parsed, matched against keydown events and
4
+ * shown as kbd parts. `mod` is Command on a Mac (platform.ts decides) and Control
5
+ * elsewhere. A chord is two bare keys in a row; CommandPalette times it. Editable targets
6
+ * (fields, contenteditable, a dialog that isn't the palette) never fire command shortcuts.
7
+ * Pure: no directive, so server-safe files (PaletteRow) can import it.
8
+ * @author David @dvhsh (https://dvh.sh)
9
+ * @created Sat Oct 3, 2026
10
+ * @modified Sat Oct 3, 2026
11
+ */
12
+ /** A key with modifiers. `key` is lower-case, compared to `event.key`. */
13
+ export type Combo = {
14
+ key: string;
15
+ mod: boolean;
16
+ shift: boolean;
17
+ alt: boolean;
18
+ ctrl: boolean;
19
+ meta: boolean;
20
+ };
21
+ /** A combo, or a chord of two bare keys pressed in turn. */
22
+ export type Shortcut = {
23
+ kind: "combo";
24
+ combo: Combo;
25
+ } | {
26
+ kind: "chord";
27
+ keys: readonly [string, string];
28
+ };
29
+ /**
30
+ * @function parseShortcut
31
+ * @param text {string} "mod+shift+c", "?" or "g p"
32
+ * @returns {Shortcut | null} the parsed shortcut, or null for text that isn't one
33
+ */
34
+ export declare function parseShortcut(text: string): Shortcut | null;
35
+ /**
36
+ * @function matchesCombo
37
+ * @param event {KeyboardEvent} a keydown
38
+ * @param combo {Combo} the parsed combo
39
+ * @param mac {boolean} whether `mod` means Meta
40
+ * @returns {boolean} true when the key and the exact modifier set match. Shift is not required
41
+ * for a symbol key such as "?", which needs it on most layouts.
42
+ */
43
+ export declare function matchesCombo(event: KeyboardEvent, combo: Combo, mac: boolean): boolean;
44
+ /**
45
+ * @function isEditableTarget
46
+ * @param target {EventTarget | null} the keydown's target
47
+ * @returns {boolean} true inside an input, textarea, select, contenteditable, or a `<dialog>`
48
+ * without `data-palette`
49
+ */
50
+ export declare function isEditableTarget(target: EventTarget | null): boolean;
51
+ /**
52
+ * @function displayShortcut
53
+ * @param shortcut {Shortcut} a parsed shortcut
54
+ * @param mac {boolean} symbols (⌘ ⇧ ⌥ ⌃) on a Mac, words elsewhere
55
+ * @returns {string[]} one entry per `<kbd>`, in order
56
+ */
57
+ export declare function displayShortcut(shortcut: Shortcut, mac: boolean): string[];
@@ -0,0 +1,111 @@
1
+ /**
2
+ * @file src/components/palette/hotkeys.ts
3
+ * @desc Shortcut text ("mod+k", "?", a chord "g p") parsed, matched against keydown events and
4
+ * shown as kbd parts. `mod` is Command on a Mac (platform.ts decides) and Control
5
+ * elsewhere. A chord is two bare keys in a row; CommandPalette times it. Editable targets
6
+ * (fields, contenteditable, a dialog that isn't the palette) never fire command shortcuts.
7
+ * Pure: no directive, so server-safe files (PaletteRow) can import it.
8
+ * @author David @dvhsh (https://dvh.sh)
9
+ * @created Sat Oct 3, 2026
10
+ * @modified Sat Oct 3, 2026
11
+ */
12
+ const MODIFIERS = new Set(["mod", "shift", "alt", "ctrl", "meta"]);
13
+ /**
14
+ * @function parseShortcut
15
+ * @param text {string} "mod+shift+c", "?" or "g p"
16
+ * @returns {Shortcut | null} the parsed shortcut, or null for text that isn't one
17
+ */
18
+ export function parseShortcut(text) {
19
+ const trimmed = text.trim().toLowerCase();
20
+ if (trimmed.length === 0)
21
+ return null;
22
+ const chord = trimmed.split(/\s+/);
23
+ if (chord.length === 2 && chord.every((k) => k.length === 1)) {
24
+ return { kind: "chord", keys: [chord[0], chord[1]] };
25
+ }
26
+ if (chord.length !== 1)
27
+ return null;
28
+ const parts = trimmed.split("+");
29
+ const key = parts.pop();
30
+ if (!key || parts.some((part) => !MODIFIERS.has(part)))
31
+ return null;
32
+ const has = (name) => parts.includes(name);
33
+ return {
34
+ kind: "combo",
35
+ combo: {
36
+ key,
37
+ mod: has("mod"),
38
+ shift: has("shift"),
39
+ alt: has("alt"),
40
+ ctrl: has("ctrl"),
41
+ meta: has("meta"),
42
+ },
43
+ };
44
+ }
45
+ const isLetterOrDigit = (key) => /^[a-z0-9]$/i.test(key);
46
+ /**
47
+ * @function matchesCombo
48
+ * @param event {KeyboardEvent} a keydown
49
+ * @param combo {Combo} the parsed combo
50
+ * @param mac {boolean} whether `mod` means Meta
51
+ * @returns {boolean} true when the key and the exact modifier set match. Shift is not required
52
+ * for a symbol key such as "?", which needs it on most layouts.
53
+ */
54
+ export function matchesCombo(event, combo, mac) {
55
+ if (event.key.toLowerCase() !== combo.key)
56
+ return false;
57
+ const wantMeta = combo.meta || (combo.mod && mac);
58
+ const wantCtrl = combo.ctrl || (combo.mod && !mac);
59
+ if (event.metaKey !== wantMeta || event.ctrlKey !== wantCtrl || event.altKey !== combo.alt)
60
+ return false;
61
+ if (combo.key.length === 1 && !isLetterOrDigit(combo.key) && !combo.shift)
62
+ return true;
63
+ return event.shiftKey === combo.shift;
64
+ }
65
+ /**
66
+ * @function isEditableTarget
67
+ * @param target {EventTarget | null} the keydown's target
68
+ * @returns {boolean} true inside an input, textarea, select, contenteditable, or a `<dialog>`
69
+ * without `data-palette`
70
+ */
71
+ export function isEditableTarget(target) {
72
+ if (!(target instanceof Element))
73
+ return false;
74
+ const tag = target.tagName;
75
+ if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT")
76
+ return true;
77
+ if (target instanceof HTMLElement && target.isContentEditable)
78
+ return true;
79
+ const dialog = target.closest("dialog");
80
+ return dialog !== null && !dialog.hasAttribute("data-palette");
81
+ }
82
+ const KEY_NAMES = {
83
+ arrowup: "↑",
84
+ arrowdown: "↓",
85
+ enter: "↵",
86
+ escape: "Esc",
87
+ backspace: "⌫",
88
+ };
89
+ /**
90
+ * @function displayShortcut
91
+ * @param shortcut {Shortcut} a parsed shortcut
92
+ * @param mac {boolean} symbols (⌘ ⇧ ⌥ ⌃) on a Mac, words elsewhere
93
+ * @returns {string[]} one entry per `<kbd>`, in order
94
+ */
95
+ export function displayShortcut(shortcut, mac) {
96
+ const name = (key) => KEY_NAMES[key] ?? key.toUpperCase();
97
+ if (shortcut.kind === "chord")
98
+ return shortcut.keys.map(name);
99
+ const { combo } = shortcut;
100
+ const parts = [];
101
+ if (combo.ctrl || (combo.mod && !mac))
102
+ parts.push(mac ? "⌃" : "Ctrl");
103
+ if (combo.alt)
104
+ parts.push(mac ? "⌥" : "Alt");
105
+ if (combo.shift)
106
+ parts.push(mac ? "⇧" : "Shift");
107
+ if (combo.meta || (combo.mod && mac))
108
+ parts.push(mac ? "⌘" : "Win");
109
+ parts.push(name(combo.key));
110
+ return parts;
111
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * @file src/components/palette/paletteEvents.ts
3
+ * @desc How anything opens the mounted CommandPalette without a context provider: a CustomEvent
4
+ * on window that the palette listens for. `CommandPaletteButton` and app code call
5
+ * `openCommandPalette`, optionally with a page to open onto.
6
+ * @author David @dvhsh (https://dvh.sh)
7
+ * @created Sat Oct 3, 2026
8
+ * @modified Sat Oct 3, 2026
9
+ */
10
+ import type { Page } from "./types.js";
11
+ /** The event name CommandPalette listens for on window. */
12
+ export declare const PALETTE_EVENT = "haruhime:palette";
13
+ /** The event's detail. */
14
+ export type PaletteEventDetail = {
15
+ page?: Page | undefined;
16
+ };
17
+ /**
18
+ * @function openCommandPalette
19
+ * @param page {Page} a page to open straight onto, above the root
20
+ * @returns {void}
21
+ */
22
+ export declare function openCommandPalette(page?: Page): void;
@@ -0,0 +1,22 @@
1
+ /**
2
+ * @file src/components/palette/paletteEvents.ts
3
+ * @desc How anything opens the mounted CommandPalette without a context provider: a CustomEvent
4
+ * on window that the palette listens for. `CommandPaletteButton` and app code call
5
+ * `openCommandPalette`, optionally with a page to open onto.
6
+ * @author David @dvhsh (https://dvh.sh)
7
+ * @created Sat Oct 3, 2026
8
+ * @modified Sat Oct 3, 2026
9
+ */
10
+ "use client";
11
+ /** The event name CommandPalette listens for on window. */
12
+ export const PALETTE_EVENT = "haruhime:palette";
13
+ /**
14
+ * @function openCommandPalette
15
+ * @param page {Page} a page to open straight onto, above the root
16
+ * @returns {void}
17
+ */
18
+ export function openCommandPalette(page) {
19
+ if (typeof window === "undefined")
20
+ return;
21
+ window.dispatchEvent(new CustomEvent(PALETTE_EVENT, { detail: { page } }));
22
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * @file src/components/palette/platform.ts
3
+ * @desc Whether this is a Mac, so `mod` means Command and hints say ⌘. Client-only (reads
4
+ * navigator); the palette and its button call it after mount.
5
+ * @author David @dvhsh (https://dvh.sh)
6
+ * @created Sat Oct 3, 2026
7
+ * @modified Sat Oct 3, 2026
8
+ */
9
+ /**
10
+ * @function isMac
11
+ * @returns {boolean} true on macOS, from `userAgentData.platform` or `navigator.platform`
12
+ */
13
+ export declare function isMac(): boolean;
@@ -0,0 +1,21 @@
1
+ /**
2
+ * @file src/components/palette/platform.ts
3
+ * @desc Whether this is a Mac, so `mod` means Command and hints say ⌘. Client-only (reads
4
+ * navigator); the palette and its button call it after mount.
5
+ * @author David @dvhsh (https://dvh.sh)
6
+ * @created Sat Oct 3, 2026
7
+ * @modified Sat Oct 3, 2026
8
+ */
9
+ "use client";
10
+ /**
11
+ * @function isMac
12
+ * @returns {boolean} true on macOS, from `userAgentData.platform` or `navigator.platform`
13
+ */
14
+ export function isMac() {
15
+ if (typeof navigator === "undefined")
16
+ return false;
17
+ const data = navigator.userAgentData;
18
+ if (data?.platform)
19
+ return data.platform === "macOS";
20
+ return /^Mac/.test(navigator.platform ?? "");
21
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * @file src/components/palette/recents.ts
3
+ * @desc Which commands get used, in localStorage under "haruhime:palette:<key>": run count and
4
+ * last-use time per command id, pruned to the 50 most recent. Every read and write is
5
+ * guarded, so a blocked or full store means no recents, never an error.
6
+ * @author David @dvhsh (https://dvh.sh)
7
+ * @created Sat Oct 3, 2026
8
+ * @modified Sat Oct 3, 2026
9
+ */
10
+ /** Per command id: how many runs and when the last one was (ms since epoch). */
11
+ export type Recents = Record<string, {
12
+ n: number;
13
+ t: number;
14
+ }>;
15
+ /**
16
+ * @function storageKeyFor
17
+ * @param key {string} the app's namespace (CommandPalette's `storageKey`)
18
+ * @returns {string} the localStorage key
19
+ */
20
+ export declare const storageKeyFor: (key: string) => string;
21
+ /**
22
+ * @function readRecents
23
+ * @param key {string} the app's namespace
24
+ * @returns {Recents} the stored recents, dropping entries that aren't `{ n, t }`; empty when
25
+ * storage is missing, throws, or holds something that isn't JSON
26
+ */
27
+ export declare function readRecents(key: string): Recents;
28
+ /**
29
+ * @function recordRecent
30
+ * @param key {string} the app's namespace
31
+ * @param id {string} the command that ran
32
+ * @param now {number} the time, default Date.now()
33
+ * @returns {Recents} the recents after the write (empty when storage is unavailable)
34
+ */
35
+ export declare function recordRecent(key: string, id: string, now?: number): Recents;
36
+ /**
37
+ * @function recentIds
38
+ * @param recents {Recents} the stored recents
39
+ * @param limit {number} how many, default 5
40
+ * @returns {string[]} ids by last use, newest first
41
+ */
42
+ export declare const recentIds: (recents: Recents, limit?: number) => string[];
43
+ /**
44
+ * @function boostFrom
45
+ * @param recents {Recents} the stored recents
46
+ * @returns {(id: string) => number} the run count per id, 0 for unknown ids (rankResults' boost)
47
+ */
48
+ export declare const boostFrom: (recents: Recents) => (id: string) => number;
@@ -0,0 +1,85 @@
1
+ /**
2
+ * @file src/components/palette/recents.ts
3
+ * @desc Which commands get used, in localStorage under "haruhime:palette:<key>": run count and
4
+ * last-use time per command id, pruned to the 50 most recent. Every read and write is
5
+ * guarded, so a blocked or full store means no recents, never an error.
6
+ * @author David @dvhsh (https://dvh.sh)
7
+ * @created Sat Oct 3, 2026
8
+ * @modified Sat Oct 3, 2026
9
+ */
10
+ const LIMIT = 50;
11
+ /**
12
+ * @function storageKeyFor
13
+ * @param key {string} the app's namespace (CommandPalette's `storageKey`)
14
+ * @returns {string} the localStorage key
15
+ */
16
+ export const storageKeyFor = (key) => `haruhime:palette:${key}`;
17
+ const isEntry = (value) => typeof value === "object" &&
18
+ value !== null &&
19
+ typeof value.n === "number" &&
20
+ typeof value.t === "number";
21
+ /**
22
+ * @function readRecents
23
+ * @param key {string} the app's namespace
24
+ * @returns {Recents} the stored recents, dropping entries that aren't `{ n, t }`; empty when
25
+ * storage is missing, throws, or holds something that isn't JSON
26
+ */
27
+ export function readRecents(key) {
28
+ try {
29
+ const raw = localStorage.getItem(storageKeyFor(key));
30
+ if (!raw)
31
+ return {};
32
+ const parsed = JSON.parse(raw);
33
+ if (typeof parsed !== "object" || parsed === null)
34
+ return {};
35
+ return Object.fromEntries(Object.entries(parsed).filter(([, value]) => isEntry(value)));
36
+ }
37
+ catch {
38
+ return {};
39
+ }
40
+ }
41
+ /**
42
+ * @function recordRecent
43
+ * @param key {string} the app's namespace
44
+ * @param id {string} the command that ran
45
+ * @param now {number} the time, default Date.now()
46
+ * @returns {Recents} the recents after the write (empty when storage is unavailable)
47
+ */
48
+ export function recordRecent(key, id, now = Date.now()) {
49
+ // Storage that can't be read is treated as absent: nothing is written over it either.
50
+ try {
51
+ localStorage.getItem(storageKeyFor(key));
52
+ }
53
+ catch {
54
+ return {};
55
+ }
56
+ const recents = readRecents(key);
57
+ const entry = recents[id];
58
+ recents[id] = { n: (entry?.n ?? 0) + 1, t: now };
59
+ const kept = Object.fromEntries(Object.entries(recents)
60
+ .sort(([, a], [, b]) => b.t - a.t)
61
+ .slice(0, LIMIT));
62
+ try {
63
+ localStorage.setItem(storageKeyFor(key), JSON.stringify(kept));
64
+ return kept;
65
+ }
66
+ catch {
67
+ return {};
68
+ }
69
+ }
70
+ /**
71
+ * @function recentIds
72
+ * @param recents {Recents} the stored recents
73
+ * @param limit {number} how many, default 5
74
+ * @returns {string[]} ids by last use, newest first
75
+ */
76
+ export const recentIds = (recents, limit = 5) => Object.entries(recents)
77
+ .sort(([, a], [, b]) => b.t - a.t)
78
+ .slice(0, limit)
79
+ .map(([id]) => id);
80
+ /**
81
+ * @function boostFrom
82
+ * @param recents {Recents} the stored recents
83
+ * @returns {(id: string) => number} the run count per id, 0 for unknown ids (rankResults' boost)
84
+ */
85
+ export const boostFrom = (recents) => (id) => recents[id]?.n ?? 0;
@@ -0,0 +1,63 @@
1
+ /**
2
+ * @file src/components/palette/rows.ts
3
+ * @desc What the list shows for a state, pure: option rows (a command under a group heading,
4
+ * with the title's matched spans) and hint rows (not selectable: provider states, no
5
+ * matches). Empty query: Recent, then every group in declaration order. A query: the
6
+ * calculator row at the root, the ranked static matches, then each provider's rows or its
7
+ * state. In a choice prompt: the choices, fuzzy-filtered.
8
+ * @author David @dvhsh (https://dvh.sh)
9
+ * @created Sat Oct 3, 2026
10
+ * @modified Sat Oct 3, 2026
11
+ */
12
+ import { type FuzzyMatch } from "./fuzzy.js";
13
+ import type { ArgState, ProviderResult } from "./store.js";
14
+ import type { Command, PaletteContext, Provider } from "./types.js";
15
+ /** One list entry. Options are selectable; hints are text. */
16
+ export type Row = {
17
+ kind: "option";
18
+ id: string;
19
+ command: Command;
20
+ ranges: FuzzyMatch["ranges"];
21
+ group: string;
22
+ } | {
23
+ kind: "hint";
24
+ id: string;
25
+ text: string;
26
+ };
27
+ /** What buildRows needs from the palette. */
28
+ export type RowsInput = {
29
+ query: string;
30
+ commands: readonly Command[];
31
+ providers: readonly Provider[];
32
+ providerResults: Record<string, ProviderResult>;
33
+ recentIds: readonly string[];
34
+ boost: (id: string) => number;
35
+ isRoot: boolean;
36
+ calculator: boolean;
37
+ arg: ArgState | null;
38
+ ctx: PaletteContext;
39
+ };
40
+ /** The id of the calculator's row and command. */
41
+ export declare const CALC_ID = "calc";
42
+ /**
43
+ * @function buildRows
44
+ * @param input {RowsInput} the query, commands, providers and their results, recents, and the
45
+ * argument prompt in progress
46
+ * @returns {Row[]} the list, top to bottom
47
+ */
48
+ export declare function buildRows(input: RowsInput): Row[];
49
+ /**
50
+ * @function optionCount
51
+ * @param rows {readonly Row[]} the list
52
+ * @returns {number} how many rows can be selected
53
+ */
54
+ export declare const optionCount: (rows: readonly Row[]) => number;
55
+ /**
56
+ * @function optionAt
57
+ * @param rows {readonly Row[]} the list
58
+ * @param index {number} the active index, counting options only
59
+ * @returns {Extract<Row, { kind: "option" }> | undefined} that option
60
+ */
61
+ export declare const optionAt: (rows: readonly Row[], index: number) => Extract<Row, {
62
+ kind: "option";
63
+ }> | undefined;
@@ -0,0 +1,136 @@
1
+ /**
2
+ * @file src/components/palette/rows.ts
3
+ * @desc What the list shows for a state, pure: option rows (a command under a group heading,
4
+ * with the title's matched spans) and hint rows (not selectable: provider states, no
5
+ * matches). Empty query: Recent, then every group in declaration order. A query: the
6
+ * calculator row at the root, the ranked static matches, then each provider's rows or its
7
+ * state. In a choice prompt: the choices, fuzzy-filtered.
8
+ * @author David @dvhsh (https://dvh.sh)
9
+ * @created Sat Oct 3, 2026
10
+ * @modified Sat Oct 3, 2026
11
+ */
12
+ import { evaluate, formatResult, isBareNumber } from "./calc.js";
13
+ import { rankResults } from "./fuzzy.js";
14
+ /** The id of the calculator's row and command. */
15
+ export const CALC_ID = "calc";
16
+ const DEFAULT_GROUP = "Commands";
17
+ const DEFAULT_MIN = 2;
18
+ const option = (command, group, ranges = [], prefix = "") => ({
19
+ kind: "option",
20
+ id: `${prefix}${group}/${command.id}`,
21
+ command,
22
+ ranges,
23
+ group,
24
+ });
25
+ const hint = (text, scope = "") => ({
26
+ kind: "hint",
27
+ id: `hint:${scope}${text}`,
28
+ text,
29
+ });
30
+ const visible = (commands, ctx) => commands.filter((command) => {
31
+ if (!command.when)
32
+ return true;
33
+ try {
34
+ return command.when(ctx);
35
+ }
36
+ catch (error) {
37
+ console.error(`CommandPalette: "${command.id}" when() threw`, error);
38
+ return false;
39
+ }
40
+ });
41
+ const choiceRows = (arg) => {
42
+ const spec = arg.command.args?.[arg.index];
43
+ if (spec?.type !== "choice")
44
+ return [];
45
+ const group = spec.label;
46
+ const rows = (spec.choices ?? []).map((choice) => ({
47
+ id: choice.value,
48
+ title: choice.label,
49
+ subtitle: choice.subtitle,
50
+ }));
51
+ if (arg.query.trim() === "")
52
+ return rows.map((row) => option(row, group));
53
+ return rankResults(arg.query, rows, () => 0).map((scored) => option(scored.command, group, scored.ranges));
54
+ };
55
+ // A bare word ("e", "pi") is a search, not a sum, even though the calculator knows it.
56
+ const BARE_WORD = /^[a-z]+$/i;
57
+ const calcRow = (query) => {
58
+ if (isBareNumber(query) || BARE_WORD.test(query))
59
+ return null;
60
+ const value = evaluate(query);
61
+ if (value === null)
62
+ return null;
63
+ const text = formatResult(value);
64
+ const command = {
65
+ id: CALC_ID,
66
+ title: `= ${text}`,
67
+ subtitle: "Enter to copy",
68
+ closeOnRun: false,
69
+ run: (ctx) => ctx.copy(text),
70
+ };
71
+ return option(command, "Calculator");
72
+ };
73
+ const providerRows = (input) => input.providers.flatMap((provider) => {
74
+ const group = provider.group ?? "Results";
75
+ const min = provider.minLength ?? DEFAULT_MIN;
76
+ const scope = `${provider.id}:`;
77
+ if (input.query.trim().length < min)
78
+ return [hint(`Type ${min} characters to search`, scope)];
79
+ const result = input.providerResults[provider.id];
80
+ const rows = (result?.rows ?? []).map((command) => option(command, group, [], scope));
81
+ // Searching: the last rows stay under the hint, so the list doesn't flash per keystroke.
82
+ if (!result || result.state === "loading")
83
+ return [hint("Searching…", scope), ...rows];
84
+ if (result.state === "error")
85
+ return [hint("Couldn't search, try again", scope)];
86
+ if (rows.length === 0)
87
+ return [hint(`No results for “${input.query.trim()}”`, scope)];
88
+ return rows;
89
+ });
90
+ /**
91
+ * @function buildRows
92
+ * @param input {RowsInput} the query, commands, providers and their results, recents, and the
93
+ * argument prompt in progress
94
+ * @returns {Row[]} the list, top to bottom
95
+ */
96
+ export function buildRows(input) {
97
+ if (input.arg)
98
+ return choiceRows(input.arg);
99
+ const commands = visible(input.commands, input.ctx);
100
+ const query = input.query.trim();
101
+ if (query === "") {
102
+ const recent = input.recentIds
103
+ .map((id) => commands.find((command) => command.id === id))
104
+ .filter((command) => command !== undefined)
105
+ .map((command) => option(command, "Recent", [], "recent:"));
106
+ return [
107
+ ...recent,
108
+ ...commands.map((command) => option(command, command.group ?? DEFAULT_GROUP)),
109
+ ];
110
+ }
111
+ const rows = [];
112
+ const calc = input.isRoot && input.calculator ? calcRow(query) : null;
113
+ if (calc)
114
+ rows.push(calc);
115
+ for (const scored of rankResults(query, commands, input.boost)) {
116
+ rows.push(option(scored.command, scored.command.group ?? DEFAULT_GROUP, scored.ranges));
117
+ }
118
+ rows.push(...providerRows(input));
119
+ if (!rows.some((row) => row.kind === "option") && input.providers.length === 0) {
120
+ rows.push(hint("No matching commands"));
121
+ }
122
+ return rows;
123
+ }
124
+ /**
125
+ * @function optionCount
126
+ * @param rows {readonly Row[]} the list
127
+ * @returns {number} how many rows can be selected
128
+ */
129
+ export const optionCount = (rows) => rows.filter((row) => row.kind === "option").length;
130
+ /**
131
+ * @function optionAt
132
+ * @param rows {readonly Row[]} the list
133
+ * @param index {number} the active index, counting options only
134
+ * @returns {Extract<Row, { kind: "option" }> | undefined} that option
135
+ */
136
+ export const optionAt = (rows, index) => rows.filter((row) => row.kind === "option")[index];
@@ -0,0 +1,38 @@
1
+ /**
2
+ * @file src/components/palette/siteCommands.ts
3
+ * @desc The default command set every tool gets: "Go to <page>" from the nav items, "Open
4
+ * <tool>" from HARUHIME_TOOLS, page actions (copy URL, back, top, reload, GitHub), account
5
+ * (sign in, my account, sign out by signedIn), and help (the shortcuts page, report a bug).
6
+ * Ids are stable ("site.copy-url") so recents survive relabeling. Client-only: it opens
7
+ * tabs and scrolls.
8
+ * @author David @dvhsh (https://dvh.sh)
9
+ * @created Sat Oct 3, 2026
10
+ * @modified Sat Oct 3, 2026
11
+ */
12
+ import { type HaruhimeToolId } from "../shell/haruhimeTools.js";
13
+ import type { SiteLinkItem } from "../shell/links.js";
14
+ import type { Command } from "./types.js";
15
+ /** Which default groups to build and what they need. */
16
+ export type SiteCommandsOptions = {
17
+ /** The nav items the app passes to SiteHeader; one "Go to <label>" per linked item. */
18
+ pages?: readonly SiteLinkItem[] | undefined;
19
+ /** "Open <tool>" for every other live tool plus haruhime.moe. The current tool is skipped. false leaves them out. */
20
+ tools?: HaruhimeToolId | false | undefined;
21
+ /** The app's repo URL: "Open on GitHub" and "Report a bug". */
22
+ repo?: string | undefined;
23
+ account?: {
24
+ signedIn: boolean;
25
+ signInHref: string;
26
+ accountHref?: string | undefined;
27
+ /** Navigated, not fetched (next-kit's sign-out route). */
28
+ signOutHref?: string | undefined;
29
+ } | undefined;
30
+ /** Default: every group. */
31
+ include?: readonly ("navigate" | "page" | "account" | "help")[] | undefined;
32
+ };
33
+ /**
34
+ * @function siteCommands
35
+ * @param options {SiteCommandsOptions} pages, tools, repo, account and which groups to include
36
+ * @returns {Command[]} the defaults in order: Navigate, Page, Account, Help
37
+ */
38
+ export declare function siteCommands(options?: SiteCommandsOptions): Command[];