@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,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[];
@@ -0,0 +1,152 @@
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
+ "use client";
13
+ import { isExternalHref } from "../../utils/href.js";
14
+ import { HARUHIME_TOOLS } from "../shell/haruhimeTools.js";
15
+ const slug = (label) => label
16
+ .toLowerCase()
17
+ .replace(/[^a-z0-9]+/g, "-")
18
+ .replace(/^-|-$/g, "") || "page";
19
+ const openTab = (href) => {
20
+ window.open(href, "_blank", "noopener");
21
+ };
22
+ const go = (href, ctx) => isExternalHref(href) ? openTab(href) : ctx.navigate(href);
23
+ const reducedMotion = () => typeof window.matchMedia === "function" &&
24
+ window.matchMedia("(prefers-reduced-motion: reduce)").matches;
25
+ const navigate = (options) => {
26
+ const pages = (options.pages ?? []).flatMap((item) => {
27
+ const href = item.href;
28
+ if (!href)
29
+ return [];
30
+ return [
31
+ {
32
+ id: `site.go.${slug(item.label)}`,
33
+ title: `Go to ${item.label}`,
34
+ group: "Navigate",
35
+ keywords: [href],
36
+ run: (ctx) => go(href, ctx),
37
+ },
38
+ ];
39
+ });
40
+ if (options.tools === false)
41
+ return pages;
42
+ const tools = HARUHIME_TOOLS.filter((tool) => tool.id !== options.tools).map((tool) => ({
43
+ id: `site.tool.${tool.id}`,
44
+ title: `Open ${tool.name}`,
45
+ subtitle: tool.blurb,
46
+ group: "Navigate",
47
+ run: () => openTab(tool.href),
48
+ }));
49
+ tools.push({
50
+ id: "site.tool.home",
51
+ title: "Open haruhime.moe",
52
+ subtitle: "all tools",
53
+ group: "Navigate",
54
+ run: () => openTab("https://www.haruhime.moe"),
55
+ });
56
+ return [...pages, ...tools];
57
+ };
58
+ const page = (options) => {
59
+ const list = [
60
+ {
61
+ id: "site.copy-url",
62
+ title: "Copy page URL",
63
+ group: "Page",
64
+ shortcut: "mod+shift+c",
65
+ closeOnRun: false,
66
+ run: (ctx) => ctx.copy(location.href),
67
+ },
68
+ { id: "site.back", title: "Go back", group: "Page", run: () => history.back() },
69
+ {
70
+ id: "site.top",
71
+ title: "Scroll to top",
72
+ group: "Page",
73
+ run: () => window.scrollTo({ top: 0, behavior: reducedMotion() ? "auto" : "smooth" }),
74
+ },
75
+ { id: "site.reload", title: "Reload page", group: "Page", run: () => location.reload() },
76
+ ];
77
+ if (options.repo)
78
+ list.push({
79
+ id: "site.github",
80
+ title: "Open on GitHub",
81
+ group: "Page",
82
+ keywords: ["source", "repo"],
83
+ run: () => openTab(options.repo),
84
+ });
85
+ return list;
86
+ };
87
+ const account = (options) => {
88
+ const acct = options.account;
89
+ if (!acct)
90
+ return [];
91
+ const list = [
92
+ {
93
+ id: "site.sign-in",
94
+ title: "Sign in",
95
+ group: "Account",
96
+ when: () => !acct.signedIn,
97
+ run: (ctx) => ctx.navigate(acct.signInHref),
98
+ },
99
+ ];
100
+ if (acct.accountHref)
101
+ list.push({
102
+ id: "site.account",
103
+ title: "My account",
104
+ group: "Account",
105
+ when: () => acct.signedIn,
106
+ run: (ctx) => ctx.navigate(acct.accountHref),
107
+ });
108
+ if (acct.signOutHref)
109
+ list.push({
110
+ id: "site.sign-out",
111
+ title: "Sign out",
112
+ group: "Account",
113
+ when: () => acct.signedIn,
114
+ run: (ctx) => ctx.navigate(acct.signOutHref),
115
+ });
116
+ return list;
117
+ };
118
+ const help = (options) => {
119
+ const list = [
120
+ {
121
+ id: "site.shortcuts",
122
+ title: "Keyboard shortcuts",
123
+ group: "Help",
124
+ shortcut: "?",
125
+ closeOnRun: false,
126
+ run: (ctx) => ctx.push({
127
+ title: "Keyboard shortcuts",
128
+ placeholder: "Filter shortcuts…",
129
+ commands: ctx.commands.filter((c) => c.shortcut && c.id !== "site.shortcuts"),
130
+ }),
131
+ },
132
+ ];
133
+ if (options.repo)
134
+ list.push({
135
+ id: "site.report",
136
+ title: "Report a bug",
137
+ group: "Help",
138
+ keywords: ["issue", "feedback"],
139
+ run: () => openTab(`${options.repo}/issues/new`),
140
+ });
141
+ return list;
142
+ };
143
+ const BUILDERS = { navigate, page, account, help };
144
+ /**
145
+ * @function siteCommands
146
+ * @param options {SiteCommandsOptions} pages, tools, repo, account and which groups to include
147
+ * @returns {Command[]} the defaults in order: Navigate, Page, Account, Help
148
+ */
149
+ export function siteCommands(options = {}) {
150
+ const include = options.include ?? ["navigate", "page", "account", "help"];
151
+ return include.flatMap((group) => BUILDERS[group](options));
152
+ }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * @file src/components/palette/store.ts
3
+ * @desc The palette's state and reducer, pure: open or closed, a stack of page frames (each with
4
+ * its own query, active row and provider results, so popping back restores where you
5
+ * were), and the argument prompt in progress. Every key the palette handles is an action
6
+ * here, so the rules test without a DOM. CommandPalette owns it through useReducer.
7
+ * @author David @dvhsh (https://dvh.sh)
8
+ * @created Sat Oct 3, 2026
9
+ * @modified Sat Oct 3, 2026
10
+ */
11
+ import type { Command, Page } from "./types.js";
12
+ /** One provider's rows on a frame and where its search is. */
13
+ export type ProviderResult = {
14
+ state: "loading" | "done" | "error";
15
+ rows: readonly Command[];
16
+ };
17
+ /** One page on the stack. */
18
+ export type Frame = {
19
+ page: Page;
20
+ query: string;
21
+ active: number;
22
+ providers: Record<string, ProviderResult>;
23
+ };
24
+ /** The argument prompt in progress. */
25
+ export type ArgState = {
26
+ command: Command;
27
+ index: number;
28
+ values: Record<string, string>;
29
+ query: string;
30
+ active: number;
31
+ error?: string | undefined;
32
+ };
33
+ export type PaletteState = {
34
+ open: boolean;
35
+ stack: Frame[];
36
+ arg: ArgState | null;
37
+ };
38
+ export type PaletteAction = {
39
+ type: "open";
40
+ root: Page;
41
+ page?: Page | undefined;
42
+ } | {
43
+ type: "close";
44
+ } | {
45
+ type: "query";
46
+ value: string;
47
+ } | {
48
+ type: "active";
49
+ index: number;
50
+ count: number;
51
+ } | {
52
+ type: "move";
53
+ delta: Delta;
54
+ count: number;
55
+ } | {
56
+ type: "push";
57
+ page: Page;
58
+ } | {
59
+ type: "pop";
60
+ } | {
61
+ type: "beginArgs";
62
+ command: Command;
63
+ } | {
64
+ type: "argAccept";
65
+ value: string;
66
+ } | {
67
+ type: "argError";
68
+ message: string;
69
+ } | {
70
+ type: "provided";
71
+ depth: number;
72
+ providerId: string;
73
+ result: ProviderResult;
74
+ };
75
+ /**
76
+ * @function initialState
77
+ * @returns {PaletteState} closed, empty
78
+ */
79
+ export declare const initialState: () => PaletteState;
80
+ /**
81
+ * @function topFrame
82
+ * @param state {PaletteState} the state
83
+ * @returns {Frame | undefined} the page on top, undefined while closed
84
+ */
85
+ export declare const topFrame: (state: PaletteState) => Frame | undefined;
86
+ type Delta = 1 | -1 | "home" | "end";
87
+ /**
88
+ * @function reduce
89
+ * @param state {PaletteState} the state before
90
+ * @param action {PaletteAction} what happened
91
+ * @returns {PaletteState} the state after; the same object when nothing changes
92
+ */
93
+ export declare function reduce(state: PaletteState, action: PaletteAction): PaletteState;
94
+ export {};