@x-tech-laboratories/x-libs 0.4.0 → 0.7.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 (87) hide show
  1. package/README.md +89 -13
  2. package/dist/backups.d.ts +31 -0
  3. package/dist/backups.js +43 -0
  4. package/dist/dates.d.ts +9 -0
  5. package/dist/dates.js +60 -0
  6. package/dist/durations.d.ts +9 -1
  7. package/dist/durations.js +19 -5
  8. package/dist/electron/app.d.ts +13 -0
  9. package/dist/electron/app.js +27 -0
  10. package/dist/electron/bridge.d.ts +32 -0
  11. package/dist/electron/bridge.js +53 -0
  12. package/dist/electron/events.d.ts +21 -0
  13. package/dist/electron/events.js +25 -0
  14. package/dist/electron/fetch.d.ts +25 -0
  15. package/dist/electron/fetch.js +67 -0
  16. package/dist/electron/window.d.ts +96 -0
  17. package/dist/electron/window.js +128 -0
  18. package/dist/log.d.ts +30 -0
  19. package/dist/log.js +79 -0
  20. package/dist/server/backup_files.d.ts +3 -0
  21. package/dist/server/backup_files.js +25 -0
  22. package/dist/web/drag.d.ts +3 -0
  23. package/dist/web/drag.js +13 -0
  24. package/dist/web/format.d.ts +1 -0
  25. package/dist/web/format.js +5 -0
  26. package/dist/web/menus.d.ts +4 -0
  27. package/dist/web/menus.js +28 -1
  28. package/dist/web/prefs.d.ts +12 -0
  29. package/dist/web/prefs.js +62 -0
  30. package/dist/web/{preact → react}/badges.d.ts +9 -9
  31. package/dist/web/{preact → react}/badges.js +6 -6
  32. package/dist/web/react/controls.d.ts +72 -0
  33. package/dist/web/react/controls.js +88 -0
  34. package/dist/web/{preact → react}/dialog.d.ts +10 -10
  35. package/dist/web/{preact → react}/dialog.js +14 -9
  36. package/dist/web/react/layout.d.ts +100 -0
  37. package/dist/web/react/layout.js +80 -0
  38. package/dist/web/react/menus.d.ts +31 -0
  39. package/dist/web/react/menus.js +159 -0
  40. package/dist/web/react/prefs.d.ts +2 -0
  41. package/dist/web/react/prefs.js +11 -0
  42. package/dist/web/react/reveal.d.ts +9 -0
  43. package/dist/web/react/reveal.js +30 -0
  44. package/dist/web/react/shell.d.ts +41 -0
  45. package/dist/web/react/shell.js +90 -0
  46. package/dist/web/react/shortcuts.d.ts +23 -0
  47. package/dist/web/react/shortcuts.js +95 -0
  48. package/dist/web/{preact → react}/split.d.ts +4 -4
  49. package/dist/web/{preact → react}/split.js +9 -10
  50. package/dist/web/{preact → react}/states.d.ts +7 -7
  51. package/dist/web/{preact → react}/states.js +6 -5
  52. package/dist/web/react/table.d.ts +69 -0
  53. package/dist/web/react/table.js +278 -0
  54. package/dist/web/{preact → react}/time.d.ts +2 -1
  55. package/dist/web/react/time.js +19 -0
  56. package/dist/web/react/toasts.d.ts +29 -0
  57. package/dist/web/react/toasts.js +72 -0
  58. package/dist/web/react/tree.d.ts +28 -0
  59. package/dist/web/react/tree.js +183 -0
  60. package/dist/web/react/watch.d.ts +3 -0
  61. package/dist/web/react/watch.js +5 -0
  62. package/dist/web/selection.d.ts +25 -0
  63. package/dist/web/selection.js +106 -0
  64. package/dist/web/tables.d.ts +19 -1
  65. package/dist/web/tables.js +78 -8
  66. package/dist/web/theme.d.ts +6 -1
  67. package/dist/web/theme.js +31 -21
  68. package/dist/web/tree.d.ts +37 -0
  69. package/dist/web/tree.js +128 -0
  70. package/dist/web/virtual.d.ts +7 -0
  71. package/dist/web/virtual.js +10 -0
  72. package/dist/web/watch.d.ts +6 -0
  73. package/dist/web/watch.js +17 -0
  74. package/package.json +15 -4
  75. package/dist/web/preact/controls.d.ts +0 -37
  76. package/dist/web/preact/controls.js +0 -43
  77. package/dist/web/preact/layout.d.ts +0 -80
  78. package/dist/web/preact/layout.js +0 -71
  79. package/dist/web/preact/menus.d.ts +0 -19
  80. package/dist/web/preact/menus.js +0 -70
  81. package/dist/web/preact/shell.d.ts +0 -27
  82. package/dist/web/preact/shell.js +0 -63
  83. package/dist/web/preact/table.d.ts +0 -23
  84. package/dist/web/preact/table.js +0 -20
  85. package/dist/web/preact/time.js +0 -9
  86. package/dist/web/preact/toasts.d.ts +0 -15
  87. package/dist/web/preact/toasts.js +0 -34
@@ -0,0 +1,96 @@
1
+ export interface Rectangle {
2
+ x: number;
3
+ y: number;
4
+ width: number;
5
+ height: number;
6
+ }
7
+ export interface BrowserWindowOptionsLike {
8
+ title: string;
9
+ icon?: string;
10
+ x?: number;
11
+ y?: number;
12
+ width: number;
13
+ height: number;
14
+ minWidth?: number;
15
+ minHeight?: number;
16
+ show: boolean;
17
+ backgroundColor: string;
18
+ titleBarStyle?: "default" | "hidden" | "hiddenInset";
19
+ webPreferences: {
20
+ preload?: string;
21
+ sandbox: boolean;
22
+ contextIsolation: boolean;
23
+ nodeIntegration: boolean;
24
+ backgroundThrottling: boolean;
25
+ };
26
+ }
27
+ export interface NavigationEventLike {
28
+ preventDefault(): void;
29
+ }
30
+ export interface BrowserWindowLike {
31
+ webContents: {
32
+ setWindowOpenHandler(handler: (details: {
33
+ url: string;
34
+ }) => {
35
+ action: "deny";
36
+ }): void;
37
+ on(event: "will-navigate", listener: (event: NavigationEventLike, url: string) => void): unknown;
38
+ };
39
+ on(event: string, listener: () => void): unknown;
40
+ once(event: string, listener: () => void): unknown;
41
+ show(): void;
42
+ maximize(): void;
43
+ isMaximized(): boolean;
44
+ getNormalBounds(): Rectangle;
45
+ setBackgroundColor(color: string): void;
46
+ loadURL(url: string): Promise<void>;
47
+ loadFile(path: string): Promise<void>;
48
+ }
49
+ export interface NativeThemeLike {
50
+ shouldUseDarkColors: boolean;
51
+ on(event: "updated", listener: () => void): unknown;
52
+ removeListener(event: "updated", listener: () => void): unknown;
53
+ }
54
+ export interface ShellLike {
55
+ openExternal(url: string): Promise<void>;
56
+ }
57
+ export interface ScreenLike {
58
+ getAllDisplays(): {
59
+ workArea: Rectangle;
60
+ }[];
61
+ }
62
+ export interface Size {
63
+ width: number;
64
+ height: number;
65
+ }
66
+ export interface WindowOptions {
67
+ title: string;
68
+ icon?: string;
69
+ preload?: string;
70
+ dev_url?: string;
71
+ file: string;
72
+ default_size: Size;
73
+ min_size?: Size;
74
+ state_file?: string;
75
+ native_theme: NativeThemeLike;
76
+ shell: ShellLike;
77
+ screen?: ScreenLike;
78
+ background?: {
79
+ light: string;
80
+ dark: string;
81
+ };
82
+ sandbox?: boolean;
83
+ background_throttling?: boolean;
84
+ title_bar_style?: "default" | "hidden" | "hiddenInset";
85
+ before_write?: (path: string) => void;
86
+ save_delay_ms?: number;
87
+ }
88
+ export interface WindowState {
89
+ bounds: Rectangle;
90
+ is_maximized: boolean;
91
+ }
92
+ export declare function read_window_state(state_file: string): WindowState | null;
93
+ export declare function write_window_state(state_file: string, state: WindowState): void;
94
+ export declare function is_external_link(url: string): boolean;
95
+ export declare function is_app_page(url: string, options: Pick<WindowOptions, "dev_url" | "file">): boolean;
96
+ export declare function create_window<Window extends BrowserWindowLike>(BrowserWindow: new (options: BrowserWindowOptionsLike) => Window, options: WindowOptions): Window;
@@ -0,0 +1,128 @@
1
+ // An Electron app's main window: links and pages of other sites open in the browser, the background follows the system theme, and its size and place are remembered.
2
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
3
+ import { dirname } from "node:path";
4
+ import { pathToFileURL } from "node:url";
5
+ const DEFAULT_BACKGROUND = { light: "#f5f6f8", dark: "#0f1115" };
6
+ const DEFAULT_SAVE_DELAY_MS = 500;
7
+ // ── State file ───────────────────────────────────────────────────────────────
8
+ // A missing, unreadable or malformed file reads as no saved state.
9
+ export function read_window_state(state_file) {
10
+ try {
11
+ const state = JSON.parse(readFileSync(state_file, "utf8"));
12
+ const bounds = state.bounds;
13
+ const is_whole = (value) => Number.isInteger(value);
14
+ if (!bounds || ![bounds.x, bounds.y, bounds.width, bounds.height].every(is_whole) || bounds.width <= 0 || bounds.height <= 0)
15
+ return null;
16
+ return { bounds, is_maximized: state.is_maximized === true };
17
+ }
18
+ catch {
19
+ return null;
20
+ }
21
+ }
22
+ // Writes beside the file and renames over it, so a crash mid-write never leaves half a file.
23
+ export function write_window_state(state_file, state) {
24
+ mkdirSync(dirname(state_file), { recursive: true });
25
+ const temporary_file = `${state_file}.tmp`;
26
+ writeFileSync(temporary_file, JSON.stringify(state));
27
+ renameSync(temporary_file, state_file);
28
+ }
29
+ function overlaps(first, second) {
30
+ return first.x < second.x + second.width && second.x < first.x + first.width && first.y < second.y + second.height && second.y < first.y + first.height;
31
+ }
32
+ // ── Links ────────────────────────────────────────────────────────────────────
33
+ const EXTERNAL_SCHEMES = new Set(["http:", "https:", "mailto:"]);
34
+ // Only web and mail links reach the browser, never a file or another app's scheme.
35
+ export function is_external_link(url) {
36
+ try {
37
+ return EXTERNAL_SCHEMES.has(new URL(url).protocol);
38
+ }
39
+ catch {
40
+ return false;
41
+ }
42
+ }
43
+ // True for the app's own pages: the dev server's origin, or files beside the built page.
44
+ export function is_app_page(url, options) {
45
+ try {
46
+ const target = new URL(url);
47
+ if (options.dev_url)
48
+ return target.origin === new URL(options.dev_url).origin;
49
+ const page_folder = new URL(".", pathToFileURL(options.file)).href;
50
+ return target.protocol === "file:" && target.href.startsWith(page_folder);
51
+ }
52
+ catch {
53
+ return false;
54
+ }
55
+ }
56
+ function open_outside(shell, url) {
57
+ if (is_external_link(url))
58
+ void shell.openExternal(url);
59
+ }
60
+ // ── Window ───────────────────────────────────────────────────────────────────
61
+ export function create_window(BrowserWindow, options) {
62
+ const background = options.background ?? DEFAULT_BACKGROUND;
63
+ const background_color = () => options.native_theme.shouldUseDarkColors ? background.dark : background.light;
64
+ const saved = options.state_file ? read_window_state(options.state_file) : null;
65
+ const on_a_screen = saved && (!options.screen || options.screen.getAllDisplays().some((display) => overlaps(saved.bounds, display.workArea)));
66
+ const size = saved?.bounds ?? options.default_size;
67
+ const window = new BrowserWindow({
68
+ title: options.title,
69
+ icon: options.icon,
70
+ ...(on_a_screen ? { x: saved.bounds.x, y: saved.bounds.y } : {}),
71
+ width: size.width,
72
+ height: size.height,
73
+ minWidth: options.min_size?.width,
74
+ minHeight: options.min_size?.height,
75
+ show: false,
76
+ backgroundColor: background_color(),
77
+ ...(options.title_bar_style ? { titleBarStyle: options.title_bar_style } : {}),
78
+ webPreferences: { preload: options.preload, sandbox: options.sandbox ?? false, contextIsolation: true, nodeIntegration: false, backgroundThrottling: options.background_throttling ?? true },
79
+ });
80
+ window.once("ready-to-show", () => {
81
+ if (saved?.is_maximized)
82
+ window.maximize();
83
+ window.show();
84
+ });
85
+ window.webContents.setWindowOpenHandler(({ url }) => {
86
+ open_outside(options.shell, url);
87
+ return { action: "deny" };
88
+ });
89
+ // A link that would load another site in the window opens in the browser instead.
90
+ window.webContents.on("will-navigate", (event, url) => {
91
+ if (is_app_page(url, options))
92
+ return;
93
+ event.preventDefault();
94
+ open_outside(options.shell, url);
95
+ });
96
+ const follow_theme = () => window.setBackgroundColor(background_color());
97
+ options.native_theme.on("updated", follow_theme);
98
+ window.on("closed", () => options.native_theme.removeListener("updated", follow_theme));
99
+ if (options.state_file)
100
+ remember_state(window, options.state_file, options.save_delay_ms ?? DEFAULT_SAVE_DELAY_MS, options.before_write);
101
+ if (options.dev_url)
102
+ void window.loadURL(options.dev_url);
103
+ else
104
+ void window.loadFile(options.file);
105
+ return window;
106
+ }
107
+ // Saves a moment after the last move or resize, and at once when the window closes.
108
+ function remember_state(window, state_file, save_delay_ms, before_write) {
109
+ let timer;
110
+ const save_now = () => {
111
+ clearTimeout(timer);
112
+ timer = undefined;
113
+ try {
114
+ before_write?.(state_file);
115
+ write_window_state(state_file, { bounds: window.getNormalBounds(), is_maximized: window.isMaximized() });
116
+ }
117
+ catch {
118
+ // A refused or failed save keeps the last one, and the window carries on.
119
+ }
120
+ };
121
+ const save_soon = () => {
122
+ clearTimeout(timer);
123
+ timer = setTimeout(save_now, save_delay_ms);
124
+ };
125
+ for (const event of ["resize", "move", "maximize", "unmaximize"])
126
+ window.on(event, save_soon);
127
+ window.on("close", save_now);
128
+ }
package/dist/log.d.ts ADDED
@@ -0,0 +1,30 @@
1
+ export type Level = "critical" | "error" | "warn" | "info" | "debug";
2
+ export declare const levels: Level[];
3
+ export declare const default_level: Level;
4
+ export interface LevelSpec {
5
+ default: Level;
6
+ scopes: Record<string, Level>;
7
+ }
8
+ export declare function parse_level_spec(spec: string | undefined, fallback?: Level): LevelSpec;
9
+ export declare function level_for(spec: LevelSpec, scope: string): Level;
10
+ export declare function is_enabled(spec: LevelSpec, scope: string, level: Level): boolean;
11
+ export interface LogRecord {
12
+ at: number;
13
+ level: Level;
14
+ scope: string;
15
+ message: string;
16
+ data?: unknown;
17
+ }
18
+ export type LogSink = (record: LogRecord) => void;
19
+ export declare function format_record(record: LogRecord): string;
20
+ export interface Logger {
21
+ critical(message: string, data?: unknown): void;
22
+ error(message: string, data?: unknown): void;
23
+ warn(message: string, data?: unknown): void;
24
+ info(message: string, data?: unknown): void;
25
+ debug(message: string, data?: unknown): void;
26
+ enabled(level: Level): boolean;
27
+ child(scope: string): Logger;
28
+ }
29
+ export type SpecSource = LevelSpec | (() => LevelSpec);
30
+ export declare function create_logger(scope: string, spec: SpecSource, sink: LogSink): Logger;
package/dist/log.js ADDED
@@ -0,0 +1,79 @@
1
+ // Logging by scope and level, such as "warn,play:debug", where a mistake in the setting never silences logging.
2
+ const level_order = { critical: 0, error: 1, warn: 2, info: 3, debug: 4 };
3
+ export const levels = Object.keys(level_order);
4
+ const level_aliases = {
5
+ crit: "critical", critical: "critical",
6
+ err: "error", error: "error",
7
+ warn: "warn", warning: "warn",
8
+ info: "info",
9
+ debug: "debug", verbose: "debug", trace: "debug",
10
+ };
11
+ export const default_level = "info";
12
+ // ── Parsing ──────────────────────────────────────────────────────────────────
13
+ // A bare level sets the default and "scope:level" one scope; entries it cannot read are skipped.
14
+ export function parse_level_spec(spec, fallback = default_level) {
15
+ const parsed = { default: fallback, scopes: {} };
16
+ if (!spec)
17
+ return parsed;
18
+ for (const raw_part of spec.split(",")) {
19
+ const part = raw_part.trim().toLowerCase();
20
+ if (part === "")
21
+ continue;
22
+ // Scopes hold colons of their own, so the level follows the last one.
23
+ const colon = part.lastIndexOf(":");
24
+ if (colon === -1) {
25
+ const level = level_aliases[part];
26
+ if (level)
27
+ parsed.default = level;
28
+ continue;
29
+ }
30
+ const scope = part.slice(0, colon).trim();
31
+ const level = level_aliases[part.slice(colon + 1).trim()];
32
+ if (!level || scope === "")
33
+ continue;
34
+ if (scope === "*")
35
+ parsed.default = level;
36
+ else
37
+ parsed.scopes[scope] = level;
38
+ }
39
+ return parsed;
40
+ }
41
+ export function level_for(spec, scope) {
42
+ return spec.scopes[scope] ?? spec.default;
43
+ }
44
+ export function is_enabled(spec, scope, level) {
45
+ return level_order[level] <= level_order[level_for(spec, scope)];
46
+ }
47
+ const two_digits = (count) => String(count).padStart(2, "0");
48
+ // A line in fixed columns, such as "10:33:35.123 WARN play message {"detail":1}".
49
+ export function format_record(record) {
50
+ const date = new Date(record.at);
51
+ const time = `${two_digits(date.getHours())}:${two_digits(date.getMinutes())}:${two_digits(date.getSeconds())}.${String(date.getMilliseconds()).padStart(3, "0")}`;
52
+ let line = `${time} ${record.level.toUpperCase().padEnd(8)} ${record.scope.padEnd(9)} ${record.message}`;
53
+ if (record.data !== undefined) {
54
+ try {
55
+ line += ` ${JSON.stringify(record.data)}`;
56
+ }
57
+ catch {
58
+ line += " [unserializable]";
59
+ }
60
+ }
61
+ return line;
62
+ }
63
+ const resolve_spec = (source) => typeof source === "function" ? source() : source;
64
+ export function create_logger(scope, spec, sink) {
65
+ const emit = (level, message, data) => {
66
+ if (!is_enabled(resolve_spec(spec), scope, level))
67
+ return;
68
+ sink({ at: Date.now(), level, scope, message, data });
69
+ };
70
+ return {
71
+ critical: (message, data) => emit("critical", message, data),
72
+ error: (message, data) => emit("error", message, data),
73
+ warn: (message, data) => emit("warn", message, data),
74
+ info: (message, data) => emit("info", message, data),
75
+ debug: (message, data) => emit("debug", message, data),
76
+ enabled: (level) => is_enabled(resolve_spec(spec), scope, level),
77
+ child: (child_scope) => create_logger(`${scope}:${child_scope}`, spec, sink),
78
+ };
79
+ }
@@ -0,0 +1,3 @@
1
+ import type { BackupNaming, BackupStore } from "../backups.ts";
2
+ export declare function backup_path(folder: string, naming: BackupNaming, name: string): string;
3
+ export declare function file_backup_store(folder: () => string, naming: BackupNaming, copy_to: (path: string) => void): BackupStore;
@@ -0,0 +1,25 @@
1
+ // Backups kept as files in a folder on disk, for an app's main process or server.
2
+ import { mkdirSync, readdirSync, rmSync, statSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ // The path of a backup in the folder, refusing any name the naming did not write.
5
+ export function backup_path(folder, naming, name) {
6
+ if (!naming.pattern.test(name))
7
+ throw new Error(`not a backup name: ${name}`);
8
+ return join(folder, name);
9
+ }
10
+ // A folder of backup files, made when first used; copy_to writes the app's data to the path it is given, such as through SQLite's own backup.
11
+ export function file_backup_store(folder, naming, copy_to) {
12
+ const path_of = (name) => backup_path(folder(), naming, name);
13
+ return {
14
+ list: () => {
15
+ mkdirSync(folder(), { recursive: true });
16
+ return readdirSync(folder()).filter((name) => naming.pattern.test(name)).map((name) => ({ name, size_bytes: statSync(path_of(name)).size }));
17
+ },
18
+ take: (name) => {
19
+ mkdirSync(folder(), { recursive: true });
20
+ copy_to(path_of(name));
21
+ return statSync(path_of(name)).size;
22
+ },
23
+ remove: (name) => rmSync(path_of(name)),
24
+ };
25
+ }
@@ -0,0 +1,3 @@
1
+ export declare function set_drag_badge(event: {
2
+ dataTransfer: DataTransfer | null;
3
+ }, label: string, class_name?: string): void;
@@ -0,0 +1,13 @@
1
+ // What a drag shows under the pointer.
2
+ // Shows a small badge such as "3 tasks" under the pointer as the drag image, in place of a picture of the grabbed row.
3
+ export function set_drag_badge(event, label, class_name = "drag-badge") {
4
+ if (!event.dataTransfer)
5
+ return;
6
+ const badge = document.createElement("div");
7
+ badge.className = class_name;
8
+ badge.textContent = label;
9
+ document.body.append(badge);
10
+ event.dataTransfer.setDragImage(badge, -12, -12);
11
+ // The browser takes its picture of the badge during this event, so it can go once the event is over.
12
+ setTimeout(() => badge.remove(), 0);
13
+ }
@@ -8,3 +8,4 @@ export declare function size(count: number | null | undefined): string;
8
8
  export declare function size_change(count: number | null | undefined): string;
9
9
  export declare function count_change(value: number | null | undefined): string;
10
10
  export declare function plural(count: number, word: string): string;
11
+ export declare function compact_count(count: number): string;
@@ -75,3 +75,8 @@ export function count_change(value) {
75
75
  export function plural(count, word) {
76
76
  return `${count.toLocaleString()} ${word}${plurals.select(count) === "one" ? "" : "s"}`;
77
77
  }
78
+ const compact = new Intl.NumberFormat("en", { notation: "compact", maximumFractionDigits: 1 });
79
+ // A count in a few characters, such as "1.4M", "150K" or "950".
80
+ export function compact_count(count) {
81
+ return compact.format(count);
82
+ }
@@ -6,8 +6,12 @@ export interface MenuItem {
6
6
  danger?: boolean;
7
7
  separator_before?: boolean;
8
8
  checked?: boolean;
9
+ radio?: boolean;
10
+ keep_open?: boolean;
9
11
  }
10
12
  export declare function on_screen_position(x: number, y: number, width: number, height: number, room_width?: number, room_height?: number): {
11
13
  left: number;
12
14
  top: number;
13
15
  };
16
+ export declare function item_after_key(key: string, current: number, count: number): number | null;
17
+ export declare function item_starting_with(labels: readonly string[], typed: string, current: number): number | null;
package/dist/web/menus.js CHANGED
@@ -1,4 +1,4 @@
1
- // What a menu item holds, and where a menu opened at a point goes so it stays on screen.
1
+ // What a menu item holds, where a menu opened at a point goes so it stays on screen, and which item a key moves to.
2
2
  const edge_gap = 4;
3
3
  // Flipped to the point's other side near an edge, or pushed back from the edge when neither side has room.
4
4
  export function on_screen_position(x, y, width, height, room_width = window.innerWidth, room_height = window.innerHeight) {
@@ -11,3 +11,30 @@ export function on_screen_position(x, y, width, height, room_width = window.inne
11
11
  };
12
12
  return { left: place(x, width, room_width), top: place(y, height, room_height) };
13
13
  }
14
+ // ── Keys ─────────────────────────────────────────────────────────────────────
15
+ // The item an arrow, Home or End key moves to from the current one, wrapping round, or null for any other key.
16
+ export function item_after_key(key, current, count) {
17
+ if (count === 0)
18
+ return null;
19
+ switch (key) {
20
+ case "ArrowDown": return current < 0 ? 0 : (current + 1) % count;
21
+ case "ArrowUp": return current < 0 ? count - 1 : (current - 1 + count) % count;
22
+ case "Home": return 0;
23
+ case "End": return count - 1;
24
+ default: return null;
25
+ }
26
+ }
27
+ // The first item after the current one whose label starts with the typed text, or null when none does.
28
+ export function item_starting_with(labels, typed, current) {
29
+ const wanted = typed.toLocaleLowerCase();
30
+ // A repeated first letter steps on through the items that start with it.
31
+ const repeated = wanted.length > 1 && [...wanted].every((letter) => letter === wanted[0]);
32
+ const prefix = repeated ? wanted[0] : wanted;
33
+ const start = repeated || wanted.length === 1 ? current + 1 : Math.max(current, 0);
34
+ for (let offset = 0; offset < labels.length; offset += 1) {
35
+ const index = (start + offset) % labels.length;
36
+ if (labels[index].trim().toLocaleLowerCase().startsWith(prefix))
37
+ return index;
38
+ }
39
+ return null;
40
+ }
@@ -0,0 +1,12 @@
1
+ export interface StorageLike {
2
+ getItem(key: string): string | null;
3
+ setItem(key: string, value: string): void;
4
+ removeItem(key: string): void;
5
+ }
6
+ export type Validator<Value> = (value: unknown) => value is Value;
7
+ export declare function browser_storage(): StorageLike | undefined;
8
+ export declare function read_pref<Value>(storage: StorageLike | undefined, key: string, initial: Value, validate?: Validator<Value>): Value;
9
+ export declare function write_pref(storage: StorageLike | undefined, key: string, value: unknown): void;
10
+ export declare function one_of<Value extends string>(...allowed: readonly Value[]): Validator<Value>;
11
+ export declare function is_boolean(value: unknown): value is boolean;
12
+ export declare function is_number_in(min: number, max: number): Validator<number>;
@@ -0,0 +1,62 @@
1
+ // Remembered view settings: a stored value that is corrupt, from an older version or of the wrong shape reads as the default.
2
+ // This browser's localStorage, or nothing where there is none or it may not be touched.
3
+ export function browser_storage() {
4
+ try {
5
+ return typeof localStorage === "undefined" ? undefined : localStorage;
6
+ }
7
+ catch {
8
+ return undefined;
9
+ }
10
+ }
11
+ // ── Reading and writing ──────────────────────────────────────────────────────
12
+ // The stored value, or the initial one whenever the stored one cannot be used.
13
+ export function read_pref(storage, key, initial, validate) {
14
+ if (!storage)
15
+ return initial;
16
+ let raw;
17
+ try {
18
+ raw = storage.getItem(key);
19
+ }
20
+ catch {
21
+ return initial;
22
+ }
23
+ if (raw === null)
24
+ return initial;
25
+ let parsed;
26
+ try {
27
+ parsed = JSON.parse(raw);
28
+ }
29
+ catch {
30
+ return initial;
31
+ }
32
+ if (validate)
33
+ return validate(parsed) ? parsed : initial;
34
+ // Without a validator the stored value must at least have the initial value's shape.
35
+ if (parsed === null || initial === null)
36
+ return parsed ?? initial;
37
+ if (Array.isArray(initial) !== Array.isArray(parsed))
38
+ return initial;
39
+ if (typeof parsed !== typeof initial)
40
+ return initial;
41
+ return parsed;
42
+ }
43
+ export function write_pref(storage, key, value) {
44
+ if (!storage)
45
+ return;
46
+ try {
47
+ storage.setItem(key, JSON.stringify(value));
48
+ }
49
+ catch {
50
+ // A full or disabled storage keeps the setting until the page closes.
51
+ }
52
+ }
53
+ // ── Validators ───────────────────────────────────────────────────────────────
54
+ export function one_of(...allowed) {
55
+ return (value) => typeof value === "string" && allowed.includes(value);
56
+ }
57
+ export function is_boolean(value) {
58
+ return typeof value === "boolean";
59
+ }
60
+ export function is_number_in(min, max) {
61
+ return (value) => typeof value === "number" && Number.isFinite(value) && value >= min && value <= max;
62
+ }
@@ -1,26 +1,26 @@
1
- import type { ComponentChildren } from "preact";
1
+ import type { ReactNode } from "react";
2
2
  export type Tone = "good" | "warn" | "bad" | "info" | "none";
3
3
  export declare function Badge({ tone, title, children }: {
4
4
  tone?: Tone;
5
5
  title?: string;
6
- children: ComponentChildren;
7
- }): import("preact").JSX.Element;
6
+ children: ReactNode;
7
+ }): import("react").JSX.Element;
8
8
  export declare function StateBadge<State extends string>({ labels, state, title }: {
9
9
  labels: Record<State, [text: string, tone: Tone]>;
10
10
  state: State;
11
11
  title?: string;
12
- }): import("preact").JSX.Element;
12
+ }): import("react").JSX.Element;
13
13
  export declare function Chip({ title, on_click, class_name, children }: {
14
14
  title?: string;
15
15
  on_click?: () => void;
16
16
  class_name?: string;
17
- children: ComponentChildren;
18
- }): import("preact").JSX.Element;
17
+ children: ReactNode;
18
+ }): import("react").JSX.Element;
19
19
  export declare function Chips({ inline, children }: {
20
20
  inline?: boolean;
21
- children: ComponentChildren;
22
- }): import("preact").JSX.Element;
21
+ children: ReactNode;
22
+ }): import("react").JSX.Element;
23
23
  export declare function Verdict({ ok, label }: {
24
24
  ok: boolean | null | undefined;
25
25
  label: string;
26
- }): import("preact").JSX.Element;
26
+ }): import("react").JSX.Element;
@@ -1,7 +1,7 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "preact/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  // ── Badges ───────────────────────────────────────────────────────────────────
3
3
  export function Badge({ tone = "none", title, children }) {
4
- return _jsx("span", { class: `badge tone-${tone}`, title: title, children: children });
4
+ return _jsx("span", { className: `badge tone-${tone}`, title: title, children: children });
5
5
  }
6
6
  // A badge for one of a fixed set of states, each named by its text and tone.
7
7
  export function StateBadge({ labels, state, title }) {
@@ -13,16 +13,16 @@ export function StateBadge({ labels, state, title }) {
13
13
  export function Chip({ title, on_click, class_name, children }) {
14
14
  const chip_class = class_name ? `chip ${class_name}` : "chip";
15
15
  return on_click
16
- ? _jsx("button", { type: "button", class: chip_class, title: title, onClick: on_click, children: children })
17
- : _jsx("span", { class: chip_class, title: title, children: children });
16
+ ? _jsx("button", { type: "button", className: chip_class, title: title, onClick: on_click, children: children })
17
+ : _jsx("span", { className: chip_class, title: title, children: children });
18
18
  }
19
19
  // A row of chips or badges, with less space between them when they sit inside a line of text.
20
20
  export function Chips({ inline = false, children }) {
21
- return _jsx("span", { class: inline ? "chips inline-chips" : "chips", children: children });
21
+ return _jsx("span", { className: inline ? "chips inline-chips" : "chips", children: children });
22
22
  }
23
23
  // ── Verdicts ─────────────────────────────────────────────────────────────────
24
24
  // A pass, fail or not-yet verdict, such as "checks: pass".
25
25
  export function Verdict({ ok, label }) {
26
26
  const known = ok !== null && ok !== undefined;
27
- return _jsxs("span", { class: `verdict tone-${known ? (ok ? "good" : "bad") : "none"}`, children: [label, ": ", known ? (ok ? "pass" : "fail") : "no verdict"] });
27
+ return _jsxs("span", { className: `verdict tone-${known ? (ok ? "good" : "bad") : "none"}`, children: [label, ": ", known ? (ok ? "pass" : "fail") : "no verdict"] });
28
28
  }