@haruhimemoe/ui 0.7.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 (46) hide show
  1. package/CHANGELOG.md +16 -1
  2. package/README.md +114 -11
  3. package/dist/components/basics/Disclosure.d.ts +1 -1
  4. package/dist/components/basics/Disclosure.js +2 -2
  5. package/dist/components/forms/Checkbox.d.ts +1 -1
  6. package/dist/components/forms/Checkbox.js +3 -1
  7. package/dist/components/forms/RadioGroup.js +3 -1
  8. package/dist/components/palette/CommandPalette.d.ts +21 -0
  9. package/dist/components/palette/CommandPalette.js +315 -0
  10. package/dist/components/palette/CommandPaletteButton.d.ts +21 -0
  11. package/dist/components/palette/CommandPaletteButton.js +26 -0
  12. package/dist/components/palette/PaletteFooter.d.ts +21 -0
  13. package/dist/components/palette/PaletteFooter.js +10 -0
  14. package/dist/components/palette/PaletteInput.d.ts +30 -0
  15. package/dist/components/palette/PaletteInput.js +23 -0
  16. package/dist/components/palette/PaletteList.d.ts +29 -0
  17. package/dist/components/palette/PaletteList.js +44 -0
  18. package/dist/components/palette/PaletteRow.d.ts +30 -0
  19. package/dist/components/palette/PaletteRow.js +33 -0
  20. package/dist/components/palette/calc.d.ts +28 -0
  21. package/dist/components/palette/calc.js +226 -0
  22. package/dist/components/palette/fuzzy.d.ts +51 -0
  23. package/dist/components/palette/fuzzy.js +138 -0
  24. package/dist/components/palette/hotkeys.d.ts +57 -0
  25. package/dist/components/palette/hotkeys.js +111 -0
  26. package/dist/components/palette/paletteEvents.d.ts +22 -0
  27. package/dist/components/palette/paletteEvents.js +22 -0
  28. package/dist/components/palette/platform.d.ts +13 -0
  29. package/dist/components/palette/platform.js +21 -0
  30. package/dist/components/palette/recents.d.ts +48 -0
  31. package/dist/components/palette/recents.js +85 -0
  32. package/dist/components/palette/rows.d.ts +63 -0
  33. package/dist/components/palette/rows.js +136 -0
  34. package/dist/components/palette/siteCommands.d.ts +38 -0
  35. package/dist/components/palette/siteCommands.js +152 -0
  36. package/dist/components/palette/store.d.ts +94 -0
  37. package/dist/components/palette/store.js +135 -0
  38. package/dist/components/palette/types.d.ts +102 -0
  39. package/dist/components/palette/types.js +11 -0
  40. package/dist/components/palette/useProviderSearch.d.ts +28 -0
  41. package/dist/components/palette/useProviderSearch.js +68 -0
  42. package/dist/components/shell/HeaderMenu.d.ts +1 -1
  43. package/dist/components/shell/HeaderMenu.js +2 -2
  44. package/dist/index.d.ts +8 -1
  45. package/dist/index.js +8 -1
  46. package/package.json +8 -3
@@ -0,0 +1,226 @@
1
+ /**
2
+ * @file src/components/palette/calc.ts
3
+ * @desc The palette's calculator, pure and without eval: a tokenizer and the shunting-yard
4
+ * algorithm over + - * / % ^, unary minus, parentheses, k and m number suffixes, pi and e,
5
+ * and sqrt, abs, round, floor, ceil, min and max. Anything it can't parse, and any result
6
+ * that isn't finite (division by zero), is null.
7
+ * @author David @dvhsh (https://dvh.sh)
8
+ * @created Sat Oct 3, 2026
9
+ * @modified Sat Oct 3, 2026
10
+ */
11
+ const CONSTANTS = { pi: Math.PI, e: Math.E };
12
+ const FUNCTIONS = {
13
+ sqrt: ([x]) => (x === undefined ? undefined : Math.sqrt(x)),
14
+ abs: ([x]) => (x === undefined ? undefined : Math.abs(x)),
15
+ round: ([x]) => (x === undefined ? undefined : Math.round(x)),
16
+ floor: ([x]) => (x === undefined ? undefined : Math.floor(x)),
17
+ ceil: ([x]) => (x === undefined ? undefined : Math.ceil(x)),
18
+ min: (args) => (args.length === 0 ? undefined : Math.min(...args)),
19
+ max: (args) => (args.length === 0 ? undefined : Math.max(...args)),
20
+ };
21
+ const SUFFIX = { k: 1e3, m: 1e6 };
22
+ const NUMBER = /^(\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?([km])?/i;
23
+ const IDENT = /^[a-z]+/i;
24
+ const tokenize = (expression) => {
25
+ const tokens = [];
26
+ let rest = expression.trim();
27
+ while (rest.length > 0) {
28
+ const space = /^\s+/.exec(rest);
29
+ if (space) {
30
+ rest = rest.slice(space[0].length);
31
+ continue;
32
+ }
33
+ const num = NUMBER.exec(rest);
34
+ const ident = num ? null : IDENT.exec(rest);
35
+ if (num) {
36
+ const value = Number(num[0].replace(/[km]$/i, "")) * (SUFFIX[(num[2] ?? "").toLowerCase()] ?? 1);
37
+ tokens.push({ kind: "num", value });
38
+ rest = rest.slice(num[0].length);
39
+ }
40
+ else if (ident) {
41
+ const name = ident[0].toLowerCase();
42
+ rest = rest.slice(ident[0].length);
43
+ if (name in CONSTANTS)
44
+ tokens.push({ kind: "num", value: CONSTANTS[name] });
45
+ else if (name in FUNCTIONS)
46
+ tokens.push({ kind: "fn", value: name });
47
+ else
48
+ return null;
49
+ }
50
+ else {
51
+ const ch = rest[0];
52
+ rest = rest.slice(1);
53
+ if (ch === "(" || ch === ")" || ch === ",")
54
+ tokens.push({ kind: ch });
55
+ else if ("+-*/%^".includes(ch)) {
56
+ const previous = tokens[tokens.length - 1];
57
+ const unary = ch === "-" &&
58
+ (!previous || previous.kind === "op" || previous.kind === "(" || previous.kind === ",");
59
+ tokens.push({
60
+ kind: "op",
61
+ value: unary ? "neg" : ch,
62
+ });
63
+ }
64
+ else
65
+ return null;
66
+ }
67
+ }
68
+ return tokens;
69
+ };
70
+ const PRECEDENCE = {
71
+ "+": 1,
72
+ "-": 1,
73
+ "*": 2,
74
+ "/": 2,
75
+ "%": 2,
76
+ "^": 4,
77
+ neg: 3,
78
+ };
79
+ const RIGHT = new Set(["^", "neg"]);
80
+ const apply = (op, values) => {
81
+ if (op === "neg") {
82
+ const x = values.pop();
83
+ if (x === undefined)
84
+ return false;
85
+ values.push(-x);
86
+ return true;
87
+ }
88
+ const b = values.pop();
89
+ const a = values.pop();
90
+ if (a === undefined || b === undefined)
91
+ return false;
92
+ const result = op === "+"
93
+ ? a + b
94
+ : op === "-"
95
+ ? a - b
96
+ : op === "*"
97
+ ? a * b
98
+ : op === "/"
99
+ ? a / b
100
+ : op === "%"
101
+ ? a % b
102
+ : a ** b;
103
+ values.push(result);
104
+ return true;
105
+ };
106
+ /**
107
+ * @function evaluate
108
+ * @param expression {string} what was typed, e.g. "1.5k * (2 + sqrt(9))"
109
+ * @returns {number | null} the value, or null when it doesn't parse or isn't finite
110
+ */
111
+ export function evaluate(expression) {
112
+ const tokens = tokenize(expression);
113
+ if (!tokens || tokens.length === 0)
114
+ return null;
115
+ const values = [];
116
+ const ops = [];
117
+ const argc = [];
118
+ let expectValue = true;
119
+ const popOps = (until) => {
120
+ while (ops.length > 0) {
121
+ const top = ops[ops.length - 1];
122
+ if (until(top))
123
+ return true;
124
+ ops.pop();
125
+ if (top.kind !== "op" || !apply(top.value, values))
126
+ return false;
127
+ }
128
+ return false;
129
+ };
130
+ for (const [i, token] of tokens.entries()) {
131
+ const next = tokens[i + 1];
132
+ if (token.kind === "num") {
133
+ if (!expectValue)
134
+ return null;
135
+ values.push(token.value);
136
+ expectValue = false;
137
+ }
138
+ else if (token.kind === "fn") {
139
+ if (!expectValue || next?.kind !== "(")
140
+ return null;
141
+ ops.push({ kind: "(", fn: token.value });
142
+ argc.push(next === undefined ? 0 : tokens[i + 2]?.kind === ")" ? 0 : 1);
143
+ tokens.splice(i + 1, 1);
144
+ }
145
+ else if (token.kind === "(") {
146
+ if (!expectValue)
147
+ return null;
148
+ ops.push(token);
149
+ argc.push(0);
150
+ expectValue = true;
151
+ }
152
+ else if (token.kind === ",") {
153
+ if (expectValue)
154
+ return null;
155
+ if (!popOps((top) => top.kind === "("))
156
+ return null;
157
+ const top = ops[ops.length - 1];
158
+ if (top?.kind !== "(" || !top.fn)
159
+ return null;
160
+ argc[argc.length - 1] = (argc[argc.length - 1] ?? 0) + 1;
161
+ expectValue = true;
162
+ }
163
+ else if (token.kind === ")") {
164
+ if (expectValue && ops[ops.length - 1]?.kind !== "(")
165
+ return null;
166
+ if (!popOps((top) => top.kind === "("))
167
+ return null;
168
+ const open = ops.pop();
169
+ const count = argc.pop() ?? 0;
170
+ if (open?.kind === "(" && open.fn) {
171
+ const n = expectValue ? count : count;
172
+ const args = values.splice(values.length - n, n);
173
+ if (args.length !== n)
174
+ return null;
175
+ const result = FUNCTIONS[open.fn]?.(args);
176
+ if (result === undefined)
177
+ return null;
178
+ values.push(result);
179
+ }
180
+ else if (expectValue)
181
+ return null;
182
+ expectValue = false;
183
+ }
184
+ else {
185
+ const op = token.value;
186
+ if (op === "neg") {
187
+ if (!expectValue)
188
+ return null;
189
+ ops.push(token);
190
+ continue;
191
+ }
192
+ if (expectValue)
193
+ return null;
194
+ const p = PRECEDENCE[op];
195
+ popOps((top) => {
196
+ if (top.kind !== "op")
197
+ return true;
198
+ const topP = PRECEDENCE[top.value];
199
+ return topP < p || (topP === p && RIGHT.has(op));
200
+ });
201
+ ops.push(token);
202
+ expectValue = true;
203
+ }
204
+ }
205
+ if (expectValue)
206
+ return null;
207
+ if (popOps((top) => top.kind === "("))
208
+ return null;
209
+ const result = values.length === 1 ? values[0] : undefined;
210
+ return result !== undefined && Number.isFinite(result) ? result : null;
211
+ }
212
+ /**
213
+ * @function formatResult
214
+ * @param value {number} a result
215
+ * @returns {string} up to 10 significant digits, trailing zeros dropped, "-0" as "0"
216
+ */
217
+ export function formatResult(value) {
218
+ const text = String(Number(value.toPrecision(10)));
219
+ return text === "-0" ? "0" : text;
220
+ }
221
+ /**
222
+ * @function isBareNumber
223
+ * @param expression {string} what was typed
224
+ * @returns {boolean} true for just a number ("2", " -2.5 "), when the palette shows no "= 2" row
225
+ */
226
+ export const isBareNumber = (expression) => /^\s*-?(\d+\.?\d*|\.\d+)\s*$/.test(expression);
@@ -0,0 +1,51 @@
1
+ /**
2
+ * @file src/components/palette/fuzzy.ts
3
+ * @desc The palette's scorer, pure. A query matches a text when its characters appear in order
4
+ * (case-insensitive). Each matched character scores 10 at a word start, 8 when it follows
5
+ * the previous match, 4 otherwise, minus one per skipped character; a query that prefixes
6
+ * the text adds 20. A command's score is the best of its title, keywords (0.7), subtitle
7
+ * (0.5) and group (0.3). A cased letter or digit may only match at a word start or right
8
+ * after the previous match, so "cpu" lands on "Copy page URL"'s initials and "go" never
9
+ * finds "Sign out"; punctuation and uncased scripts (kanji, kana) match anywhere, since
10
+ * they have no word starts. Backtracking, left to right, memoized.
11
+ * @author David @dvhsh (https://dvh.sh)
12
+ * @created Sat Oct 3, 2026
13
+ * @modified Sat Oct 3, 2026
14
+ */
15
+ import type { Command } from "./types.js";
16
+ /** A match: its score and the matched [start, end) spans in the text, for highlighting. */
17
+ export type FuzzyMatch = {
18
+ score: number;
19
+ ranges: readonly (readonly [number, number])[];
20
+ };
21
+ /** A command with its score and the title's matched spans (empty when another field matched). */
22
+ export type Scored = {
23
+ command: Command;
24
+ score: number;
25
+ ranges: FuzzyMatch["ranges"];
26
+ };
27
+ /**
28
+ * @function fuzzyScore
29
+ * @param query {string} what was typed; surrounding spaces are ignored
30
+ * @param text {string} a title, keyword or heading
31
+ * @returns {FuzzyMatch | null} the score and matched spans, or null when the query isn't a
32
+ * subsequence of the text. An empty query scores 0 with no spans.
33
+ */
34
+ export declare function fuzzyScore(query: string, text: string): FuzzyMatch | null;
35
+ /**
36
+ * @function scoreCommand
37
+ * @param query {string} what was typed
38
+ * @param command {Command} a row
39
+ * @returns {Scored | null} the best weighted field match, with title spans only when the title
40
+ * itself matched; null when no field matches
41
+ */
42
+ export declare function scoreCommand(query: string, command: Command): Scored | null;
43
+ /**
44
+ * @function rankResults
45
+ * @param query {string} what was typed
46
+ * @param commands {readonly Command[]} the rows to search
47
+ * @param boost {(id: string) => number} a tie-breaker per id (recents count)
48
+ * @returns {Scored[]} the matching rows, best first; ties go to the higher boost, then to
49
+ * declaration order
50
+ */
51
+ export declare function rankResults(query: string, commands: readonly Command[], boost: (id: string) => number): Scored[];
@@ -0,0 +1,138 @@
1
+ /**
2
+ * @file src/components/palette/fuzzy.ts
3
+ * @desc The palette's scorer, pure. A query matches a text when its characters appear in order
4
+ * (case-insensitive). Each matched character scores 10 at a word start, 8 when it follows
5
+ * the previous match, 4 otherwise, minus one per skipped character; a query that prefixes
6
+ * the text adds 20. A command's score is the best of its title, keywords (0.7), subtitle
7
+ * (0.5) and group (0.3). A cased letter or digit may only match at a word start or right
8
+ * after the previous match, so "cpu" lands on "Copy page URL"'s initials and "go" never
9
+ * finds "Sign out"; punctuation and uncased scripts (kanji, kana) match anywhere, since
10
+ * they have no word starts. Backtracking, left to right, memoized.
11
+ * @author David @dvhsh (https://dvh.sh)
12
+ * @created Sat Oct 3, 2026
13
+ * @modified Sat Oct 3, 2026
14
+ */
15
+ const WORD_BREAK = /[\s\-_/.]/;
16
+ const isWordStart = (text, at) => {
17
+ if (at === 0)
18
+ return true;
19
+ const before = text[at - 1] ?? "";
20
+ const here = text[at] ?? "";
21
+ if (WORD_BREAK.test(before))
22
+ return true;
23
+ return before === before.toLowerCase() && here !== here.toLowerCase();
24
+ };
25
+ /**
26
+ * A character the word-start rule applies to: a digit or a letter with case. Scripts without
27
+ * case (kanji, kana, hangul) have no spaces to start words at, so they may match anywhere.
28
+ */
29
+ const isStrict = (ch) => /\p{N}/u.test(ch) || (/\p{L}/u.test(ch) && ch.toLowerCase() !== ch.toUpperCase());
30
+ /**
31
+ * The positions where the query's characters match, found left to right with backtracking,
32
+ * memoized on (index, from, consecutive) so a repetitive query stays polynomial. A cased letter
33
+ * or a digit may only match at a word start or right after the previous match (so "go" never
34
+ * lands on the g inside "Sign out"); punctuation and uncased letters may match anywhere. Null
35
+ * when the query can't be placed.
36
+ */
37
+ const place = (chars, i, from, prev, text, t, memo) => {
38
+ const ch = chars[i];
39
+ if (ch === undefined)
40
+ return [];
41
+ const key = `${i}:${from}:${prev === from - 1 ? 1 : 0}`;
42
+ const seen = memo.get(key);
43
+ if (seen !== undefined)
44
+ return seen;
45
+ let found = null;
46
+ for (let at = t.indexOf(ch, from); at >= 0; at = t.indexOf(ch, at + 1)) {
47
+ const allowed = !isStrict(ch) || (prev >= 0 && at === prev + 1) || isWordStart(text, at);
48
+ if (!allowed)
49
+ continue;
50
+ const rest = place(chars, i + 1, at + 1, at, text, t, memo);
51
+ if (rest) {
52
+ found = [at, ...rest];
53
+ break;
54
+ }
55
+ }
56
+ memo.set(key, found);
57
+ return found;
58
+ };
59
+ /**
60
+ * @function fuzzyScore
61
+ * @param query {string} what was typed; surrounding spaces are ignored
62
+ * @param text {string} a title, keyword or heading
63
+ * @returns {FuzzyMatch | null} the score and matched spans, or null when the query isn't a
64
+ * subsequence of the text. An empty query scores 0 with no spans.
65
+ */
66
+ export function fuzzyScore(query, text) {
67
+ const q = query.trim().toLowerCase();
68
+ if (q.length === 0)
69
+ return { score: 0, ranges: [] };
70
+ const t = text.toLowerCase();
71
+ const positions = place([...q], 0, 0, -1, text, t, new Map());
72
+ if (!positions)
73
+ return null;
74
+ let score = 0;
75
+ let prev = -1;
76
+ const ranges = [];
77
+ for (const at of positions) {
78
+ if (prev >= 0 && at === prev + 1)
79
+ score += 8;
80
+ else if (isWordStart(text, at))
81
+ score += 10;
82
+ else
83
+ score += 4;
84
+ if (prev >= 0)
85
+ score -= Math.max(0, at - prev - 1);
86
+ const last = ranges[ranges.length - 1];
87
+ if (last && last[1] === at)
88
+ last[1] = at + 1;
89
+ else
90
+ ranges.push([at, at + 1]);
91
+ prev = at;
92
+ }
93
+ if (t.startsWith(q))
94
+ score += 20;
95
+ return { score, ranges };
96
+ }
97
+ const WEIGHTS = { keyword: 0.7, subtitle: 0.5, group: 0.3 };
98
+ /**
99
+ * @function scoreCommand
100
+ * @param query {string} what was typed
101
+ * @param command {Command} a row
102
+ * @returns {Scored | null} the best weighted field match, with title spans only when the title
103
+ * itself matched; null when no field matches
104
+ */
105
+ export function scoreCommand(query, command) {
106
+ const title = fuzzyScore(query, command.title);
107
+ let best = title ? { score: title.score, ranges: title.ranges } : null;
108
+ const consider = (text, weight) => {
109
+ if (!text)
110
+ return;
111
+ const match = fuzzyScore(query, text);
112
+ if (match && (!best || match.score * weight > best.score)) {
113
+ best = { score: match.score * weight, ranges: [] };
114
+ }
115
+ };
116
+ for (const keyword of command.keywords ?? [])
117
+ consider(keyword, WEIGHTS.keyword);
118
+ consider(command.subtitle, WEIGHTS.subtitle);
119
+ consider(command.group, WEIGHTS.group);
120
+ return best ? { command, ...best } : null;
121
+ }
122
+ /**
123
+ * @function rankResults
124
+ * @param query {string} what was typed
125
+ * @param commands {readonly Command[]} the rows to search
126
+ * @param boost {(id: string) => number} a tie-breaker per id (recents count)
127
+ * @returns {Scored[]} the matching rows, best first; ties go to the higher boost, then to
128
+ * declaration order
129
+ */
130
+ export function rankResults(query, commands, boost) {
131
+ return commands
132
+ .map((command, index) => ({ scored: scoreCommand(query, command), index }))
133
+ .filter((entry) => entry.scored !== null)
134
+ .sort((a, b) => b.scored.score - a.scored.score ||
135
+ boost(b.scored.command.id) - boost(a.scored.command.id) ||
136
+ a.index - b.index)
137
+ .map((entry) => entry.scored);
138
+ }
@@ -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
+ }