yapin.js-client 0.1.0 → 0.1.5
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 +46 -0
- package/README.md +1 -1
- package/docs/README.md +1 -0
- package/docs/about-page.md +19 -2
- package/docs/configuration.md +47 -2
- package/docs/getting-started.md +1 -1
- package/docs/installer.md +39 -12
- package/docs/ui.md +50 -3
- package/docs/welcome.md +63 -0
- package/package.json +1 -1
- package/src/build/build.js +63 -24
- package/src/build/nsis.js +763 -0
- package/src/config/defaults.js +37 -3
- package/src/config/load.js +10 -2
- package/src/desktop/preload.cjs +3 -1
- package/src/desktop/shim.cjs +60 -11
- package/src/installer/main.js +5 -0
- package/src/installer/nsis.js +75 -0
- package/src/installer/operations.js +23 -18
- package/src/installer/page.js +29 -0
- package/src/installer/processes.js +74 -19
- package/src/runtime/about.js +126 -36
- package/src/runtime/builtin-pages.js +2 -2
- package/src/runtime/client.js +22 -2
- package/src/runtime/onboarding.js +196 -0
- package/src/runtime/ui.js +85 -10
- package/src/shared/constants.js +1 -1
- package/src/shared/icons.js +80 -0
- package/templates/init/icons/.gitkeep +0 -0
- package/templates/init/yapin-client.config.js +6 -0
- package/types/index.d.ts +47 -1
- package/types/runtime.d.ts +30 -2
package/src/runtime/about.js
CHANGED
|
@@ -26,11 +26,11 @@ export class AboutPage {
|
|
|
26
26
|
const c = this.client;
|
|
27
27
|
const { build = {}, git = null } = c.manifest;
|
|
28
28
|
const env = c.environment;
|
|
29
|
-
this.section('app', { title: null, order: 0 });
|
|
29
|
+
this.section('app', { title: null, order: 0, layout: 'header' });
|
|
30
30
|
this.section('build', { title: 'Build', order: 10 });
|
|
31
31
|
this.section('git', { title: 'Source', order: 20 });
|
|
32
32
|
this.section('environment', { title: 'Environment', order: 30 });
|
|
33
|
-
this.section('links', { title: 'Links', order: 40 });
|
|
33
|
+
this.section('links', { title: 'Links', order: 40, layout: 'chips' });
|
|
34
34
|
|
|
35
35
|
this.set('app.name', { label: 'Name', value: c.name, order: 0 });
|
|
36
36
|
this.set('app.version', { label: 'Version', value: c.version, copy: true, order: 1 });
|
|
@@ -67,16 +67,20 @@ export class AboutPage {
|
|
|
67
67
|
|
|
68
68
|
this.action('copy', {
|
|
69
69
|
label: 'Copy debug info',
|
|
70
|
+
icon: 'copy',
|
|
70
71
|
order: 0,
|
|
71
72
|
run: async () => {
|
|
72
73
|
await navigator.clipboard.writeText(this.debugInfo());
|
|
73
74
|
c.ui.toast('Debug info copied', { type: 'success' });
|
|
74
75
|
},
|
|
75
76
|
});
|
|
77
|
+
if (c.welcome?.enabled) this.action('welcome', { label: 'Welcome screen', icon: 'sparkles', order: 3, variant: 'ghost', run: () => c.welcome.show() });
|
|
78
|
+
if (c.whatsNew?.enabled && c.whatsNew.config.entries?.length) this.action('whatsNew', { label: "What's new", icon: 'rocket', order: 4, variant: 'ghost', run: () => c.whatsNew.show() });
|
|
76
79
|
if (c.native) {
|
|
77
|
-
this.action('folder', { label: '
|
|
80
|
+
this.action('folder', { label: 'Data folder', icon: 'folder', order: 1, variant: 'ghost', run: () => c.native.openFolder('data') });
|
|
78
81
|
this.action('safeMode', {
|
|
79
|
-
label: c.safeMode ? 'Restart normally' : '
|
|
82
|
+
label: c.safeMode ? 'Restart normally' : 'Safe mode',
|
|
83
|
+
icon: 'shield',
|
|
80
84
|
order: 2,
|
|
81
85
|
variant: 'ghost',
|
|
82
86
|
run: async () => {
|
|
@@ -103,14 +107,29 @@ export class AboutPage {
|
|
|
103
107
|
}
|
|
104
108
|
}
|
|
105
109
|
|
|
106
|
-
/**
|
|
107
|
-
|
|
110
|
+
/**
|
|
111
|
+
* Adds or changes a section: section('support', { title: 'Support', order: 35, layout: 'rows' }).
|
|
112
|
+
* Layouts: 'rows' (label and value, the default), 'chips' (compact pills, for links) and 'header'
|
|
113
|
+
* (the card at the top: its `name`, `version` and `description` fields become the title, a version
|
|
114
|
+
* tag and the line under it; any other field is shown small underneath).
|
|
115
|
+
*/
|
|
116
|
+
section(id, { title, order, layout } = {}) {
|
|
108
117
|
const existing = this.#sections.get(id);
|
|
109
|
-
this.#sections.set(id, {
|
|
118
|
+
this.#sections.set(id, {
|
|
119
|
+
id,
|
|
120
|
+
title: title === undefined ? existing?.title ?? id : title,
|
|
121
|
+
order: order ?? existing?.order ?? 100,
|
|
122
|
+
layout: LAYOUTS.has(layout) ? layout : existing?.layout ?? 'rows',
|
|
123
|
+
});
|
|
110
124
|
this.#changed();
|
|
111
125
|
return this;
|
|
112
126
|
}
|
|
113
127
|
|
|
128
|
+
/** The sections, in order: [{ id, title, order, layout }]. */
|
|
129
|
+
sections() {
|
|
130
|
+
return this.#sortedSections().map((s) => ({ ...s }));
|
|
131
|
+
}
|
|
132
|
+
|
|
114
133
|
removeSection(id) {
|
|
115
134
|
this.#sections.delete(id);
|
|
116
135
|
for (const key of [...this.#fields.keys()]) if (key.startsWith(`${id}.`)) this.#fields.delete(key);
|
|
@@ -148,10 +167,10 @@ export class AboutPage {
|
|
|
148
167
|
return [...this.#fields.values()].map((f) => ({ ...f }));
|
|
149
168
|
}
|
|
150
169
|
|
|
151
|
-
/** A button
|
|
152
|
-
action(id, { label, run, variant = 'primary', order = 100 } = {}) {
|
|
170
|
+
/** A button under the header: action('report', { label, icon, run, variant, order }). */
|
|
171
|
+
action(id, { label, icon = null, run, variant = 'primary', order = 100 } = {}) {
|
|
153
172
|
if (typeof run !== 'function') throw new ClientError(ErrorCodes.InvalidDefinition, `About action "${id}" needs a run function`);
|
|
154
|
-
this.#actions.set(id, { id, label: label ?? id, run, variant, order });
|
|
173
|
+
this.#actions.set(id, { id, label: label ?? id, icon, run, variant, order });
|
|
155
174
|
this.#changed();
|
|
156
175
|
return this;
|
|
157
176
|
}
|
|
@@ -196,16 +215,22 @@ export class AboutPage {
|
|
|
196
215
|
render(root, ctx) {
|
|
197
216
|
const { ui } = this.client;
|
|
198
217
|
const draw = () => {
|
|
199
|
-
const
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
218
|
+
const sections = this.#sortedSections()
|
|
219
|
+
.map((section) => ({ ...section, fields: this.#sortedFields(section.id) }))
|
|
220
|
+
.filter((section) => section.fields.length);
|
|
221
|
+
const header = sections.find((s) => s.layout === 'header');
|
|
222
|
+
const cards = sections.filter((s) => s !== header);
|
|
223
|
+
root.replaceChildren(
|
|
224
|
+
ui.heading(this.config.title ?? 'About'),
|
|
225
|
+
h(
|
|
226
|
+
'div',
|
|
227
|
+
{ class: 'yc-about' },
|
|
228
|
+
header && this.#header(header),
|
|
229
|
+
this.config.intro && ui.hint(this.config.intro),
|
|
230
|
+
this.#actionBar(),
|
|
231
|
+
cards.length > 0 && h('div', { class: 'yc-about-grid' }, cards.map((s) => this.#card(s))),
|
|
232
|
+
),
|
|
233
|
+
);
|
|
209
234
|
};
|
|
210
235
|
this.onChange = draw;
|
|
211
236
|
draw();
|
|
@@ -214,29 +239,94 @@ export class AboutPage {
|
|
|
214
239
|
});
|
|
215
240
|
}
|
|
216
241
|
|
|
217
|
-
#
|
|
242
|
+
#header(section) {
|
|
218
243
|
const { ui } = this.client;
|
|
219
|
-
const
|
|
220
|
-
const
|
|
221
|
-
|
|
222
|
-
const
|
|
244
|
+
const field = (name) => section.fields.find((f) => f.id === `${section.id}.${name}`);
|
|
245
|
+
const [name, version, description] = [field('name'), field('version'), field('description')];
|
|
246
|
+
const rest = section.fields.filter((f) => f !== name && f !== version && f !== description);
|
|
247
|
+
const title = name ? valueText(name) : this.client.name;
|
|
248
|
+
const logo = this.config.logo ? ui.icon(this.config.logo) : null;
|
|
223
249
|
return h(
|
|
224
|
-
'
|
|
225
|
-
{ class: 'yc-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
250
|
+
'div',
|
|
251
|
+
{ class: 'yc-about-header', dataset: { section: section.id } },
|
|
252
|
+
h('div', { class: 'yc-about-logo', attrs: { 'aria-hidden': 'true' } }, logo ?? title.trim().charAt(0).toUpperCase()),
|
|
253
|
+
h(
|
|
254
|
+
'div',
|
|
255
|
+
{ class: 'yc-grow' },
|
|
256
|
+
h('div', { class: 'yc-about-title' }, title, version && h('span', { class: 'yc-about-version' }, `v${valueText(version).replace(/^v/, '')}`, version.copy && this.#copyButton(version))),
|
|
257
|
+
description && h('div', { class: 'yc-about-desc' }, valueText(description)),
|
|
258
|
+
rest.length > 0 && h('div', { class: 'yc-about-meta' }, rest.map((f) => h('span', null, h('span', { class: 'yc-about-label' }, f.label), ' ', this.#value(f)))),
|
|
259
|
+
),
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
#actionBar() {
|
|
264
|
+
const { ui } = this.client;
|
|
265
|
+
const actions = [...this.#actions.values()].sort((a, b) => a.order - b.order);
|
|
266
|
+
if (!actions.length) return null;
|
|
267
|
+
return h('div', { class: 'yc-about-actions' }, actions.map((a) => ui.button({ label: a.label, icon: a.icon, variant: a.variant, small: true, onClick: () => a.run(this.client) })));
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
#card(section) {
|
|
271
|
+
return h(
|
|
272
|
+
'section',
|
|
273
|
+
{ class: 'yc-about-card', dataset: { section: section.id, layout: section.layout } },
|
|
274
|
+
section.title && h('div', { class: 'yc-section-title' }, section.title),
|
|
275
|
+
section.layout === 'chips' ? this.#chips(section.fields) : this.#rows(section.fields),
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
#rows(fields) {
|
|
280
|
+
return h(
|
|
281
|
+
'dl',
|
|
282
|
+
{ class: 'yc-about-rows' },
|
|
283
|
+
fields.flatMap((f) => [h('dt', null, f.label), h('dd', null, this.#value(f))]),
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
#chips(fields) {
|
|
288
|
+
const { ui } = this.client;
|
|
289
|
+
return h(
|
|
290
|
+
'div',
|
|
291
|
+
{ class: 'yc-about-chips' },
|
|
292
|
+
fields.map((f) => {
|
|
293
|
+
if (f.href) return h(ui.link({ href: f.href, label: f.label, title: valueText(f) }), { class: 'yc-about-chip' }, ui.icon('external'));
|
|
294
|
+
return h('span', { class: 'yc-about-chip', title: f.label }, this.#value(f));
|
|
235
295
|
}),
|
|
236
296
|
);
|
|
237
297
|
}
|
|
298
|
+
|
|
299
|
+
#value(field) {
|
|
300
|
+
const { ui } = this.client;
|
|
301
|
+
const text = valueText(field);
|
|
302
|
+
const content = field.href ? ui.link({ href: field.href, label: text }) : field.code ? h('code', null, text) : text;
|
|
303
|
+
return h('span', { class: 'yc-about-value', title: text }, h('span', { class: 'yc-about-text' }, content), field.copy && this.#copyButton(field));
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
#copyButton(field) {
|
|
307
|
+
const { ui } = this.client;
|
|
308
|
+
const text = field.copy === true ? valueText(field) : String(field.copy);
|
|
309
|
+
const button = h('button', {
|
|
310
|
+
type: 'button',
|
|
311
|
+
class: 'yc-about-copy',
|
|
312
|
+
title: `Copy ${field.label.toLowerCase()}`,
|
|
313
|
+
attrs: { 'aria-label': `Copy ${field.label.toLowerCase()}` },
|
|
314
|
+
onClick: async () => {
|
|
315
|
+
try {
|
|
316
|
+
await navigator.clipboard.writeText(text);
|
|
317
|
+
button.replaceChildren(ui.icon('check'));
|
|
318
|
+
setTimeout(() => button.isConnected && button.replaceChildren(ui.icon('copy')), 1200);
|
|
319
|
+
} catch (error) {
|
|
320
|
+
ui.toast(`Couldn't copy: ${error?.message ?? error}`, { type: 'error' });
|
|
321
|
+
}
|
|
322
|
+
},
|
|
323
|
+
}, ui.icon('copy'));
|
|
324
|
+
return button;
|
|
325
|
+
}
|
|
238
326
|
}
|
|
239
327
|
|
|
328
|
+
const LAYOUTS = new Set(['rows', 'chips', 'header']);
|
|
329
|
+
|
|
240
330
|
function valueText(field) {
|
|
241
331
|
try {
|
|
242
332
|
const value = typeof field.value === 'function' ? field.value() : field.value;
|
|
@@ -27,7 +27,7 @@ export function pluginsPage(client, config = {}) {
|
|
|
27
27
|
id: 'plugins',
|
|
28
28
|
title: config.title ?? 'Plugins',
|
|
29
29
|
order: config.order ?? 10,
|
|
30
|
-
icon: config.icon ??
|
|
30
|
+
icon: config.icon ?? 'puzzle',
|
|
31
31
|
render(root, ctx) {
|
|
32
32
|
const { ui } = client;
|
|
33
33
|
let query = '';
|
|
@@ -142,7 +142,7 @@ export function themesPage(client, config = {}) {
|
|
|
142
142
|
id: 'themes',
|
|
143
143
|
title: config.title ?? 'Themes',
|
|
144
144
|
order: config.order ?? 20,
|
|
145
|
-
icon: config.icon ??
|
|
145
|
+
icon: config.icon ?? 'palette',
|
|
146
146
|
render(root, ctx) {
|
|
147
147
|
const { ui } = client;
|
|
148
148
|
let query = '';
|
package/src/runtime/client.js
CHANGED
|
@@ -7,6 +7,7 @@ import { Emitter } from './events.js';
|
|
|
7
7
|
import { Host } from './host.js';
|
|
8
8
|
import { Logger } from './logger.js';
|
|
9
9
|
import { ModuleFinder } from './modules.js';
|
|
10
|
+
import { Welcome, WhatsNew, onboardingCSS } from './onboarding.js';
|
|
10
11
|
import { PageManager } from './pages.js';
|
|
11
12
|
import { Patcher } from './patcher.js';
|
|
12
13
|
import { PluginManager } from './plugins.js';
|
|
@@ -45,7 +46,7 @@ export class Client {
|
|
|
45
46
|
this.host = new Host(config.host ?? {}, { win });
|
|
46
47
|
this.dom = new DomWatcher({ onError: (error) => this.reportError(error, { where: 'dom' }) });
|
|
47
48
|
this.styles = new StyleManager(win?.document);
|
|
48
|
-
this.ui = new UI({ host: this.host, openExternal: (url) => this.openExternal(url), onError: (error) => this.reportError(error, { where: 'ui' }) });
|
|
49
|
+
this.ui = new UI({ host: this.host, openExternal: (url) => this.openExternal(url), onError: (error) => this.reportError(error, { where: 'ui' }), icons: config.icons?.set });
|
|
49
50
|
this.react = {
|
|
50
51
|
fiberOf,
|
|
51
52
|
propsOf,
|
|
@@ -63,6 +64,8 @@ export class Client {
|
|
|
63
64
|
this.plugins = new PluginManager(this, config.plugins ?? {}, { loadModule });
|
|
64
65
|
this.badges = new BadgeManager(this, config.badges ?? {});
|
|
65
66
|
this.userContent = new UserContent(this, { plugins: config.plugins ?? {}, themes: config.themes ?? {} });
|
|
67
|
+
this.welcome = config.welcome === false ? null : new Welcome(this, config.welcome ?? {});
|
|
68
|
+
this.whatsNew = config.whatsNew === false ? null : new WhatsNew(this, config.whatsNew ?? {});
|
|
66
69
|
this.about = config.about === false ? null : new AboutPage(this, config.about ?? {});
|
|
67
70
|
this.ready = new Promise((resolve) => {
|
|
68
71
|
this.#readyResolve = resolve;
|
|
@@ -99,6 +102,7 @@ export class Client {
|
|
|
99
102
|
async start(bundle = {}) {
|
|
100
103
|
const config = this.config;
|
|
101
104
|
this.styles.add(baseCSS, { id: 'framework', order: StyleOrder.framework });
|
|
105
|
+
this.styles.add(onboardingCSS, { id: 'framework-onboarding', order: StyleOrder.framework });
|
|
102
106
|
for (const theme of bundle.themes ?? []) {
|
|
103
107
|
try {
|
|
104
108
|
this.themes.add(this.themes.parse(theme.text, { file: theme.file, source: 'bundled' }), { source: 'bundled', file: theme.file });
|
|
@@ -146,6 +150,11 @@ export class Client {
|
|
|
146
150
|
this.badges.start();
|
|
147
151
|
await this.plugins.startAll();
|
|
148
152
|
if (this.safeMode) this.ui.toast(`${this.name} is in safe mode: plugins and themes are off.`, { type: 'warning', timeout: 8000 });
|
|
153
|
+
try {
|
|
154
|
+
this.#maybeShowOnboarding();
|
|
155
|
+
} catch (error) {
|
|
156
|
+
this.reportError(error, { where: 'welcome/what\'s new' });
|
|
157
|
+
}
|
|
149
158
|
this.isReady = true;
|
|
150
159
|
this.#readyResolve(this);
|
|
151
160
|
this.events.emit('ready', this);
|
|
@@ -156,7 +165,7 @@ export class Client {
|
|
|
156
165
|
#addBuiltinPages() {
|
|
157
166
|
const pages = this.config.pages ?? {};
|
|
158
167
|
if (this.about) {
|
|
159
|
-
this.pages.add({ id: 'about', title: this.config.about?.title ?? 'About', order: this.config.about?.order ?? 0, icon: this.config.about?.icon ??
|
|
168
|
+
this.pages.add({ id: 'about', title: this.config.about?.title ?? 'About', order: this.config.about?.order ?? 0, icon: this.config.about?.icon ?? 'info', render: (root, ctx) => this.about.render(root, ctx) });
|
|
160
169
|
}
|
|
161
170
|
if (pages.plugins !== false) this.pages.add(pluginsPage(this, pages.plugins ?? {}));
|
|
162
171
|
if (pages.themes !== false) this.pages.add(themesPage(this, pages.themes ?? {}));
|
|
@@ -166,6 +175,7 @@ export class Client {
|
|
|
166
175
|
id: 'settings',
|
|
167
176
|
title: options.title ?? 'Settings',
|
|
168
177
|
order: options.order ?? 5,
|
|
178
|
+
icon: options.icon ?? 'sliders',
|
|
169
179
|
render: (root, ctx) => {
|
|
170
180
|
const form = this.ui.form(this.settings, { context: this });
|
|
171
181
|
root.append(this.ui.heading(options.title ?? 'Settings'), form.element);
|
|
@@ -175,6 +185,16 @@ export class Client {
|
|
|
175
185
|
}
|
|
176
186
|
}
|
|
177
187
|
|
|
188
|
+
/** First run shows the welcome screen (and skips what's new for that first run); otherwise an update shows what's new. */
|
|
189
|
+
#maybeShowOnboarding() {
|
|
190
|
+
if (this.welcome?.shouldShow()) {
|
|
191
|
+
this.welcome.show();
|
|
192
|
+
this.whatsNew?.markSeen();
|
|
193
|
+
} else {
|
|
194
|
+
this.whatsNew?.maybeShow();
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
178
198
|
/** Stops plugins, removes styles and flushes storage. Mostly for tests and development reloads. */
|
|
179
199
|
stop() {
|
|
180
200
|
this.plugins.stopAll();
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { h } from './ui.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The rounded-corner "Welcome" screen a client app shows the first time it runs with no saved state,
|
|
5
|
+
* and the "What's new" screen it shows after an update. Both are entirely optional (`welcome: false` /
|
|
6
|
+
* `whatsNew: false`), entirely editable from the config (title, icon, steps/entries, labels), and can
|
|
7
|
+
* be replayed at any time with `client.welcome.show()` / `client.whatsNew.show()`.
|
|
8
|
+
*/
|
|
9
|
+
const SEEN_NAMESPACE = 'onboarding';
|
|
10
|
+
|
|
11
|
+
function fill(text, client) {
|
|
12
|
+
if (typeof text !== 'string') return text;
|
|
13
|
+
return text.replace(/\{name\}/g, client.name).replace(/\{version\}/g, client.version);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Builds the shared rounded, animated panel. Returns the backdrop element; caller appends it. */
|
|
17
|
+
function panel(client, { icon, title, subtitle, rows = [], actionLabel, dismissLabel, dismissable = true, onAction, onDismiss, onClose }) {
|
|
18
|
+
const { ui } = client;
|
|
19
|
+
const doc = globalThis.document;
|
|
20
|
+
let settled = false;
|
|
21
|
+
const finish = (fn) => {
|
|
22
|
+
if (settled) return;
|
|
23
|
+
settled = true;
|
|
24
|
+
backdrop.classList.add('yc-onboard-leave');
|
|
25
|
+
doc.removeEventListener('keydown', onKey, true);
|
|
26
|
+
setTimeout(() => backdrop.remove(), 180);
|
|
27
|
+
fn?.();
|
|
28
|
+
onClose?.();
|
|
29
|
+
};
|
|
30
|
+
const onKey = (event) => {
|
|
31
|
+
if (event.key === 'Escape' && dismissable) {
|
|
32
|
+
event.stopPropagation();
|
|
33
|
+
finish(onDismiss);
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
const rowsEl = rows.map((row, index) =>
|
|
37
|
+
h(
|
|
38
|
+
'div',
|
|
39
|
+
{ class: 'yc-onboard-row', style: { '--yc-i': String(index) } },
|
|
40
|
+
row.icon !== false && h('div', { class: 'yc-onboard-row-icon' }, ui.icon(row.icon ?? 'sparkles')),
|
|
41
|
+
h('div', { class: 'yc-onboard-row-body' }, h('div', { class: 'yc-onboard-row-title' }, row.title), row.description && h('div', { class: 'yc-onboard-row-desc' }, row.description)),
|
|
42
|
+
),
|
|
43
|
+
);
|
|
44
|
+
const buttons = [];
|
|
45
|
+
if (dismissable && dismissLabel) buttons.push(ui.button({ label: dismissLabel, variant: 'ghost', onClick: () => finish(onDismiss) }));
|
|
46
|
+
buttons.push(ui.button({ label: actionLabel, variant: 'primary', onClick: () => finish(onAction) }));
|
|
47
|
+
const dialog = h(
|
|
48
|
+
'div',
|
|
49
|
+
{ class: 'yc-onboard', attrs: { role: 'dialog', 'aria-modal': 'true' } },
|
|
50
|
+
dismissable && h('button', { type: 'button', class: 'yc-onboard-close', attrs: { 'aria-label': 'Dismiss' }, onClick: () => finish(onDismiss) }, ui.icon('x')),
|
|
51
|
+
h('div', { class: 'yc-onboard-hero' }, h('div', { class: 'yc-onboard-hero-glow' }), h('div', { class: 'yc-onboard-hero-icon' }, ui.icon(icon ?? 'sparkles'))),
|
|
52
|
+
h('h2', { class: 'yc-onboard-title' }, title),
|
|
53
|
+
subtitle && h('p', { class: 'yc-onboard-subtitle' }, subtitle),
|
|
54
|
+
rowsEl.length ? h('div', { class: 'yc-onboard-rows' }, rowsEl) : null,
|
|
55
|
+
h('div', { class: 'yc-onboard-actions' }, buttons),
|
|
56
|
+
);
|
|
57
|
+
const backdrop = h('div', { class: 'yc-onboard-backdrop', onMousedown: (event) => event.target === backdrop && dismissable && finish(onDismiss) }, dialog);
|
|
58
|
+
doc.addEventListener('keydown', onKey, true);
|
|
59
|
+
return backdrop;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
class OnboardingScreen {
|
|
63
|
+
constructor(client, config, storageKey) {
|
|
64
|
+
this.client = client;
|
|
65
|
+
this.config = config ?? {};
|
|
66
|
+
this.storageKey = storageKey;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
get enabled() {
|
|
70
|
+
return this.config !== false && this.config?.enabled !== false;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
#store() {
|
|
74
|
+
return this.client.storage.namespace(SEEN_NAMESPACE);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Marks the screen as seen without showing it, so it won't appear next time either. */
|
|
78
|
+
markSeen() {
|
|
79
|
+
this.#store().set(this.storageKey, this.client.version);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Clears the seen flag so it shows again next time the client app starts (or call show() now). */
|
|
83
|
+
reset() {
|
|
84
|
+
this.#store().delete(this.storageKey);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Shown the first time the client app runs (nothing saved yet). `welcome: false` turns it off. */
|
|
89
|
+
export class Welcome extends OnboardingScreen {
|
|
90
|
+
constructor(client, config) {
|
|
91
|
+
super(client, config, 'welcomeSeenVersion');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
shouldShow() {
|
|
95
|
+
return this.enabled && !this.#store().has(this.storageKey);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
#store() {
|
|
99
|
+
return this.client.storage.namespace(SEEN_NAMESPACE);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Shows the welcome screen right now, regardless of whether it's been seen. */
|
|
103
|
+
show() {
|
|
104
|
+
const doc = globalThis.document;
|
|
105
|
+
if (!doc?.body) return () => {};
|
|
106
|
+
const { client, config } = this;
|
|
107
|
+
const backdrop = panel(client, {
|
|
108
|
+
icon: config.icon ?? 'sparkles',
|
|
109
|
+
title: fill(config.title ?? 'Welcome to {name}', client),
|
|
110
|
+
subtitle: fill(config.subtitle ?? null, client),
|
|
111
|
+
rows: (config.steps ?? []).map((s) => ({ icon: s.icon, title: fill(s.title, client), description: fill(s.description, client) })),
|
|
112
|
+
actionLabel: config.actionLabel ?? 'Get started',
|
|
113
|
+
dismissLabel: config.dismissible === false ? null : config.dismissLabel ?? 'Skip',
|
|
114
|
+
dismissable: config.dismissible !== false,
|
|
115
|
+
onAction: () => this.markSeen(),
|
|
116
|
+
onDismiss: () => this.markSeen(),
|
|
117
|
+
});
|
|
118
|
+
doc.body.appendChild(backdrop);
|
|
119
|
+
return () => backdrop.remove();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
maybeShow() {
|
|
123
|
+
if (this.shouldShow()) this.show();
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Shown after an update, once, when there are entries configured. `whatsNew: false` turns it off. */
|
|
128
|
+
export class WhatsNew extends OnboardingScreen {
|
|
129
|
+
constructor(client, config) {
|
|
130
|
+
super(client, config, 'whatsNewSeenVersion');
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
shouldShow() {
|
|
134
|
+
if (!this.enabled || !(this.config.entries ?? []).length) return false;
|
|
135
|
+
return this.#store().get(this.storageKey) !== this.client.version;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
#store() {
|
|
139
|
+
return this.client.storage.namespace(SEEN_NAMESPACE);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Shows the what's new screen right now, regardless of version. */
|
|
143
|
+
show() {
|
|
144
|
+
const doc = globalThis.document;
|
|
145
|
+
if (!doc?.body) return () => {};
|
|
146
|
+
const { client, config } = this;
|
|
147
|
+
const backdrop = panel(client, {
|
|
148
|
+
icon: config.icon ?? 'rocket',
|
|
149
|
+
title: fill(config.title ?? "What's new in {name} {version}", client),
|
|
150
|
+
subtitle: fill(config.subtitle ?? null, client),
|
|
151
|
+
rows: (config.entries ?? []).map((e) => ({ icon: e.icon, title: fill(e.title, client), description: fill(e.description, client) })),
|
|
152
|
+
actionLabel: config.actionLabel ?? 'Got it',
|
|
153
|
+
dismissLabel: config.dismissible === false ? null : config.dismissLabel ?? null,
|
|
154
|
+
dismissable: config.dismissible !== false,
|
|
155
|
+
onAction: () => this.markSeen(),
|
|
156
|
+
onDismiss: () => this.markSeen(),
|
|
157
|
+
});
|
|
158
|
+
doc.body.appendChild(backdrop);
|
|
159
|
+
return () => backdrop.remove();
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
maybeShow() {
|
|
163
|
+
if (this.shouldShow()) this.show();
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export const onboardingCSS = `
|
|
168
|
+
.yc-onboard-backdrop{position:fixed;inset:0;z-index:2147483100;background:#0009;display:flex;align-items:center;justify-content:center;padding:20px;animation:yc-onboard-fade .18s ease-out}
|
|
169
|
+
.yc-onboard-backdrop.yc-onboard-leave{animation:yc-onboard-fade .18s ease-in reverse}
|
|
170
|
+
.yc-onboard-backdrop.yc-onboard-leave .yc-onboard{animation:yc-onboard-in .18s ease-in reverse}
|
|
171
|
+
.yc-onboard{position:relative;background:var(--bg-float,#1b1c21);color:var(--text,#ececf1);border:1px solid var(--border);border-radius:20px;box-shadow:var(--shadow,0 20px 60px #0008);width:min(440px,100%);max-height:90vh;overflow:auto;padding:28px 26px 24px;text-align:center;animation:yc-onboard-in .22s cubic-bezier(.2,.8,.2,1)}
|
|
172
|
+
.yc-onboard-close{position:absolute;top:14px;right:14px;width:28px;height:28px;border-radius:999px;border:none;background:var(--bg-active,#2a2b31);color:var(--text-muted);display:flex;align-items:center;justify-content:center;cursor:pointer;padding:0}
|
|
173
|
+
.yc-onboard-close:hover{background:var(--bg-hover,#34353c);color:var(--text)}
|
|
174
|
+
.yc-onboard-close .yc-icon{width:14px;height:14px}
|
|
175
|
+
.yc-onboard-hero{position:relative;width:64px;height:64px;margin:0 auto 14px;display:flex;align-items:center;justify-content:center}
|
|
176
|
+
.yc-onboard-hero-glow{position:absolute;inset:0;border-radius:999px;background:radial-gradient(circle,var(--accent) 0%,transparent 70%);opacity:.35;animation:yc-onboard-pulse 2.4s ease-in-out infinite}
|
|
177
|
+
.yc-onboard-hero-icon{position:relative;width:64px;height:64px;border-radius:999px;background:var(--bg-active,#2a2b31);border:1px solid var(--border);display:flex;align-items:center;justify-content:center;color:var(--accent);animation:yc-onboard-pop .4s cubic-bezier(.2,.8,.2,1) .05s backwards}
|
|
178
|
+
.yc-onboard-hero-icon .yc-icon{width:28px;height:28px}
|
|
179
|
+
.yc-onboard-title{margin:0 0 6px;font-size:20px;font-weight:700}
|
|
180
|
+
.yc-onboard-subtitle{margin:0 0 18px;color:var(--text-muted);font-size:14px;line-height:1.5}
|
|
181
|
+
.yc-onboard-rows{display:flex;flex-direction:column;gap:12px;text-align:left;margin-bottom:20px}
|
|
182
|
+
.yc-onboard-row{display:flex;gap:12px;align-items:flex-start;opacity:0;animation:yc-onboard-row-in .35s ease-out forwards;animation-delay:calc(120ms + var(--yc-i) * 70ms)}
|
|
183
|
+
.yc-onboard-row-icon{flex-shrink:0;width:34px;height:34px;border-radius:10px;background:var(--bg-active,#2a2b31);display:flex;align-items:center;justify-content:center;color:var(--accent)}
|
|
184
|
+
.yc-onboard-row-icon .yc-icon{width:18px;height:18px}
|
|
185
|
+
.yc-onboard-row-title{font-weight:600;font-size:14px}
|
|
186
|
+
.yc-onboard-row-desc{color:var(--text-muted);font-size:13px;margin-top:2px;line-height:1.4}
|
|
187
|
+
.yc-onboard-actions{display:flex;justify-content:center;gap:10px}
|
|
188
|
+
@keyframes yc-onboard-fade{from{opacity:0}to{opacity:1}}
|
|
189
|
+
@keyframes yc-onboard-in{from{opacity:0;transform:translateY(14px) scale(.96)}to{opacity:1;transform:none}}
|
|
190
|
+
@keyframes yc-onboard-pop{from{opacity:0;transform:scale(.6)}to{opacity:1;transform:none}}
|
|
191
|
+
@keyframes yc-onboard-pulse{0%,100%{opacity:.25;transform:scale(1)}50%{opacity:.45;transform:scale(1.15)}}
|
|
192
|
+
@keyframes yc-onboard-row-in{from{opacity:0;transform:translateX(-6px)}to{opacity:1;transform:none}}
|
|
193
|
+
@media (prefers-reduced-motion:reduce){
|
|
194
|
+
.yc-onboard-backdrop,.yc-onboard,.yc-onboard-hero-icon,.yc-onboard-hero-glow,.yc-onboard-row{animation:none!important;opacity:1!important;transform:none!important}
|
|
195
|
+
}
|
|
196
|
+
`;
|