@3sln/nook 0.0.0-stage → 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.
- package/LICENSE +21 -0
- package/README.md +666 -2
- package/package.json +52 -4
- package/src/bl/commands.js +27 -0
- package/src/bl/menu.js +314 -0
- package/src/capture.js +129 -0
- package/src/index.js +2308 -0
- package/src/providers/attention.js +99 -0
- package/src/providers/onboarding.js +36 -0
- package/src/ui/bounds.js +160 -0
- package/src/ui/commandPalette.js +236 -0
- package/src/ui/enumPicker.js +307 -0
- package/src/ui/feedback.js +576 -0
- package/src/ui/icons.js +46 -0
- package/src/ui/keyboardLabels.js +31 -0
- package/src/ui/localization.js +95 -0
- package/src/ui/motion.js +49 -0
- package/src/ui/styles.js +1960 -0
- package/src/ui/swipeDial.js +389 -0
- package/src/ui/tutorialGraphics.js +56 -0
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
import { positionPoint } from "../bl/menu.js";
|
|
2
|
+
const clamp = (value, min, max) => Math.max(min, Math.min(max, value));
|
|
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
|
+
};
|
|
163
|
+
const rect = () =>
|
|
164
|
+
dock.anchorRect || {
|
|
165
|
+
left: 0,
|
|
166
|
+
top: 0,
|
|
167
|
+
right: dock.ownerDocument.defaultView.innerWidth,
|
|
168
|
+
bottom: dock.ownerDocument.defaultView.innerHeight,
|
|
169
|
+
width: dock.ownerDocument.defaultView.innerWidth,
|
|
170
|
+
height: dock.ownerDocument.defaultView.innerHeight,
|
|
171
|
+
};
|
|
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),
|
|
184
|
+
};
|
|
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]));
|
|
235
|
+
let next = null;
|
|
236
|
+
for (const option of options) {
|
|
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) {
|
|
261
|
+
if (
|
|
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
|
|
270
|
+
)
|
|
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
|
+
}
|
|
298
|
+
}
|
|
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");
|
|
310
|
+
for (const option of options)
|
|
311
|
+
option.el.toggleAttribute("data-selected", option === next);
|
|
312
|
+
if (selected?.id !== next?.id) {
|
|
313
|
+
selected = next;
|
|
314
|
+
i18n.text(
|
|
315
|
+
dock.live,
|
|
316
|
+
next ? "Release to choose {label}." : "",
|
|
317
|
+
next
|
|
318
|
+
? () => ({ label: next.el.getAttribute("aria-label") })
|
|
319
|
+
: undefined,
|
|
320
|
+
);
|
|
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;
|
|
333
|
+
};
|
|
334
|
+
const cancel = () => {
|
|
335
|
+
clearTimeout(timer);
|
|
336
|
+
timer = undefined;
|
|
337
|
+
pending = null;
|
|
338
|
+
selected = null;
|
|
339
|
+
preview = false;
|
|
340
|
+
root.removeAttribute("data-preview");
|
|
341
|
+
sync();
|
|
342
|
+
};
|
|
343
|
+
const move = (pointer) => {
|
|
344
|
+
if (pointer.drag) return;
|
|
345
|
+
pending = pointer;
|
|
346
|
+
if (preview) {
|
|
347
|
+
render();
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
if (Math.hypot(pointer.lastX - pointer.x, pointer.lastY - pointer.y) < 28)
|
|
351
|
+
return;
|
|
352
|
+
pointer.swipeStarted = true;
|
|
353
|
+
if (timer !== undefined) return;
|
|
354
|
+
timer = setTimeout(() => {
|
|
355
|
+
timer = undefined;
|
|
356
|
+
if (signal.aborted || dock.pointer !== pending || pending.drag) return;
|
|
357
|
+
preview = true;
|
|
358
|
+
sync();
|
|
359
|
+
}, 180);
|
|
360
|
+
};
|
|
361
|
+
signal.addEventListener(
|
|
362
|
+
"abort",
|
|
363
|
+
() => {
|
|
364
|
+
clearTimeout(timer);
|
|
365
|
+
preview = false;
|
|
366
|
+
pending = null;
|
|
367
|
+
},
|
|
368
|
+
{ once: true },
|
|
369
|
+
);
|
|
370
|
+
return {
|
|
371
|
+
move,
|
|
372
|
+
cancel,
|
|
373
|
+
sync,
|
|
374
|
+
root,
|
|
375
|
+
release(x, y) {
|
|
376
|
+
if (!preview || !pending) return null;
|
|
377
|
+
pending.lastX = x;
|
|
378
|
+
pending.lastY = y;
|
|
379
|
+
render();
|
|
380
|
+
return selected?.id || null;
|
|
381
|
+
},
|
|
382
|
+
get shown() {
|
|
383
|
+
return preview;
|
|
384
|
+
},
|
|
385
|
+
get selection() {
|
|
386
|
+
return selected?.id || null;
|
|
387
|
+
},
|
|
388
|
+
};
|
|
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
|
+
`;
|