@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,10 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ const KBD = "rounded border border-b3 bg-b5 px-1 font-sans text-c3";
3
+ /**
4
+ * @function PaletteFooter
5
+ * @param props {PaletteFooterProps} count, nested, status and its run
6
+ * @returns {JSX.Element} the footer with its `<output>` live region
7
+ */
8
+ export function PaletteFooter({ count, nested, status, statusRun }) {
9
+ return (_jsxs("div", { className: "flex items-center justify-between gap-4 border-b4 border-t px-4 py-2 text-c4 text-xs", children: [_jsx("output", { "aria-live": "polite", className: "truncate", children: statusRun === null ? (`${count} ${count === 1 ? "result" : "results"}`) : (_jsx("span", { children: status }, statusRun)) }), _jsxs("span", { className: "hidden shrink-0 gap-3 sm:flex", children: [_jsxs("span", { children: [_jsx("kbd", { className: KBD, children: "\u2191" }), " ", _jsx("kbd", { className: KBD, children: "\u2193" }), " navigate"] }), _jsxs("span", { children: [_jsx("kbd", { className: KBD, children: "\u21B5" }), " select"] }), nested ? (_jsxs("span", { children: [_jsx("kbd", { className: KBD, children: "\u232B" }), " back"] })) : null, _jsxs("span", { children: [_jsx("kbd", { className: KBD, children: "esc" }), " ", nested ? "back" : "close"] })] })] }));
10
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * @file src/components/palette/PaletteInput.tsx
3
+ * @desc The palette's top line (internal): breadcrumb chips for nested pages or the argument
4
+ * being collected, the combobox input, and the validation message under it. The input is
5
+ * the dialog's one focusable thing, so its row shows focus: the bottom border turns h1 on
6
+ * focus-visible, the cue 0.7.0's fields use. The error line is a status region that is
7
+ * always mounted, so a message that appears is announced.
8
+ * @author David @dvhsh (https://dvh.sh)
9
+ * @created Sat Oct 3, 2026
10
+ * @modified Sat Oct 3, 2026
11
+ */
12
+ import { type Ref } from "react";
13
+ /** The input's state and ids. */
14
+ export type PaletteInputProps = {
15
+ ref: Ref<HTMLInputElement>;
16
+ value: string;
17
+ placeholder: string;
18
+ label: string;
19
+ crumbs: readonly string[];
20
+ listId: string;
21
+ activeId: string | undefined;
22
+ error: string | undefined;
23
+ onChange: (value: string) => void;
24
+ };
25
+ /**
26
+ * @function PaletteInput
27
+ * @param props {PaletteInputProps} value, placeholder, name, crumbs, list and active ids, error
28
+ * @returns {JSX.Element} the crumbs, the combobox and its error line
29
+ */
30
+ export declare function PaletteInput({ ref, value, placeholder, label, crumbs, listId, activeId, error, onChange, }: PaletteInputProps): import("react").JSX.Element;
@@ -0,0 +1,23 @@
1
+ /**
2
+ * @file src/components/palette/PaletteInput.tsx
3
+ * @desc The palette's top line (internal): breadcrumb chips for nested pages or the argument
4
+ * being collected, the combobox input, and the validation message under it. The input is
5
+ * the dialog's one focusable thing, so its row shows focus: the bottom border turns h1 on
6
+ * focus-visible, the cue 0.7.0's fields use. The error line is a status region that is
7
+ * always mounted, so a message that appears is announced.
8
+ * @author David @dvhsh (https://dvh.sh)
9
+ * @created Sat Oct 3, 2026
10
+ * @modified Sat Oct 3, 2026
11
+ */
12
+ "use client";
13
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
14
+ import { useId } from "react";
15
+ /**
16
+ * @function PaletteInput
17
+ * @param props {PaletteInputProps} value, placeholder, name, crumbs, list and active ids, error
18
+ * @returns {JSX.Element} the crumbs, the combobox and its error line
19
+ */
20
+ export function PaletteInput({ ref, value, placeholder, label, crumbs, listId, activeId, error, onChange, }) {
21
+ const errorId = `${useId()}-error`;
22
+ return (_jsxs("div", { className: "border-b4 border-b has-[input:focus-visible]:border-h1", children: [_jsxs("div", { className: "flex items-center gap-2 px-4 py-3", children: [crumbs.map((crumb, index) => (_jsx("span", { className: "shrink-0 rounded bg-b4 px-2 py-0.5 text-c3 text-xs", children: crumb }, index))), _jsx("input", { ref: ref, type: "text", role: "combobox", "aria-expanded": "true", "aria-controls": listId, "aria-activedescendant": activeId, "aria-autocomplete": "list", "aria-label": label, "aria-invalid": error ? true : undefined, "aria-describedby": errorId, autoComplete: "off", spellCheck: false, value: value, placeholder: placeholder, onChange: (event) => onChange(event.target.value), className: "min-w-0 flex-1 bg-transparent text-base text-c1 outline-hidden placeholder:text-c4 sm:text-lg" })] }), _jsx("p", { id: errorId, role: "status", className: error ? "px-4 pb-2 text-rose-300 text-xs" : "sr-only", children: error })] }));
23
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * @file src/components/palette/PaletteList.tsx
3
+ * @desc The palette's listbox (internal): option rows under group headings, hint rows as plain
4
+ * text, the active row scrolled into view. Hover makes a row active; click selects it.
5
+ * @author David @dvhsh (https://dvh.sh)
6
+ * @created Sat Oct 3, 2026
7
+ * @modified Sat Oct 3, 2026
8
+ */
9
+ import type { Row } from "./rows.js";
10
+ import type { Command } from "./types.js";
11
+ /** The rows, which option is active, how to id an option, and the handlers. */
12
+ export type PaletteListProps = {
13
+ id: string;
14
+ rows: readonly Row[];
15
+ active: number;
16
+ mac: boolean;
17
+ optionId: (index: number) => string;
18
+ /** A provider is searching: the listbox is aria-busy. */
19
+ busy?: boolean | undefined;
20
+ /** The pointer is over a row, at this position. */
21
+ onActivate: (index: number, x: number, y: number) => void;
22
+ onSelect: (command: Command) => void;
23
+ };
24
+ /**
25
+ * @function PaletteList
26
+ * @param props {PaletteListProps} rows, the active index, option ids and handlers
27
+ * @returns {JSX.Element} the `role="listbox"`
28
+ */
29
+ export declare function PaletteList({ id, rows, active, mac, optionId, busy, onActivate, onSelect, }: PaletteListProps): import("react").JSX.Element;
@@ -0,0 +1,44 @@
1
+ /**
2
+ * @file src/components/palette/PaletteList.tsx
3
+ * @desc The palette's listbox (internal): option rows under group headings, hint rows as plain
4
+ * text, the active row scrolled into view. Hover makes a row active; click selects it.
5
+ * @author David @dvhsh (https://dvh.sh)
6
+ * @created Sat Oct 3, 2026
7
+ * @modified Sat Oct 3, 2026
8
+ */
9
+ "use client";
10
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
11
+ import { useEffect } from "react";
12
+ import { PaletteRow } from "./PaletteRow.js";
13
+ const segments = (rows) => {
14
+ const out = [];
15
+ let index = 0;
16
+ for (const row of rows) {
17
+ if (row.kind === "hint") {
18
+ out.push({ kind: "hint", row });
19
+ continue;
20
+ }
21
+ const last = out[out.length - 1];
22
+ const item = { row, index: index++ };
23
+ if (last?.kind === "group" && last.group === row.group)
24
+ last.items.push(item);
25
+ else
26
+ out.push({ kind: "group", group: row.group, items: [item] });
27
+ }
28
+ return out;
29
+ };
30
+ /**
31
+ * @function PaletteList
32
+ * @param props {PaletteListProps} rows, the active index, option ids and handlers
33
+ * @returns {JSX.Element} the `role="listbox"`
34
+ */
35
+ export function PaletteList({ id, rows, active, mac, optionId, busy, onActivate, onSelect, }) {
36
+ // Runs for a new list too (its first row changes), so it scrolls back up when active stays 0.
37
+ const first = rows[0]?.id;
38
+ useEffect(() => {
39
+ if (first === undefined)
40
+ return;
41
+ document.getElementById(optionId(active))?.scrollIntoView?.({ block: "nearest" });
42
+ }, [active, optionId, first]);
43
+ return (_jsx("div", { id: id, role: "listbox", "aria-label": "Results", "aria-busy": busy ? true : undefined, className: "min-h-0 flex-1 overflow-y-auto p-2", children: segments(rows).map((segment) => segment.kind === "hint" ? (_jsx("div", { role: "option", "aria-disabled": "true", "aria-selected": "false", className: "px-3 py-2 text-c4 text-sm", children: segment.row.text }, segment.row.id)) : (_jsxs("div", { role: "group", "aria-labelledby": `${id}-${segment.items[0]?.index}-h`, children: [_jsx("div", { id: `${id}-${segment.items[0]?.index}-h`, className: "px-3 pt-3 pb-1 font-bold text-c3 text-xs", children: segment.group }), segment.items.map(({ row, index }) => (_jsx(PaletteRow, { id: optionId(index), command: row.command, ranges: row.ranges, active: index === active, mac: mac, onHover: (x, y) => onActivate(index, x, y), onSelect: () => onSelect(row.command) }, row.id)))] }, `${segment.group}:${segment.items[0]?.row.id}`))) }));
44
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * @file src/components/palette/PaletteRow.tsx
3
+ * @desc One option in the palette's list (internal): icon slot, the title with its matched
4
+ * characters marked, the subtitle, and the shortcut as kbd parts. Finished class strings,
5
+ * no hooks: the list sets aria-selected and the pointer handlers. Keyboard handling lives
6
+ * on the dialog, so this isn't focusable (aria-activedescendant names it).
7
+ * @author David @dvhsh (https://dvh.sh)
8
+ * @created Sat Oct 3, 2026
9
+ * @modified Sat Oct 3, 2026
10
+ */
11
+ import type { FuzzyMatch } from "./fuzzy.js";
12
+ import type { Command } from "./types.js";
13
+ /** The row's id (for aria-activedescendant), its command and spans, whether it's active. */
14
+ export type PaletteRowProps = {
15
+ id: string;
16
+ command: Command;
17
+ ranges: FuzzyMatch["ranges"];
18
+ active: boolean;
19
+ mac: boolean;
20
+ /** The pointer moved over the row; gets the pointer's position, so a stale move can be told. */
21
+ onHover: (x: number, y: number) => void;
22
+ onSelect: () => void;
23
+ };
24
+ /**
25
+ * @function PaletteRow
26
+ * @param props {PaletteRowProps} the option's id, command, matched spans, active state, platform
27
+ * and pointer handlers
28
+ * @returns {JSX.Element} a `role="option"` row
29
+ */
30
+ export declare function PaletteRow({ id, command, ranges, active, mac, onHover, onSelect, }: PaletteRowProps): import("react").JSX.Element;
@@ -0,0 +1,33 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { displayShortcut, parseShortcut } from "./hotkeys.js";
3
+ // The active row shows its state with an h1 edge, not background alone: bg-b4 on b6 is only
4
+ // 1.5:1. min-h-9 keeps every row a 36px target.
5
+ const ROW = "flex min-h-9 cursor-default items-center gap-3 rounded-lg border-l-2 border-transparent px-3 py-2";
6
+ const ACTIVE = `${ROW} border-h1 bg-b4 forced-colors:border-[Highlight] forced-colors:bg-[Highlight] forced-colors:text-[HighlightText]`;
7
+ const KBD = "rounded border border-b3 bg-b5 px-1.5 font-sans text-c3 text-xs";
8
+ const highlighted = (title, ranges) => {
9
+ const parts = [];
10
+ let at = 0;
11
+ for (const [start, end] of ranges) {
12
+ if (start > at)
13
+ parts.push(title.slice(at, start));
14
+ parts.push(_jsx("mark", { className: "bg-transparent text-h1", children: title.slice(start, end) }, start));
15
+ at = end;
16
+ }
17
+ if (at < title.length)
18
+ parts.push(title.slice(at));
19
+ return parts;
20
+ };
21
+ /**
22
+ * @function PaletteRow
23
+ * @param props {PaletteRowProps} the option's id, command, matched spans, active state, platform
24
+ * and pointer handlers
25
+ * @returns {JSX.Element} a `role="option"` row
26
+ */
27
+ export function PaletteRow({ id, command, ranges, active, mac, onHover, onSelect, }) {
28
+ const shortcut = command.shortcut ? parseShortcut(command.shortcut) : null;
29
+ return (_jsxs("div", { id: id, role: "option", "aria-selected": active, className: active ? ACTIVE : ROW, onPointerMove: (event) => onHover(event.clientX, event.clientY),
30
+ // Keep focus in the input: a click on a row must not blur it, so the next prompt
31
+ // (an argument, a nested page) can be typed straight away.
32
+ onPointerDown: (event) => event.preventDefault(), onClick: onSelect, children: [command.icon ? (_jsx("span", { className: "flex size-5 shrink-0 items-center justify-center text-c3", children: command.icon })) : null, _jsxs("span", { className: "flex min-w-0 flex-1 flex-col", children: [_jsx("span", { className: "truncate font-bold text-c1 text-sm", children: highlighted(command.title, ranges) }), command.subtitle ? (_jsx("span", { className: "truncate text-c4 text-xs", children: command.subtitle })) : null] }), shortcut ? (_jsx("span", { className: "flex shrink-0 gap-1", children: displayShortcut(shortcut, mac).map((part, index) => (_jsx("kbd", { className: KBD, children: part }, index))) })) : null] }));
33
+ }
@@ -0,0 +1,28 @@
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
+ /**
12
+ * @function evaluate
13
+ * @param expression {string} what was typed, e.g. "1.5k * (2 + sqrt(9))"
14
+ * @returns {number | null} the value, or null when it doesn't parse or isn't finite
15
+ */
16
+ export declare function evaluate(expression: string): number | null;
17
+ /**
18
+ * @function formatResult
19
+ * @param value {number} a result
20
+ * @returns {string} up to 10 significant digits, trailing zeros dropped, "-0" as "0"
21
+ */
22
+ export declare function formatResult(value: number): string;
23
+ /**
24
+ * @function isBareNumber
25
+ * @param expression {string} what was typed
26
+ * @returns {boolean} true for just a number ("2", " -2.5 "), when the palette shows no "= 2" row
27
+ */
28
+ export declare const isBareNumber: (expression: string) => boolean;
@@ -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
+ }