@3sln/nook 0.1.0 → 0.2.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.
@@ -1,25 +1,165 @@
1
- import { icon } from "./icons.js";
1
+ import { positionPoint } from "../bl/menu.js";
2
2
  const clamp = (value, min, max) => Math.max(min, Math.min(max, value));
3
- export default function swipeDial({ dock, make, i18n, signal }) {
4
- const root = make("div", "swipe-dial");
5
- root.className = "swipe-dial";
6
- root.hidden = true;
7
- root.setAttribute("aria-hidden", "true");
8
- const options = [
9
- { id: "all", label: "All actions", icon: "menu" },
10
- { id: "settings", label: "Settings", icon: "settings" },
11
- ].map((option) => {
12
- const el = make("div", "swipe-dial-option");
13
- el.className = "swipe-dial-option";
14
- const mark = make("span", "swipe-dial-icon");
15
- mark.innerHTML = icon(option.icon);
16
- const text = make("span", "swipe-dial-label", option.label);
17
- el.append(mark, text);
18
- root.append(el);
19
- return { ...option, el };
20
- });
21
- dock.overlayAnchor.append(root);
22
- let timer, pending, selected;
3
+
4
+ // One layout model serves pinned menus and held gestures. Built-ins always own
5
+ // their directional slots; extras never change fast-swipe routing.
6
+ export function layoutDial(bounds, origin, position, entries) {
7
+ const sx = position.includes("right") ? -1 : 1,
8
+ sy = position.startsWith("top") ? 1 : -1;
9
+ const horizontal = sx > 0 ? bounds.right - origin.x : origin.x - bounds.left;
10
+ const vertical = sy > 0 ? bounds.bottom - origin.y : origin.y - bounds.top;
11
+ const radius = Math.max(40, Math.min(232, horizontal - 32, vertical - 32));
12
+ const capacity = Math.max(
13
+ 2,
14
+ Math.min(
15
+ 6,
16
+ bounds.width < 360 ? 4 : 6,
17
+ 1 + Math.floor((Math.PI * radius) / 128),
18
+ ),
19
+ );
20
+ const reserved = entries.filter(
21
+ (entry) => entry.id === "all" || entry.id === "settings",
22
+ );
23
+ const extras = entries
24
+ .filter((entry) => entry.id !== "all" && entry.id !== "settings")
25
+ .slice(0, Math.max(0, capacity - reserved.length));
26
+ const chosen = new Set([...reserved, ...extras]);
27
+ const items = entries
28
+ .filter((entry) => chosen.has(entry))
29
+ .map((entry) => {
30
+ const angle =
31
+ entry.id === "all"
32
+ ? 0
33
+ : entry.id === "settings"
34
+ ? Math.PI / 2
35
+ : ((extras.indexOf(entry) + 1) * Math.PI) / 2 / (extras.length + 1);
36
+ return {
37
+ ...entry,
38
+ angle,
39
+ x: clamp(
40
+ origin.x + sx * radius * Math.cos(angle),
41
+ bounds.left + 32,
42
+ bounds.right - 32,
43
+ ),
44
+ y: clamp(
45
+ origin.y + sy * radius * Math.sin(angle),
46
+ bounds.top + 32,
47
+ bounds.bottom - 32,
48
+ ),
49
+ };
50
+ });
51
+ const overlaps = (a, b, gap = 4) =>
52
+ a.left < b.right + gap &&
53
+ a.right > b.left - gap &&
54
+ a.top < b.bottom + gap &&
55
+ a.bottom > b.top - gap;
56
+ const icons = items.map((item) => ({
57
+ left: item.x - 28,
58
+ right: item.x + 28,
59
+ top: item.y - 28,
60
+ bottom: item.y + 28,
61
+ }));
62
+ const captions = [];
63
+ const candidates = items
64
+ .map((item) => {
65
+ const dx = sx * Math.cos(item.angle),
66
+ dy = sy * Math.sin(item.angle);
67
+ const offsets = [
68
+ [dx * 92, dy * 72],
69
+ [-dx * 92, -dy * 72],
70
+ [0, -64],
71
+ [0, 64],
72
+ [-88, 0],
73
+ [88, 0],
74
+ [-88, -64],
75
+ [88, -64],
76
+ [-88, 64],
77
+ [88, 64],
78
+ [0, -112],
79
+ [0, 112],
80
+ ];
81
+ const choices = offsets
82
+ .map(([x, y]) => {
83
+ const cx = clamp(item.x + x, bounds.left + 52, bounds.right - 52);
84
+ const cy = clamp(item.y + y, bounds.top + 26, bounds.bottom - 26);
85
+ return {
86
+ x: cx,
87
+ y: cy,
88
+ left: cx - 48,
89
+ right: cx + 48,
90
+ top: cy - 22,
91
+ bottom: cy + 22,
92
+ };
93
+ })
94
+ .filter((candidate) => icons.every((box) => !overlaps(candidate, box)));
95
+ return { item, choices };
96
+ })
97
+ .sort((a, b) => a.choices.length - b.choices.length);
98
+ for (const { item, choices } of candidates) {
99
+ const fitted = choices.find((candidate) =>
100
+ captions.every((box) => !overlaps(candidate, box)),
101
+ );
102
+ const location = fitted || { x: item.x, y: item.y };
103
+ item.captionFits = Boolean(fitted);
104
+ item.captionX = location.x - item.x;
105
+ item.captionY = location.y - item.y;
106
+ if (fitted) captions.push(location);
107
+ }
108
+ return { grid: false, radius, items };
109
+ }
110
+
111
+ // Arrow keys follow screen direction rather than the source declaration order.
112
+ export function directionalChoice(nodes, current, key) {
113
+ const direction = {
114
+ ArrowLeft: [-1, 0],
115
+ ArrowRight: [1, 0],
116
+ ArrowUp: [0, -1],
117
+ ArrowDown: [0, 1],
118
+ }[key];
119
+ if (!direction || !current) return nodes[0];
120
+ const center = (node) => {
121
+ const box = node.getBoundingClientRect();
122
+ return [box.left + box.width / 2, box.top + box.height / 2];
123
+ };
124
+ const [x, y] = center(current);
125
+ return (
126
+ nodes
127
+ .filter((node) => node !== current)
128
+ .map((node) => {
129
+ const [nx, ny] = center(node);
130
+ const dx = nx - x,
131
+ dy = ny - y;
132
+ return {
133
+ node,
134
+ forward: dx * direction[0] + dy * direction[1],
135
+ distance: Math.hypot(dx, dy),
136
+ };
137
+ })
138
+ .filter(({ forward }) => forward > 1)
139
+ .sort((a, b) => a.distance - b.distance)[0]?.node || current
140
+ );
141
+ }
142
+
143
+ export default function swipeDial({ dock, i18n, signal }) {
144
+ const view = dock.views.get("root"),
145
+ root = view.el;
146
+ root.classList.add("swipe-dial");
147
+ root.setAttribute("part", "surface swipe-dial");
148
+ let timer,
149
+ pending,
150
+ selected,
151
+ preview = false;
152
+ let options = [];
153
+ const alias = (node, name, add) => {
154
+ if (!node) return;
155
+ const parts = new Set(
156
+ (node.getAttribute("part") || "").split(/\s+/).filter(Boolean),
157
+ );
158
+ if (add) parts.add(name);
159
+ else parts.delete(name);
160
+ const value = [...parts].join(" ");
161
+ if (node.getAttribute("part") !== value) node.setAttribute("part", value);
162
+ };
23
163
  const rect = () =>
24
164
  dock.anchorRect || {
25
165
  left: 0,
@@ -29,141 +169,221 @@ export default function swipeDial({ dock, make, i18n, signal }) {
29
169
  width: dock.ownerDocument.defaultView.innerWidth,
30
170
  height: dock.ownerDocument.defaultView.innerHeight,
31
171
  };
32
- const render = (pointer) => {
33
- const bounds = rect();
34
- const distance = Math.hypot(
35
- pointer.lastX - pointer.x,
36
- pointer.lastY - pointer.y,
37
- );
38
- root.style.setProperty(
39
- "--nook-dial-progress",
40
- String(clamp((distance - 28) / 72, 0, 1)),
41
- );
42
- root.style.setProperty(
43
- "--nook-dial-origin-x",
44
- `${pointer.x - bounds.left}px`,
45
- );
46
- root.style.setProperty(
47
- "--nook-dial-origin-y",
48
- `${pointer.y - bounds.top}px`,
49
- );
50
- const position = dock.position;
51
- const sx = position.includes("right") ? -1 : 1,
52
- sy = position.startsWith("bottom")
53
- ? -1
54
- : position.startsWith("top")
55
- ? 1
56
- : -1;
57
- const points = {
58
- all: [pointer.x + sx * 152, pointer.y],
59
- settings: [pointer.x, pointer.y + sy * 152],
172
+ const origin = () => {
173
+ const box = dock.handle.getBoundingClientRect();
174
+ if (box.width && box.height)
175
+ return {
176
+ x: clamp(box.left + box.width / 2, rect().left + 32, rect().right - 32),
177
+ y: clamp(box.top + box.height / 2, rect().top + 32, rect().bottom - 32),
178
+ };
179
+ const b = rect(),
180
+ [x, y] = positionPoint(dock.position, b.width, b.height);
181
+ return {
182
+ x: b.left + clamp(x, 32, b.width - 32),
183
+ y: b.top + clamp(y, 32, b.height - 32),
60
184
  };
61
- const placed = [];
185
+ };
186
+ const pinned = () => dock.state?.path.at(-1) === "root";
187
+ const collect = () => {
188
+ const next = (dock.rootEntries || [])
189
+ .map((entry) => ({
190
+ id: entry.id,
191
+ el:
192
+ typeof entry.id === "string"
193
+ ? dock.builtins.get(entry.id)
194
+ : dock.records.get(entry.id)?.buttons[0],
195
+ }))
196
+ .filter((option) => option.el && !option.el.hidden);
197
+ const wanted = new Set(next.map((option) => option.el));
198
+ for (const old of options)
199
+ if (!wanted.has(old.el)) {
200
+ alias(old.el, "swipe-dial-option", false);
201
+ alias(old.el.querySelector(".icon"), "swipe-dial-icon", false);
202
+ alias(old.el.querySelector(".item-label"), "swipe-dial-label", false);
203
+ const caption = old.el.querySelector(".dial-caption");
204
+ if (caption) caption.replaceWith(...caption.childNodes);
205
+ old.el.removeAttribute("data-dial-hidden");
206
+ old.el.removeAttribute("data-selected");
207
+ }
208
+ for (const option of next) {
209
+ const text = option.el.querySelector(".item-label");
210
+ if (text && text.parentNode === option.el) {
211
+ const caption = dock.ownerDocument.createElement("span");
212
+ caption.className = "dial-caption";
213
+ caption.setAttribute("part", "dial-caption");
214
+ text.before(caption);
215
+ caption.append(text);
216
+ }
217
+ alias(option.el, "swipe-dial-option", true);
218
+ alias(option.el.querySelector(".icon"), "swipe-dial-icon", true);
219
+ alias(option.el.querySelector(".item-label"), "swipe-dial-label", true);
220
+ }
221
+ options = next;
222
+ };
223
+ const render = () => {
224
+ collect();
225
+ const b = rect(),
226
+ o = origin();
227
+ const layout = layoutDial(b, o, dock.position, options);
228
+ root.dataset.dialLayout = layout.grid ? "grid" : "radial";
229
+ root.dataset.labelSide = dock.position.startsWith("bottom")
230
+ ? "above"
231
+ : "below";
232
+ root.style.setProperty("--nook-dial-origin-x", `${o.x - b.left}px`);
233
+ root.style.setProperty("--nook-dial-origin-y", `${o.y - b.top}px`);
234
+ const fitted = new Map(layout.items.map((item) => [item.id, item]));
62
235
  let next = null;
63
236
  for (const option of options) {
64
- option.el.hidden = !dock.surfaceAvailable(option.id);
65
- if (option.el.hidden) continue;
66
- const point = points[option.id];
67
- option.x = clamp(point[0], bounds.left + 52, bounds.right - 52);
68
- option.y = clamp(point[1], bounds.top + 44, bounds.bottom - 44);
69
- // Preserve a clear inner ring even when viewport clamping moves a target.
70
- // Omit targets that cannot fit rather than overlapping the handle or peers.
71
- if (
72
- Math.hypot(option.x - pointer.x, option.y - pointer.y) < 104 ||
73
- placed.some(
74
- (other) =>
75
- Math.abs(option.x - other.x) < 96 &&
76
- Math.abs(option.y - other.y) < 80,
77
- )
78
- ) {
79
- option.el.hidden = true;
80
- continue;
81
- }
82
- placed.push(option);
83
- option.el.style.left = `${option.x - bounds.left}px`;
84
- option.el.style.top = `${option.y - bounds.top}px`;
85
- const target = option.el.getBoundingClientRect();
237
+ const location = fitted.get(option.id);
238
+ option.el.toggleAttribute("data-dial-hidden", !location);
239
+ option.el.removeAttribute("data-caption-visible");
240
+ if (!location) continue;
241
+ option.el.dataset.dialOrder = String(location.angle);
242
+ option.el.style.setProperty("--nook-dial-x", `${location.x - b.left}px`);
243
+ option.el.style.setProperty("--nook-dial-y", `${location.y - b.top}px`);
244
+ option.el.style.setProperty("--nook-caption-x", `${location.captionX}px`);
245
+ option.el.style.setProperty("--nook-caption-y", `${location.captionY}px`);
246
+ const caption = option.el.querySelector(".dial-caption");
247
+ if (caption) caption.hidden = !location.captionFits;
248
+ }
249
+ // Measure after all positions are written. Actual font/part overrides can
250
+ // make a caption taller than the conservative model; omit those captions.
251
+ const visible = options
252
+ .filter((option) => fitted.has(option.id))
253
+ .map((option) => ({ option, box: option.el.getBoundingClientRect() }));
254
+ const captions = [];
255
+ const overlaps = (a, b) =>
256
+ a.left < b.right + 3 &&
257
+ a.right > b.left - 3 &&
258
+ a.top < b.bottom + 3 &&
259
+ a.bottom > b.top - 3;
260
+ for (const { option, box } of visible) {
86
261
  if (
87
- target.width > 0 &&
88
- target.height > 0 &&
89
- pointer.lastX >= target.left &&
90
- pointer.lastX <= target.right &&
91
- pointer.lastY >= target.top &&
92
- pointer.lastY <= target.bottom
262
+ preview &&
263
+ pending &&
264
+ box.width &&
265
+ box.height &&
266
+ pending.lastX >= box.left &&
267
+ pending.lastX <= box.right &&
268
+ pending.lastY >= box.top &&
269
+ pending.lastY <= box.bottom
93
270
  )
94
271
  next = option;
272
+ const caption = option.el.querySelector(".dial-caption");
273
+ if (!caption || caption.hidden) continue;
274
+ const labelBox = caption.getBoundingClientRect();
275
+ if (!labelBox.width || !labelBox.height) continue;
276
+ if (
277
+ labelBox.left < b.left ||
278
+ labelBox.right > b.right ||
279
+ labelBox.top < b.top ||
280
+ labelBox.bottom > b.bottom ||
281
+ visible.some(({ box: icon }) => overlaps(labelBox, icon)) ||
282
+ captions.some((other) => overlaps(labelBox, other))
283
+ )
284
+ caption.hidden = true;
285
+ else {
286
+ captions.push(labelBox);
287
+ option.el.setAttribute("data-caption-visible", "");
288
+ if (
289
+ preview &&
290
+ pending &&
291
+ pending.lastX >= labelBox.left &&
292
+ pending.lastX <= labelBox.right &&
293
+ pending.lastY >= labelBox.top &&
294
+ pending.lastY <= labelBox.bottom
295
+ )
296
+ next = option;
297
+ }
95
298
  }
96
- if (
97
- distance < 56 ||
98
- pointer.lastX < bounds.left - 16 ||
99
- pointer.lastX > bounds.right + 16 ||
100
- pointer.lastY < bounds.top - 16 ||
101
- pointer.lastY > bounds.bottom + 16
102
- )
103
- next = null;
299
+ if (preview && pending) {
300
+ const distance = Math.hypot(
301
+ pending.lastX - pending.x,
302
+ pending.lastY - pending.y,
303
+ );
304
+ root.style.setProperty(
305
+ "--nook-dial-progress",
306
+ String(clamp((distance - 28) / 72, 0, 1)),
307
+ );
308
+ if (distance < 56) next = null;
309
+ } else root.style.setProperty("--nook-dial-progress", "1");
104
310
  for (const option of options)
105
311
  option.el.toggleAttribute("data-selected", option === next);
106
- if (next !== selected) {
312
+ if (selected?.id !== next?.id) {
107
313
  selected = next;
108
314
  i18n.text(
109
315
  dock.live,
110
- next ? "Release to open {menu}." : "",
111
- next ? () => ({ menu: i18n.t(next.label) }) : undefined,
316
+ next ? "Release to choose {label}." : "",
317
+ next
318
+ ? () => ({ label: next.el.getAttribute("aria-label") })
319
+ : undefined,
112
320
  );
113
- }
321
+ } else selected = next;
322
+ };
323
+ const sync = () => {
324
+ collect();
325
+ const active = pinned();
326
+ root.toggleAttribute("data-preview", preview);
327
+ root.setAttribute("aria-hidden", String(!active));
328
+ if (active || preview) {
329
+ root.hidden = false;
330
+ root.inert = !active;
331
+ render();
332
+ } else if (!view.exit) root.hidden = true;
114
333
  };
115
334
  const cancel = () => {
116
335
  clearTimeout(timer);
117
336
  timer = undefined;
118
337
  pending = null;
119
338
  selected = null;
120
- root.hidden = true;
121
- root.style.setProperty("--nook-dial-progress", "0");
339
+ preview = false;
340
+ root.removeAttribute("data-preview");
341
+ sync();
122
342
  };
123
343
  const move = (pointer) => {
124
344
  if (pointer.drag) return;
125
- if (!root.hidden) {
126
- render(pointer);
345
+ pending = pointer;
346
+ if (preview) {
347
+ render();
127
348
  return;
128
349
  }
129
350
  if (Math.hypot(pointer.lastX - pointer.x, pointer.lastY - pointer.y) < 28)
130
351
  return;
131
352
  pointer.swipeStarted = true;
132
353
  if (timer !== undefined) return;
133
- pending = pointer;
134
354
  timer = setTimeout(() => {
135
355
  timer = undefined;
136
356
  if (signal.aborted || dock.pointer !== pending || pending.drag) return;
137
- const bounds = rect();
138
- if (bounds.width < 144 || bounds.height < 144) return;
139
- root.hidden = false;
140
- render(pending);
357
+ preview = true;
358
+ sync();
141
359
  }, 180);
142
360
  };
143
361
  signal.addEventListener(
144
362
  "abort",
145
363
  () => {
146
- cancel();
147
- root.remove();
364
+ clearTimeout(timer);
365
+ preview = false;
366
+ pending = null;
148
367
  },
149
368
  { once: true },
150
369
  );
151
370
  return {
152
371
  move,
153
372
  cancel,
373
+ sync,
374
+ root,
154
375
  release(x, y) {
155
- if (root.hidden || !pending) return null;
376
+ if (!preview || !pending) return null;
156
377
  pending.lastX = x;
157
378
  pending.lastY = y;
158
- render(pending);
379
+ render();
159
380
  return selected?.id || null;
160
381
  },
161
382
  get shown() {
162
- return !root.hidden;
383
+ return preview;
163
384
  },
164
385
  get selection() {
165
386
  return selected?.id || null;
166
387
  },
167
- root,
168
388
  };
169
389
  }
@@ -0,0 +1,56 @@
1
+ const node = (x, y, name, extra = "") =>
2
+ `<g class="tutorial-node ${extra}" transform="translate(${x} ${y})"><circle r="18"/><svg x="-9" y="-9" width="18" height="18" viewBox="0 0 24 24"><use href="#nook-icon-${name}"/></svg></g>`;
3
+ const nook = `<path class="tutorial-nook" d="M244 142a24 24 0 0 1 24-24v24Z"/><svg x="251" y="125" width="13" height="13" viewBox="0 0 24 24"><use href="#nook-icon-nook"/></svg>`;
4
+ const frame = `<rect class="tutorial-screen" x="12" y="6" width="256" height="136" rx="12"/><path class="tutorial-lines" d="M30 26h72m-72 10h48m-48 10h60"/>`;
5
+ const arc = `<path class="tutorial-path" d="M156 124a92 92 0 0 1 92-92"/>${node(156, 124, "menu", "tutorial-pick-actions")}${node(183, 59, "feedback")}${node(248, 32, "settings", "tutorial-pick-settings")}`;
6
+ const diagrams = {
7
+ touch: `${frame}${arc}${nook}<path class="tutorial-direction" d="M240 124h-58m7-5-7 5 7 5m59-13V60m-5 7 5-7 5 7"/><g class="tutorial-swipe-cursor"><circle class="tutorial-contact" cx="248" cy="124" r="8"/><circle class="tutorial-contact-ring" cx="248" cy="124" r="13"/></g>`,
8
+ keyboard: `${frame}${arc}${nook}<g class="tutorial-keys"><rect x="28" y="94" width="36" height="30" rx="6"/><rect class="tutorial-enter" x="70" y="94" width="56" height="30" rx="6"/><text x="46" y="114">→</text><text x="98" y="114" class="tutorial-enter-label">Enter</text></g><circle class="tutorial-key-focus tutorial-focus-one" cx="156" cy="124" r="23"/><circle class="tutorial-key-focus tutorial-focus-two" cx="183" cy="59" r="23"/>`,
9
+ drag: `${frame}<path class="tutorial-direction" d="m235 115-184-82m4 7-4-7 9-1"/><g class="tutorial-targets"><rect x="16" y="10" width="28" height="28" rx="7"/><rect x="236" y="10" width="28" height="28" rx="7"/><rect x="16" y="110" width="28" height="28" rx="7"/></g><g class="tutorial-drag-home">${nook}</g><g class="tutorial-drag-preview">${node(30, 24, "nook")}</g><circle class="tutorial-hold" cx="248" cy="124" r="15"/><g class="tutorial-drag-cursor"><circle class="tutorial-contact" cx="248" cy="124" r="8"/></g>`,
10
+ };
11
+ export function tutorialGraphic(make, kind) {
12
+ const figure = make("figure", `tutorial-graphic tutorial-${kind}-graphic`);
13
+ figure.className = `tutorial-graphic tutorial-graphic-${kind}`;
14
+ figure.setAttribute("aria-hidden", "true");
15
+ figure.innerHTML = `<svg viewBox="0 0 280 150" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" focusable="false">${diagrams[kind]}</svg>`;
16
+ return figure;
17
+ }
18
+ export const tutorialGraphicsStyles = `
19
+ .tutorial-graphic { margin: 12px auto 20px; max-width: var(--nook-tutorial-graphic-width,360px); border: 1px solid var(--nook-border); border-radius: 12px; overflow: hidden; background: color-mix(in srgb,var(--nook-surface) 85%,var(--nook-accent) 3%); }
20
+ .tutorial-graphic > svg { display: block; width: 100%; height: auto; color: var(--nook-text); }
21
+ .tutorial-screen { stroke: var(--nook-border); fill: var(--nook-surface); }
22
+ .tutorial-lines { stroke: var(--nook-border); }
23
+ .tutorial-path, .tutorial-direction { stroke: var(--nook-muted); stroke-dasharray: 3 5; opacity: .5; }
24
+ .tutorial-node circle { fill: var(--nook-surface); stroke: var(--nook-border); }
25
+ .tutorial-node svg { color: var(--nook-text); }
26
+ .tutorial-nook { fill: var(--nook-accent); stroke: var(--nook-accent); }
27
+ .tutorial-nook + svg { color: var(--nook-on-accent); }
28
+ .tutorial-contact { fill: var(--nook-surface); stroke: var(--nook-accent); stroke-width: 3; }
29
+ .tutorial-contact-ring { stroke: var(--nook-accent); opacity: .3; }
30
+ .tutorial-swipe-cursor { animation: tutorial-swipe 6s ease-in-out infinite; }
31
+ .tutorial-graphic-touch .tutorial-pick-actions circle { animation: tutorial-actions 6s ease-in-out infinite; }
32
+ .tutorial-graphic-touch .tutorial-pick-settings circle { animation: tutorial-settings 6s ease-in-out infinite; }
33
+ @keyframes tutorial-swipe { 0%,8%,46%,54%,96%,100% { transform: translate(0,0); opacity: 0; } 12%,42%,58%,92% { opacity: 1; } 30%,40% { transform: translate(-92px,0); } 78%,90% { transform: translate(0,-92px); } }
34
+ @keyframes tutorial-actions { 0%,20%,46%,100% { fill: var(--nook-surface); stroke: var(--nook-border); } 30%,40% { fill: color-mix(in srgb,var(--nook-accent) 16%,var(--nook-surface)); stroke: var(--nook-accent); } }
35
+ @keyframes tutorial-settings { 0%,65%,96%,100% { fill: var(--nook-surface); stroke: var(--nook-border); } 78%,90% { fill: color-mix(in srgb,var(--nook-accent) 16%,var(--nook-surface)); stroke: var(--nook-accent); } }
36
+ .tutorial-keys rect { fill: var(--nook-surface); stroke: var(--nook-border); }
37
+ .tutorial-keys text { fill: var(--nook-text); stroke: none; text-anchor: middle; font: 12px var(--nook-font); }
38
+ .tutorial-key-focus { stroke: var(--nook-accent); }
39
+ .tutorial-focus-one { animation: tutorial-focus-one 5s ease-in-out infinite; }
40
+ .tutorial-focus-two { animation: tutorial-focus-two 5s ease-in-out infinite; opacity: 0; }
41
+ .tutorial-enter { animation: tutorial-key-press 5s ease-in-out infinite; }
42
+ @keyframes tutorial-focus-one { 0%,25%,95%,100% { opacity: 1; } 35%,85% { opacity: 0; } }
43
+ @keyframes tutorial-focus-two { 0%,25%,95%,100% { opacity: 0; } 35%,85% { opacity: 1; } }
44
+ @keyframes tutorial-key-press { 0%,55%,90%,100% { stroke: var(--nook-border); } 65%,80% { stroke: var(--nook-accent); } }
45
+ .tutorial-targets rect { stroke: var(--nook-accent); stroke-dasharray: 3 4; opacity: .4; }
46
+ .tutorial-hold { stroke: var(--nook-accent); stroke-dasharray: 94; stroke-dashoffset: 94; animation: tutorial-hold 6s linear infinite; }
47
+ .tutorial-drag-cursor { animation: tutorial-drag 6s ease-in-out infinite; }
48
+ .tutorial-drag-preview { opacity: .3; animation: tutorial-preview 6s ease-in-out infinite; }
49
+ .tutorial-drag-home { animation: tutorial-home 6s ease-in-out infinite; }
50
+ @keyframes tutorial-hold { 0%,5%,90%,100% { stroke-dashoffset: 94; opacity: 0; } 10% { stroke-dashoffset: 94; opacity: 1; } 30% { stroke-dashoffset: 0; opacity: 1; } 40%,80% { stroke-dashoffset: 0; opacity: 0; } }
51
+ @keyframes tutorial-drag { 0%,30%,95%,100% { transform: translate(0,0); opacity: 0; } 5%,65% { opacity: 1; } 65%,85% { transform: translate(-218px,-100px); } 85% { opacity: 0; } }
52
+ @keyframes tutorial-preview { 0%,25%,95%,100% { opacity: .15; } 40%,85% { opacity: 1; } }
53
+ @keyframes tutorial-home { 0%,65%,95%,100% { opacity: 1; } 72%,85% { opacity: 0; } }
54
+ @media (prefers-reduced-motion: reduce) { .tutorial-graphic * { animation: none !important; } }
55
+ @media (max-width:480px) { .tutorial-graphic { max-width: var(--nook-tutorial-graphic-width,280px); margin-block: 8px 12px; } }
56
+ `;