@3sln/nook 0.0.0-stage → 0.1.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.
@@ -0,0 +1,99 @@
1
+ export const attentionKey = "@3sln/nook:attention:v1";
2
+ const burstMs = 6000;
3
+ const minimumRetryMs = 45 * 60 * 1000;
4
+ const retryRangeMs = 30 * 60 * 1000;
5
+
6
+ // A document-shared lease owns the timer and browser listeners. Its persisted
7
+ // deadline prevents reloading/reconnecting from restarting attention bursts.
8
+ export function attentionResource({
9
+ owner,
10
+ storage,
11
+ hasSeen,
12
+ now = Date.now,
13
+ random = Math.random,
14
+ setTimer = (callback, delay) => setTimeout(callback, delay),
15
+ clearTimer = (timer) => clearTimeout(timer),
16
+ }) {
17
+ const listeners = new Set();
18
+ let timer;
19
+ let record;
20
+ let active = false;
21
+ const emit = (value) => {
22
+ if (active === value) return;
23
+ active = value;
24
+ for (const listener of listeners) listener(value);
25
+ };
26
+ const clear = () => {
27
+ if (timer !== undefined) clearTimer(timer);
28
+ timer = undefined;
29
+ };
30
+ const read = () => {
31
+ try {
32
+ const saved = JSON.parse(storage?.getItem(attentionKey) || "null");
33
+ if (
34
+ saved &&
35
+ Number.isFinite(saved.until) &&
36
+ Number.isFinite(saved.next) &&
37
+ saved.next >= saved.until &&
38
+ saved.next <= now() + 75 * 60 * 1000 &&
39
+ saved.until <= now() + burstMs
40
+ )
41
+ record = saved;
42
+ } catch {}
43
+ return record;
44
+ };
45
+ const write = () => {
46
+ try {
47
+ storage?.setItem(attentionKey, JSON.stringify(record));
48
+ } catch {}
49
+ };
50
+ const advance = () => {
51
+ clear();
52
+ if (!listeners.size || hasSeen() || owner?.visibilityState === "hidden") {
53
+ emit(false);
54
+ return;
55
+ }
56
+ const time = now();
57
+ read();
58
+ if (!record || time >= record.next) {
59
+ record = {
60
+ until: time + burstMs,
61
+ next:
62
+ time +
63
+ minimumRetryMs +
64
+ Math.floor(Math.max(0, Math.min(1, random())) * retryRangeMs),
65
+ };
66
+ write();
67
+ }
68
+ const inviting = time < record.until;
69
+ emit(inviting);
70
+ timer = setTimer(
71
+ advance,
72
+ Math.max(1, (inviting ? record.until : record.next) - time),
73
+ );
74
+ };
75
+ return {
76
+ subscribe(listener) {
77
+ listeners.add(listener);
78
+ listener(active);
79
+ if (listeners.size === 1) {
80
+ owner?.addEventListener?.("visibilitychange", advance);
81
+ owner?.defaultView?.addEventListener?.("storage", advance);
82
+ }
83
+ advance();
84
+ return () => {
85
+ listeners.delete(listener);
86
+ if (!listeners.size) {
87
+ clear();
88
+ active = false;
89
+ owner?.removeEventListener?.("visibilitychange", advance);
90
+ owner?.defaultView?.removeEventListener?.("storage", advance);
91
+ }
92
+ };
93
+ },
94
+ acknowledge() {
95
+ clear();
96
+ emit(false);
97
+ },
98
+ };
99
+ }
@@ -0,0 +1,35 @@
1
+ import { attentionResource } from "./attention.js";
2
+ export const onboardingKey = "@3sln/nook:tutorial:v1";
3
+ // The provider owns a page-scoped fallback. Weak ownership lets the document and
4
+ // its in-memory preferences go away together, even when storage is denied.
5
+ const sessions = new WeakMap();
6
+ export function onboardingResource({ storage, owner }) {
7
+ if (!sessions.has(owner)) sessions.set(owner, { seen: new Set() });
8
+ const page = sessions.get(owner);
9
+ const session = page.seen;
10
+ const resource = {
11
+ hasSeen() {
12
+ if (session.has(onboardingKey)) return true;
13
+ try {
14
+ return storage?.getItem(onboardingKey) === "seen";
15
+ } catch {
16
+ return false;
17
+ }
18
+ },
19
+ markSeen() {
20
+ session.add(onboardingKey);
21
+ page.attention?.acknowledge();
22
+ try {
23
+ storage?.setItem(onboardingKey, "seen");
24
+ } catch {}
25
+ },
26
+ };
27
+ page.attention ??= attentionResource({
28
+ owner,
29
+ storage,
30
+ hasSeen: () => resource.hasSeen(),
31
+ });
32
+ resource.subscribeAttention = (listener) =>
33
+ page.attention.subscribe(listener);
34
+ return resource;
35
+ }
@@ -0,0 +1,159 @@
1
+ const definitions = new Set([
2
+ "nook-action",
3
+ "nook-setting",
4
+ "nook-menu",
5
+ "nook-panel",
6
+ "nook-command",
7
+ "nook-arg",
8
+ "nook-option",
9
+ "template",
10
+ "script",
11
+ "style",
12
+ ]);
13
+ export function hasAppContent(host) {
14
+ return [...host.childNodes].some((node) =>
15
+ node.nodeType === 3
16
+ ? Boolean(node.textContent.trim())
17
+ : node.nodeType === 1 && !definitions.has(node.localName),
18
+ );
19
+ }
20
+ export default function bounds(host, { signal, onChange }) {
21
+ const view = host.ownerDocument.defaultView;
22
+ const visualViewport = view.visualViewport;
23
+ const probe = host.ownerDocument.createElement("span");
24
+ probe.hidden = true;
25
+ probe.className = "boundary-measure";
26
+ host.shadowRoot.append(probe);
27
+ let frame, previous;
28
+ const update = () => {
29
+ frame = undefined;
30
+ // Fixed UI uses layout-viewport coordinates. The keyboard can shrink and
31
+ // pan only the visual viewport without changing innerHeight.
32
+ const viewport = {
33
+ left: visualViewport?.offsetLeft ?? 0,
34
+ top: visualViewport?.offsetTop ?? 0,
35
+ width: visualViewport?.width ?? view.innerWidth,
36
+ height: visualViewport?.height ?? view.innerHeight,
37
+ };
38
+ viewport.right = viewport.left + viewport.width;
39
+ viewport.bottom = viewport.top + viewport.height;
40
+ const wrapped =
41
+ host.getAttribute("boundary") === "self" ||
42
+ (host.getAttribute("boundary") !== "viewport" && hasAppContent(host));
43
+ const raw = wrapped ? host.getBoundingClientRect() : viewport;
44
+ const style = view.getComputedStyle(probe);
45
+ const length = (name) => parseFloat(style[name]) || 0;
46
+ const left = Math.max(
47
+ viewport.left,
48
+ raw.left + length("marginLeft"),
49
+ viewport.left + length("paddingLeft"),
50
+ );
51
+ const top = Math.max(
52
+ viewport.top,
53
+ raw.top + length("marginTop"),
54
+ viewport.top + length("paddingTop"),
55
+ );
56
+ const right = Math.min(
57
+ viewport.right,
58
+ raw.right - length("marginRight"),
59
+ viewport.right - length("paddingRight"),
60
+ );
61
+ const bottom = Math.min(
62
+ viewport.bottom,
63
+ raw.bottom - length("marginBottom"),
64
+ viewport.bottom - length("paddingBottom"),
65
+ );
66
+ const rect = {
67
+ left,
68
+ top,
69
+ right,
70
+ bottom,
71
+ width: Math.max(0, right - left),
72
+ height: Math.max(0, bottom - top),
73
+ wrapped,
74
+ visible: right > left && bottom > top,
75
+ };
76
+ host.toggleAttribute("data-contained", wrapped);
77
+ host.toggleAttribute("data-bound-hidden", !rect.visible);
78
+ const values = {
79
+ top,
80
+ left,
81
+ right: view.innerWidth - right,
82
+ bottom: view.innerHeight - bottom,
83
+ width: rect.width,
84
+ height: rect.height,
85
+ };
86
+ for (const [name, value] of Object.entries(values)) {
87
+ const key = `--nook-bound-${name}`,
88
+ text = `${Math.max(0, value)}px`;
89
+ if (host.style.getPropertyValue(key) !== text)
90
+ host.style.setProperty(key, text);
91
+ }
92
+ const scheme = view.getComputedStyle(host).colorScheme;
93
+ const inherited = scheme && scheme !== "normal" ? scheme : "light dark";
94
+ if (
95
+ host.style.getPropertyValue("--nook-inherited-color-scheme") !== inherited
96
+ )
97
+ host.style.setProperty("--nook-inherited-color-scheme", inherited);
98
+ if (
99
+ !previous ||
100
+ Object.keys(rect).some((key) => rect[key] !== previous[key])
101
+ ) {
102
+ previous = rect;
103
+ onChange(rect);
104
+ }
105
+ };
106
+ const schedule = () => {
107
+ if (frame === undefined && !signal.aborted)
108
+ frame = view.requestAnimationFrame(update);
109
+ };
110
+ const observer = new view.MutationObserver((records) => {
111
+ if (
112
+ records.some(
113
+ (record) =>
114
+ record.target === host.ownerDocument.documentElement &&
115
+ record.attributeName === "lang",
116
+ )
117
+ )
118
+ host.i18n.refresh();
119
+ if (
120
+ hasAppContent(host) ||
121
+ records.some(
122
+ (record) =>
123
+ record.target === host ||
124
+ record.target === host.ownerDocument.documentElement,
125
+ )
126
+ )
127
+ schedule();
128
+ });
129
+ observer.observe(host.ownerDocument.documentElement, {
130
+ subtree: true,
131
+ childList: true,
132
+ attributes: true,
133
+ attributeFilter: ["style", "class", "hidden", "lang", "boundary"],
134
+ });
135
+ const resize = view.ResizeObserver ? new view.ResizeObserver(schedule) : null;
136
+ resize?.observe(host);
137
+ visualViewport?.addEventListener("resize", schedule, { signal });
138
+ visualViewport?.addEventListener("scroll", schedule, { signal });
139
+ view.addEventListener("resize", schedule, { signal });
140
+ view.addEventListener("scroll", schedule, { signal, capture: true });
141
+ host.appSlot.addEventListener("slotchange", schedule, { signal });
142
+ signal.addEventListener(
143
+ "abort",
144
+ () => {
145
+ observer.disconnect();
146
+ resize?.disconnect();
147
+ if (frame !== undefined) view.cancelAnimationFrame(frame);
148
+ probe.remove();
149
+ },
150
+ { once: true },
151
+ );
152
+ update();
153
+ return {
154
+ refresh: schedule,
155
+ get rect() {
156
+ return previous;
157
+ },
158
+ };
159
+ }
@@ -0,0 +1,236 @@
1
+ import { commandMatches, argumentValues } from "../bl/commands.js";
2
+
3
+ // Persistent result buttons and forms belong to the DOM renderer. Filtering only
4
+ // changes visibility; it never replaces an argument field or its current value.
5
+ export default function commandPalette({
6
+ view,
7
+ make,
8
+ i18n,
9
+ choose,
10
+ run,
11
+ supported,
12
+ createEnum,
13
+ }) {
14
+ const search = make("input", "command-search");
15
+ search.type = "search";
16
+ search.className = "command-search";
17
+ i18n.attr(search, "placeholder", "Search commands…");
18
+ i18n.attr(search, "aria-label", "Search commands");
19
+ search.autocomplete = "off";
20
+ const list = make("div", "command-list");
21
+ list.className = "command-list";
22
+ const empty = make("p", "command-empty", "No matching commands.");
23
+ empty.className = "command-empty";
24
+ empty.setAttribute("role", "status");
25
+ const results = make("div", "command-results");
26
+ results.className = "command-results";
27
+ results.append(list, empty);
28
+ view.body.append(search, results);
29
+ const records = new Map();
30
+ let active = null;
31
+ let order = [];
32
+ const status = make("span", "command-status");
33
+ status.className = "live";
34
+ status.id = "nook-command-status";
35
+ status.setAttribute("role", "status");
36
+ view.body.append(status);
37
+ search.setAttribute("aria-describedby", status.id);
38
+ const visible = () => order.filter((record) => !record.button.hidden);
39
+ const highlight = (record) => {
40
+ active = record;
41
+ i18n.text(
42
+ status,
43
+ record ? "{name}. {description}" : "",
44
+ record
45
+ ? () => ({
46
+ name: record.name.textContent,
47
+ description: record.description.textContent,
48
+ })
49
+ : undefined,
50
+ );
51
+ for (const candidate of records.values())
52
+ candidate.button.dataset.active = String(candidate === record);
53
+ };
54
+ const filter = () => {
55
+ for (const [source, record] of records) {
56
+ record.button.hidden = !commandMatches(
57
+ record.name.textContent,
58
+ record.description.textContent,
59
+ search.value,
60
+ );
61
+ }
62
+ const candidates = visible();
63
+ empty.hidden = Boolean(candidates.length);
64
+ if (!candidates.includes(active)) highlight(candidates[0] || null);
65
+ };
66
+ search.addEventListener("input", filter);
67
+ search.addEventListener("keydown", (event) => {
68
+ const candidates = visible();
69
+ if (["ArrowDown", "ArrowUp", "Enter"].includes(event.key)) {
70
+ event.preventDefault();
71
+ event.stopPropagation();
72
+ if (event.key === "Enter") {
73
+ active?.button.click();
74
+ return;
75
+ }
76
+ const delta = event.key === "ArrowDown" ? 1 : -1;
77
+ highlight(
78
+ candidates[
79
+ (candidates.indexOf(active) + delta + candidates.length) %
80
+ candidates.length
81
+ ] || null,
82
+ );
83
+ active?.button.scrollIntoView?.({ block: "nearest" });
84
+ }
85
+ });
86
+ return {
87
+ search,
88
+ reset() {
89
+ search.value = "";
90
+ filter();
91
+ },
92
+ reconcile(commands) {
93
+ const wanted = new Set(commands);
94
+ for (const [source, record] of records)
95
+ if (!wanted.has(source)) {
96
+ record.button.remove();
97
+ records.delete(source);
98
+ }
99
+ let cursor = list.firstChild;
100
+ for (const source of commands) {
101
+ let record = records.get(source);
102
+ if (!record) {
103
+ const button = make("button", "command-result");
104
+ button.type = "button";
105
+ button.className = "command-result";
106
+ const name = make("span", "command-name");
107
+ const description = make("small", "command-description");
108
+ const binding = make("kbd", "command-keybind");
109
+ binding.className = "keyboard-only";
110
+ const number = make("span", "number");
111
+ number.className = "number";
112
+ number.setAttribute("aria-hidden", "true");
113
+ button.append(name, description, binding, number);
114
+ button.onclick = () => choose(source);
115
+ record = { button, name, description, binding };
116
+ records.set(source, record);
117
+ }
118
+ const name = source.getAttribute("name").trim(),
119
+ description = source.getAttribute("description").trim();
120
+ i18n.text(record.name, name);
121
+ i18n.text(record.description, description);
122
+ const binding = source.getAttribute("keybind") || "";
123
+ if (record.binding.textContent !== binding)
124
+ record.binding.textContent = binding;
125
+ record.binding.hidden = !binding || !supported(binding);
126
+ i18n.attr(record.button, "aria-label", name);
127
+ if (record.button !== cursor) list.insertBefore(record.button, cursor);
128
+ cursor = record.button.nextSibling;
129
+ }
130
+ order = commands.map((source) => records.get(source));
131
+ filter();
132
+ },
133
+ mountForm(source, body) {
134
+ const form = make("form", "command-form");
135
+ form.className = "command-form";
136
+ const description = make("p", "command-description");
137
+ description.className = "command-description";
138
+ const fields = make("div", "command-fields");
139
+ fields.className = "command-fields";
140
+ const submit = make("button", "command-run", "Run command");
141
+ submit.type = "submit";
142
+ submit.className = "command-run";
143
+ form.append(description, fields, submit);
144
+ body.append(form);
145
+ const controls = new Map();
146
+ form.addEventListener("submit", (event) => {
147
+ event.preventDefault();
148
+ event.stopPropagation();
149
+ if (form.reportValidity()) run(source, argumentValues(form));
150
+ });
151
+ return {
152
+ form,
153
+ dispose() {
154
+ for (const record of controls.values()) record.enum?.dispose();
155
+ },
156
+ reconcile() {
157
+ i18n.text(description, source.getAttribute("description"));
158
+ const args = [...source.querySelectorAll("nook-arg")].filter(
159
+ (arg) =>
160
+ arg.closest("nook-command") === source &&
161
+ arg.getAttribute("name")?.trim() &&
162
+ !arg.hidden,
163
+ );
164
+ const wanted = new Set(args);
165
+ for (const [arg, record] of controls)
166
+ if (!wanted.has(arg)) {
167
+ record.enum?.dispose();
168
+ record.row.remove();
169
+ controls.delete(arg);
170
+ }
171
+ let cursor = fields.firstChild;
172
+ for (const arg of args) {
173
+ const type = arg.getAttribute("type") || "text";
174
+ let record = controls.get(arg);
175
+ if (record && record.type !== type) {
176
+ record.enum?.dispose();
177
+ record.row.remove();
178
+ controls.delete(arg);
179
+ record = null;
180
+ }
181
+ if (!record) {
182
+ const isEnum = ["enum", "select"].includes(type);
183
+ const row = make(isEnum ? "div" : "label", "command-field");
184
+ row.className = "command-field";
185
+ const caption = make("span", "command-arg-label");
186
+ let picker;
187
+ const control = isEnum
188
+ ? (picker = createEnum(
189
+ arg,
190
+ () => arg.getAttribute("label") || arg.getAttribute("name"),
191
+ () => {
192
+ control.dispatchEvent(
193
+ new Event("input", { bubbles: true }),
194
+ );
195
+ control.dispatchEvent(
196
+ new Event("change", { bubbles: true }),
197
+ );
198
+ },
199
+ arg.getAttribute("value") || "",
200
+ )).proxy
201
+ : make("input", "command-arg-control");
202
+ if (!isEnum) control.type = type === "toggle" ? "checkbox" : type;
203
+ control.value = arg.getAttribute("value") || "";
204
+ if (control.type === "checkbox")
205
+ control.checked = arg.getAttribute("value") === "true";
206
+ row.append(caption, picker ? picker.element : control);
207
+ record = { row, caption, control, type, enum: picker };
208
+ controls.set(arg, record);
209
+ }
210
+ const { row, caption, control } = record;
211
+ const title = arg.getAttribute("label") || arg.getAttribute("name");
212
+ i18n.text(caption, title);
213
+ control.name = arg.getAttribute("name");
214
+ control.required = arg.hasAttribute("required");
215
+ control.disabled = arg.hasAttribute("disabled");
216
+ for (const key of [
217
+ "min",
218
+ "max",
219
+ "step",
220
+ "placeholder",
221
+ "pattern",
222
+ ]) {
223
+ const value = arg.getAttribute(key);
224
+ if (value === null) control.removeAttribute(key);
225
+ else if (key === "placeholder") i18n.attr(control, key, value);
226
+ else control.setAttribute(key, value);
227
+ }
228
+ record.enum?.reconcile();
229
+ if (row !== cursor) fields.insertBefore(row, cursor);
230
+ cursor = row.nextSibling;
231
+ }
232
+ },
233
+ };
234
+ },
235
+ };
236
+ }