yapin.js-client 0.1.0 → 0.1.4

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';
@@ -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
+ `;
package/src/runtime/ui.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { SettingTypes } from './settings.js';
2
+ import { builtinIcon } from '../shared/icons.js';
2
3
  import { safeURL, toArray } from './util.js';
3
4
 
4
5
  /**
@@ -57,7 +58,10 @@ const SVG_ALLOWED = new Set([
57
58
  */
58
59
  export function svg(markup) {
59
60
  if (typeof markup !== 'string' || !markup.includes('<svg')) return null;
60
- const parsed = new DOMParser().parseFromString(markup, 'image/svg+xml');
61
+ // Parsed as XML, an <svg> without xmlns is a plain element that draws nothing; inline-HTML markup often leaves it out.
62
+ let source = /<svg\b[^>]*\sxmlns=/.test(markup) ? markup : markup.replace(/<svg\b/, '<svg xmlns="http://www.w3.org/2000/svg"');
63
+ if (source.includes('xlink:') && !/\sxmlns:xlink=/.test(source)) source = source.replace(/<svg\b/, '<svg xmlns:xlink="http://www.w3.org/1999/xlink"');
64
+ const parsed = new DOMParser().parseFromString(source, 'image/svg+xml');
61
65
  const root = parsed.documentElement;
62
66
  if (!root || root.nodeName.toLowerCase() !== 'svg' || parsed.querySelector('parsererror')) return null;
63
67
  const clean = (node) => {
@@ -77,6 +81,8 @@ export function svg(markup) {
77
81
  return globalThis.document.importNode(root, true);
78
82
  }
79
83
 
84
+ const TOAST_ICONS = { info: 'info', success: 'check', warning: 'alertTriangle', error: 'alertCircle' };
85
+
80
86
  export const baseCSS = `
81
87
  .yc-page{display:flex;flex-direction:column;gap:4px;min-width:0}
82
88
  .yc-page h2{margin:0 0 12px}
@@ -89,7 +95,33 @@ export const baseCSS = `
89
95
  .yc-kv{display:grid;grid-template-columns:minmax(120px,max-content) 1fr;gap:8px 16px;align-items:baseline;padding:10px 0;border-bottom:1px solid var(--border)}
90
96
  .yc-kv dt{color:var(--text-muted);font-size:13px}
91
97
  .yc-kv dd{margin:0;word-break:break-word;font-size:14px}
92
- .yc-kv code,.yc-code{font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:12.5px;background:var(--bg-input);border-radius:6px;padding:1px 6px}
98
+ .yc-about{display:flex;flex-direction:column;gap:12px}
99
+ .yc-about-header{display:flex;gap:14px;align-items:center;background:var(--bg-elev);border:1px solid var(--border);border-radius:var(--radius,10px);padding:14px 16px}
100
+ .yc-about-logo{width:48px;height:48px;border-radius:12px;flex-shrink:0;display:flex;align-items:center;justify-content:center;background:color-mix(in srgb,var(--accent) 18%,transparent);color:var(--accent);font-size:22px;font-weight:700}
101
+ .yc-about-logo .yc-icon{width:26px;height:26px}
102
+ .yc-about-title{display:flex;align-items:center;gap:8px;flex-wrap:wrap;font-size:17px;font-weight:700}
103
+ .yc-about-version{display:inline-flex;align-items:center;gap:2px;font-size:11.5px;font-weight:600;padding:1px 4px 1px 8px;border-radius:999px;background:var(--bg-active);color:var(--text-muted)}
104
+ .yc-about-desc{color:var(--text-muted);font-size:13px;margin-top:2px}
105
+ .yc-about-meta{display:flex;flex-wrap:wrap;gap:2px 14px;font-size:12.5px;margin-top:6px}
106
+ .yc-about-label{color:var(--text-muted)}
107
+ .yc-about-actions{display:flex;flex-wrap:wrap;gap:6px}
108
+ .yc-about-actions .yc-icon{width:15px;height:15px;margin-right:4px}
109
+ .yc-about-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(250px,1fr));gap:10px}
110
+ .yc-about-card{background:var(--bg-elev);border:1px solid var(--border);border-radius:var(--radius,10px);padding:10px 14px;min-width:0}
111
+ .yc-about-rows{display:grid;grid-template-columns:max-content minmax(0,1fr);gap:4px 12px;margin:0;font-size:13px;align-items:center}
112
+ .yc-about-rows dt{color:var(--text-muted)}
113
+ .yc-about-rows dd{margin:0;min-width:0}
114
+ .yc-about-value{display:inline-flex;align-items:center;gap:2px;max-width:100%;min-width:0;vertical-align:middle}
115
+ .yc-about-text{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0}
116
+ .yc-about-copy{display:inline-flex;flex-shrink:0;border:0;background:none;color:var(--text-muted);cursor:pointer;padding:2px;border-radius:5px}
117
+ .yc-about-copy:hover{background:var(--bg-hover);color:var(--text)}
118
+ .yc-about-copy .yc-icon{width:14px;height:14px}
119
+ .yc-about-chips{display:flex;flex-wrap:wrap;gap:6px}
120
+ .yc-about-chip{display:inline-flex;align-items:center;gap:5px;max-width:100%;padding:3px 10px;border-radius:999px;background:var(--bg-active);font-size:12.5px;color:var(--text)}
121
+ .yc-page a.yc-about-chip{color:var(--text)}
122
+ .yc-page a.yc-about-chip:hover{text-decoration:none;background:var(--bg-hover)}
123
+ .yc-about-chip .yc-icon{width:13px;height:13px;color:var(--text-muted)}
124
+ .yc-kv code,.yc-about code,.yc-code{font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:12.5px;background:var(--bg-input);border-radius:6px;padding:1px 6px}
93
125
  .yc-card{background:var(--bg-elev);border:1px solid var(--border);border-radius:var(--radius,10px);padding:12px 14px;margin:8px 0}
94
126
  .yc-card-head{display:flex;align-items:center;gap:10px}
95
127
  .yc-card-title{font-weight:600}
@@ -111,10 +143,18 @@ export const baseCSS = `
111
143
  .yc-toasts{position:fixed;z-index:2147483000;bottom:20px;left:50%;transform:translateX(-50%);display:flex;flex-direction:column;gap:8px;align-items:center;pointer-events:none}
112
144
  .yc-toast{pointer-events:auto;background:var(--bg-float,#1b1c21);color:var(--text,#ececf1);border:1px solid var(--border);box-shadow:var(--shadow);border-radius:var(--radius,10px);padding:10px 14px;font-size:14px;max-width:min(520px,90vw);display:flex;gap:10px;align-items:center;animation:yc-in .15s ease-out}
113
145
  .yc-toast[data-type=success]{border-left:3px solid var(--success,#3fcf82)}
146
+ .yc-toast[data-type=success] .yc-icon{color:var(--success,#3fcf82)}
114
147
  .yc-toast[data-type=error]{border-left:3px solid var(--danger,#f2555a)}
148
+ .yc-toast[data-type=error] .yc-icon{color:var(--danger,#f2555a)}
115
149
  .yc-toast[data-type=warning]{border-left:3px solid var(--warning,#f5b544)}
150
+ .yc-toast[data-type=warning] .yc-icon{color:var(--warning,#f5b544)}
151
+ .yc-toast[data-type=info] .yc-icon{color:var(--accent)}
116
152
  .yc-backdrop{position:fixed;inset:0;z-index:2147482000;background:#0009;display:flex;align-items:center;justify-content:center;padding:16px;animation:yc-in .12s ease-out}
117
153
  .yc-dialog{background:var(--bg-float,#1b1c21);color:var(--text,#ececf1);border:1px solid var(--border);border-radius:calc(var(--radius,10px) + 4px);box-shadow:var(--shadow);width:min(460px,100%);max-height:90vh;overflow:auto;padding:20px}
154
+ .yc-dialog-head{display:flex;align-items:center;gap:10px}
155
+ .yc-dialog-head .yc-icon{width:22px;height:22px;color:var(--accent);flex-shrink:0}
156
+ .yc-dialog-head.yc-danger .yc-icon{color:var(--danger,#f2555a)}
157
+ .yc-dialog-head h3{margin:0 0 8px;font-size:18px}
118
158
  .yc-dialog h3{margin:0 0 8px;font-size:18px}
119
159
  .yc-dialog p{margin:0 0 12px;color:var(--text-muted);white-space:pre-wrap}
120
160
  .yc-dialog-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:16px}
@@ -173,10 +213,11 @@ export class UI {
173
213
  return h('div', { class: 'yc-empty' }, text);
174
214
  }
175
215
 
176
- /** An icon from SVG markup (sanitized), an https/data image URL, or an element. */
216
+ /** An icon: a built-in name ('rocket', 'puzzle', …), SVG markup (sanitized), an https/data image URL, or an element. */
177
217
  icon(source) {
178
218
  if (!source) return null;
179
219
  if (source instanceof Node) return h('span', { class: 'yc-icon' }, source);
220
+ if (typeof source === 'string' && !source.includes('<') && !/^(https?:|data:)/.test(source)) source = builtinIcon(source) ?? source;
180
221
  const asSvg = svg(source);
181
222
  if (asSvg) return h('span', { class: 'yc-icon', attrs: { 'aria-hidden': 'true' } }, asSvg);
182
223
  const url = safeURL(source, { protocols: ['https:', 'data:'] });
@@ -392,22 +433,22 @@ export class UI {
392
433
  }
393
434
 
394
435
  /** Shows a short message at the bottom of the window. type: info | success | warning | error. */
395
- toast(message, { type = 'info', timeout = 4000, action } = {}) {
436
+ toast(message, { type = 'info', timeout = 4000, action, icon = TOAST_ICONS[type] } = {}) {
396
437
  const doc = globalThis.document;
397
438
  if (!doc?.body) return () => {};
398
439
  if (!this.#toastRoot?.isConnected) {
399
440
  this.#toastRoot = h('div', { class: 'yc-toasts', attrs: { role: 'status', 'aria-live': 'polite' } });
400
441
  doc.body.appendChild(this.#toastRoot);
401
442
  }
402
- const element = h('div', { class: 'yc-toast', dataset: { type } }, h('span', { class: 'yc-grow' }, message), action && this.button({ label: action.label, small: true, variant: 'ghost', onClick: () => (close(), action.onClick()) }));
443
+ const element = h('div', { class: 'yc-toast', dataset: { type } }, this.icon(icon), h('span', { class: 'yc-grow' }, message), action && this.button({ label: action.label, small: true, variant: 'ghost', onClick: () => (close(), action.onClick()) }));
403
444
  const close = () => element.remove();
404
445
  this.#toastRoot.appendChild(element);
405
446
  if (timeout > 0) setTimeout(close, timeout);
406
447
  return close;
407
448
  }
408
449
 
409
- /** A modal dialog. Resolves with the value of the button pressed, or null when dismissed. */
410
- dialog({ title, message, content, buttons = [{ label: 'OK', value: true }], dismissable = true } = {}) {
450
+ /** A modal dialog. Resolves with the value of the button pressed, or null when dismissed. `icon` shows next to the title. */
451
+ dialog({ title, icon, message, content, buttons = [{ label: 'OK', value: true }], dismissable = true } = {}) {
411
452
  const doc = globalThis.document;
412
453
  if (!doc?.body) return Promise.resolve(null);
413
454
  return new Promise((resolve) => {
@@ -426,7 +467,8 @@ export class UI {
426
467
  }
427
468
  };
428
469
  const actions = buttons.map((b) => this.button({ label: b.label, variant: b.variant ?? 'primary', onClick: async () => finish(typeof b.value === 'function' ? await b.value() : b.value) }));
429
- const dialog = h('div', { class: 'yc-dialog', attrs: { role: 'dialog', 'aria-modal': 'true' } }, title && h('h3', null, title), message && h('p', null, message), content, h('div', { class: 'yc-dialog-actions' }, actions));
470
+ const head = title && (icon ? h('div', { class: ['yc-dialog-head', buttons.some((b) => b.variant === 'danger') && 'yc-danger'] }, this.icon(icon), h('h3', null, title)) : h('h3', null, title));
471
+ const dialog = h('div', { class: 'yc-dialog', attrs: { role: 'dialog', 'aria-modal': 'true' } }, head, message && h('p', null, message), content, h('div', { class: 'yc-dialog-actions' }, actions));
430
472
  const backdrop = h('div', { class: 'yc-backdrop', onMousedown: (event) => event.target === backdrop && dismissable && finish(null) }, dialog);
431
473
  doc.addEventListener('keydown', onKey, true);
432
474
  doc.body.appendChild(backdrop);
@@ -434,9 +476,10 @@ export class UI {
434
476
  });
435
477
  }
436
478
 
437
- async confirm({ title = 'Are you sure?', message, confirmLabel = 'Confirm', cancelLabel = 'Cancel', danger = false, content } = {}) {
479
+ async confirm({ title = 'Are you sure?', message, confirmLabel = 'Confirm', cancelLabel = 'Cancel', danger = false, icon = danger ? 'alertTriangle' : null, content } = {}) {
438
480
  const value = await this.dialog({
439
481
  title,
482
+ icon,
440
483
  message,
441
484
  content,
442
485
  buttons: [