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.
@@ -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: 'Open data folder', order: 1, run: () => c.native.openFolder('data') });
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' : 'Restart in safe mode',
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
- /** Adds or changes a section: section('support', { title: 'Support', order: 35 }). */
107
- section(id, { title, order } = {}) {
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, { id, title: title === undefined ? existing?.title ?? id : title, order: order ?? existing?.order ?? 100 });
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 at the bottom of the page: action('report', { label, run, variant, order }). */
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 parts = [ui.heading(this.config.title ?? 'About')];
200
- if (this.config.intro) parts.push(ui.hint(this.config.intro));
201
- for (const section of this.#sortedSections()) {
202
- const fields = this.#sortedFields(section.id);
203
- if (!fields.length) continue;
204
- parts.push(ui.section({ title: section.title, children: [ui.keyValue(fields.map((f) => ({ label: f.label, value: this.#value(f) })))] }));
205
- }
206
- const actions = [...this.#actions.values()].sort((a, b) => a.order - b.order);
207
- if (actions.length) parts.push(h('div', { class: 'yc-row', style: { marginTop: '16px' } }, actions.map((a) => ui.button({ label: a.label, variant: a.variant, small: true, onClick: () => a.run(this.client) }))));
208
- root.replaceChildren(...parts);
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
- #value(field) {
242
+ #header(section) {
218
243
  const { ui } = this.client;
219
- const text = valueText(field);
220
- const content = field.href ? ui.link({ href: field.href, label: text }) : field.code ? h('code', null, text) : text;
221
- if (!field.copy) return content;
222
- const copyText = field.copy === true ? text : String(field.copy);
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
- 'span',
225
- { class: 'yc-row' },
226
- content,
227
- ui.button({
228
- label: 'Copy',
229
- small: true,
230
- variant: 'ghost',
231
- onClick: async () => {
232
- await navigator.clipboard.writeText(copyText);
233
- ui.toast('Copied', { type: 'success', timeout: 1500 });
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 ?? null,
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 ?? null,
145
+ icon: config.icon ?? 'palette',
146
146
  render(root, ctx) {
147
147
  const { ui } = client;
148
148
  let query = '';
@@ -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 ?? null, render: (root, ctx) => this.about.render(root, ctx) });
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
+ `;