@5even7/dlc-ui 0.1.0 → 0.2.1

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/dist/capsule.mjs CHANGED
@@ -112,88 +112,261 @@ const PALETTES = {
112
112
  * Capsule 预置:仅星云材质 NC-01~NC-06(NC-07~09 aurora 已移除)。
113
113
  * 颜色引用公共色板,seed/speed 决定形态与流速。
114
114
  */
115
- const CAPSULE_PRESETS = [
116
- { id: 'original', code: 'NC-01', name: 'ORIGINAL', group: 'warm', seed: 1.7, speed: 0.5, colors: [...PALETTES.original] },
117
- { id: 'ocean', code: 'NC-02', name: 'OCEAN', group: 'cold', seed: 8.2, speed: 0.48, colors: [...PALETTES.ocean] },
118
- { id: 'klein', code: 'NC-03', name: 'KLEIN', group: 'cold', seed: 14.1, speed: 0.49, colors: [...PALETTES.klein] },
119
- { id: 'ultraviolet', code: 'NC-04', name: 'ULTRAVIOLET', group: 'cold', seed: 23.4, speed: 0.47, colors: [...PALETTES.ultraviolet] },
120
- { id: 'chrome', code: 'NC-05', name: 'CHROME', group: 'cold', seed: 37.8, speed: 0.42, colors: [...PALETTES.chrome] },
121
- { id: 'plus', code: 'NC-06', name: 'PLUS', group: 'warm', seed: 51.3, speed: 0.5, colors: [...PALETTES.plus] }
115
+ const CAPSULE_PRESETS = [
116
+ { id: 'original', code: 'NC-01', name: '初光', group: 'warm', seed: 1.7, speed: 0.5, colors: [...PALETTES.original] },
117
+ { id: 'ocean', code: 'NC-02', name: '沧溟', group: 'cold', seed: 8.2, speed: 0.48, colors: [...PALETTES.ocean] },
118
+ { id: 'klein', code: 'NC-03', name: '玄夜', group: 'cold', seed: 14.1, speed: 0.49, colors: [...PALETTES.klein] },
119
+ { id: 'ultraviolet', code: 'NC-04', name: '紫霄', group: 'cold', seed: 23.4, speed: 0.47, colors: [...PALETTES.ultraviolet] },
120
+ { id: 'chrome', code: 'NC-05', name: '银汉', group: 'cold', seed: 37.8, speed: 0.42, colors: [...PALETTES.chrome] },
121
+ { id: 'plus', code: 'NC-06', name: '熔金', group: 'warm', seed: 51.3, speed: 0.5, colors: [...PALETTES.plus] }
122
122
  ];
123
123
 
124
- function validatePreset(preset) {
125
- return Boolean(
126
- preset &&
127
- typeof preset.id === 'string' &&
128
- typeof preset.code === 'string' &&
129
- typeof preset.name === 'string' &&
130
- Number.isFinite(preset.seed) &&
131
- Number.isFinite(preset.speed) &&
132
- Array.isArray(preset.colors) &&
133
- preset.colors.length === 4
134
- );
135
- }
136
-
137
- function getPreset(kind, ref) {
138
- const list = CAPSULE_PRESETS ;
139
- if (!list) throw new Error(`Unknown preset kind: ${kind} (use "capsule" or "progress")`);
140
- if (ref && typeof ref === 'object') {
141
- const valid = validatePreset(ref) ;
142
- if (!valid) throw new Error(`Invalid ${kind} preset object`);
143
- return ref;
144
- }
145
- const key = String(ref).trim().toLowerCase();
146
- const found = list.find(
147
- (preset) =>
148
- preset.id.toLowerCase() === key ||
149
- preset.code.toLowerCase() === key ||
150
- preset.name.toLowerCase() === key
151
- );
152
- if (!found) throw new Error(`Unknown ${kind} preset: ${ref}`);
153
- return found;
124
+ /**
125
+ * Resolve a preset by its literary name (e.g. '沧溟').
126
+ * Matching is case-insensitive and ignores surrounding whitespace,
127
+ * so '沧溟' and ' 沧溟 ' both work.
128
+ */
129
+ function getPreset(kind, ref) {
130
+ const list = CAPSULE_PRESETS ;
131
+ if (!list) throw new Error(`Unknown preset kind: ${kind} (use "capsule" or "progress")`);
132
+ const key = String(ref).trim().toLowerCase();
133
+ const found = list.find(
134
+ (preset) => preset.name.toLowerCase() === key
135
+ );
136
+ if (!found) throw new Error(`Unknown ${kind} preset: ${ref}`);
137
+ return found;
154
138
  }
155
139
 
156
- const DEFAULTS = {
157
- capsule: {
158
- width: '100%',
159
- height: 160,
160
- quality: 'auto',
161
- renderer: 'auto',
162
- respectReducedMotion: true,
163
- interactive: true,
164
- mouseColor: true,
165
- showCopy: true
140
+ const DEFAULTS = {
141
+ capsule: {
142
+ width: '100%',
143
+ height: 160,
144
+ quality: 'auto',
145
+ renderer: 'auto',
146
+ respectReducedMotion: true,
147
+ mouseColor: true,
148
+ textRatio: 39
166
149
  }};
167
150
 
168
- /**
169
- * All user-facing copy lives here so wording/brand changes never require a
170
- * global search. Templates use {brand} {code} {name} placeholders.
171
- */
172
- const COPY = {
173
- brandName: '画境观屿',
174
- dragLabel: 'DRAG',
175
- valueSuffix: '%',
176
- progressAria: '{brand} {code} 加载进度',
151
+ /**
152
+ * Internal accessibility labels and small UI text. Since the copy API was
153
+ * removed (slots own the text area), these are no longer user-overridable.
154
+ * Templates use {brand} {code} {name} placeholders.
155
+ */
156
+ const COPY = {
177
157
  capsuleAria: '打开 {name} 沉浸预览'
178
158
  };
179
159
 
180
- function hexToRgb01(hex) {
181
- const normalized = hex.replace('#', '');
182
- const value = Number.parseInt(normalized, 16);
183
- return [
184
- ((value >> 16) & 255) / 255,
185
- ((value >> 8) & 255) / 255,
186
- (value & 255) / 255
187
- ];
188
- }
189
-
190
- function hexToRgba(hex, alpha = 1) {
191
- const normalized = hex.replace('#', '');
192
- const value = Number.parseInt(normalized, 16);
193
- const red = (value >> 16) & 255;
194
- const green = (value >> 8) & 255;
195
- const blue = value & 255;
196
- return `rgba(${red}, ${green}, ${blue}, ${alpha})`;
160
+ /**
161
+ * Color helpers. `normalizeColor` converts any supported CSS color
162
+ * (hex / named / rgb() / rgba()) into a 6-digit hex string, so renderers can
163
+ * keep working with #rrggbb only. rgba() alpha is intentionally dropped:
164
+ * the WebGL shader has no per-color alpha channel, and the component is
165
+ * opaque by design use component-level `opacity` for transparency.
166
+ */
167
+
168
+ const NAMED_COLORS = {
169
+ aliceblue: '#f0f8ff',
170
+ antiquewhite: '#faebd7',
171
+ aqua: '#00ffff',
172
+ aquamarine: '#7fffd4',
173
+ azure: '#f0ffff',
174
+ beige: '#f5f5dc',
175
+ bisque: '#ffe4c4',
176
+ black: '#000000',
177
+ blanchedalmond: '#ffebcd',
178
+ blue: '#0000ff',
179
+ blueviolet: '#8a2be2',
180
+ brown: '#a52a2a',
181
+ burlywood: '#deb887',
182
+ cadetblue: '#5f9ea0',
183
+ chartreuse: '#7fff00',
184
+ chocolate: '#d2691e',
185
+ coral: '#ff7f50',
186
+ cornflowerblue: '#6495ed',
187
+ cornsilk: '#fff8dc',
188
+ crimson: '#dc143c',
189
+ cyan: '#00ffff',
190
+ darkblue: '#00008b',
191
+ darkcyan: '#008b8b',
192
+ darkgoldenrod: '#b8860b',
193
+ darkgray: '#a9a9a9',
194
+ darkgreen: '#006400',
195
+ darkgrey: '#a9a9a9',
196
+ darkkhaki: '#bdb76b',
197
+ darkmagenta: '#8b008b',
198
+ darkolivegreen: '#556b2f',
199
+ darkorange: '#ff8c00',
200
+ darkorchid: '#9932cc',
201
+ darkred: '#8b0000',
202
+ darksalmon: '#e9967a',
203
+ darkseagreen: '#8fbc8f',
204
+ darkslateblue: '#483d8b',
205
+ darkslategray: '#2f4f4f',
206
+ darkslategrey: '#2f4f4f',
207
+ darkturquoise: '#00ced1',
208
+ darkviolet: '#9400d3',
209
+ deeppink: '#ff1493',
210
+ deepskyblue: '#00bfff',
211
+ dimgray: '#696969',
212
+ dimgrey: '#696969',
213
+ dodgerblue: '#1e90ff',
214
+ firebrick: '#b22222',
215
+ floralwhite: '#fffaf0',
216
+ forestgreen: '#228b22',
217
+ fuchsia: '#ff00ff',
218
+ gainsboro: '#dcdcdc',
219
+ ghostwhite: '#f8f8ff',
220
+ gold: '#ffd700',
221
+ goldenrod: '#daa520',
222
+ gray: '#808080',
223
+ green: '#008000',
224
+ greenyellow: '#adff2f',
225
+ grey: '#808080',
226
+ honeydew: '#f0fff0',
227
+ hotpink: '#ff69b4',
228
+ indianred: '#cd5c5c',
229
+ indigo: '#4b0082',
230
+ ivory: '#fffff0',
231
+ khaki: '#f0e68c',
232
+ lavender: '#e6e6fa',
233
+ lavenderblush: '#fff0f5',
234
+ lawngreen: '#7cfc00',
235
+ lemonchiffon: '#fffacd',
236
+ lightblue: '#add8e6',
237
+ lightcoral: '#f08080',
238
+ lightcyan: '#e0ffff',
239
+ lightgoldenrodyellow: '#fafad2',
240
+ lightgray: '#d3d3d3',
241
+ lightgreen: '#90ee90',
242
+ lightgrey: '#d3d3d3',
243
+ lightpink: '#ffb6c1',
244
+ lightsalmon: '#ffa07a',
245
+ lightseagreen: '#20b2aa',
246
+ lightskyblue: '#87cefa',
247
+ lightslategray: '#778899',
248
+ lightslategrey: '#778899',
249
+ lightsteelblue: '#b0c4de',
250
+ lightyellow: '#ffffe0',
251
+ lime: '#00ff00',
252
+ limegreen: '#32cd32',
253
+ linen: '#faf0e6',
254
+ magenta: '#ff00ff',
255
+ maroon: '#800000',
256
+ mediumaquamarine: '#66cdaa',
257
+ mediumblue: '#0000cd',
258
+ mediumorchid: '#ba55d3',
259
+ mediumpurple: '#9370db',
260
+ mediumseagreen: '#3cb371',
261
+ mediumslateblue: '#7b68ee',
262
+ mediumspringgreen: '#00fa9a',
263
+ mediumturquoise: '#48d1cc',
264
+ mediumvioletred: '#c71585',
265
+ midnightblue: '#191970',
266
+ mintcream: '#f5fffa',
267
+ mistyrose: '#ffe4e1',
268
+ moccasin: '#ffe4b5',
269
+ navajowhite: '#ffdead',
270
+ navy: '#000080',
271
+ oldlace: '#fdf5e6',
272
+ olive: '#808000',
273
+ olivedrab: '#6b8e23',
274
+ orange: '#ffa500',
275
+ orangered: '#ff4500',
276
+ orchid: '#da70d6',
277
+ palegoldenrod: '#eee8aa',
278
+ palegreen: '#98fb98',
279
+ paleturquoise: '#afeeee',
280
+ palevioletred: '#db7093',
281
+ papayawhip: '#ffefd5',
282
+ peachpuff: '#ffdab9',
283
+ peru: '#cd853f',
284
+ pink: '#ffc0cb',
285
+ plum: '#dda0dd',
286
+ powderblue: '#b0e0e6',
287
+ purple: '#800080',
288
+ rebeccapurple: '#663399',
289
+ red: '#ff0000',
290
+ rosybrown: '#bc8f8f',
291
+ royalblue: '#4169e1',
292
+ saddlebrown: '#8b4513',
293
+ salmon: '#fa8072',
294
+ sandybrown: '#f4a460',
295
+ seagreen: '#2e8b57',
296
+ seashell: '#fff5ee',
297
+ sienna: '#a0522d',
298
+ silver: '#c0c0c0',
299
+ skyblue: '#87ceeb',
300
+ slateblue: '#6a5acd',
301
+ slategray: '#708090',
302
+ slategrey: '#708090',
303
+ snow: '#fffafa',
304
+ springgreen: '#00ff7f',
305
+ steelblue: '#4682b4',
306
+ tan: '#d2b48c',
307
+ teal: '#008080',
308
+ thistle: '#d8bfd8',
309
+ tomato: '#ff6347',
310
+ turquoise: '#40e0d0',
311
+ violet: '#ee82ee',
312
+ wheat: '#f5deb3',
313
+ white: '#ffffff',
314
+ whitesmoke: '#f5f5f5',
315
+ yellow: '#ffff00',
316
+ yellowgreen: '#9acd32'
317
+ };
318
+
319
+ function clampChannel(value) {
320
+ return Math.min(255, Math.max(0, Math.round(value)));
321
+ }
322
+
323
+ function normalizeRgbArgs(args) {
324
+ const parts = args.split(/[,\s/]+/).filter(Boolean);
325
+ if (parts.length < 3) return null;
326
+ const to255 = (value) => {
327
+ if (typeof value !== 'string' || !value) return null;
328
+ if (value.endsWith('%')) return clampChannel((parseFloat(value) / 100) * 255);
329
+ const number = Number(value);
330
+ return Number.isFinite(number) ? clampChannel(number) : null;
331
+ };
332
+ const red = to255(parts[0]);
333
+ const green = to255(parts[1]);
334
+ const blue = to255(parts[2]);
335
+ if (red === null || green === null || blue === null) return null;
336
+ return `#${[red, green, blue].map((n) => n.toString(16).padStart(2, '0')).join('')}`;
337
+ }
338
+
339
+ /**
340
+ * Convert any supported CSS color into `#rrggbb`. Returns null when the
341
+ * value cannot be parsed. Supports: #rgb / #rrggbb (case-insensitive),
342
+ * CSS named colors, rgb() / rgba() with comma or modern space syntax,
343
+ * including percentages. Alpha in rgba() is ignored.
344
+ */
345
+ function normalizeColor(value) {
346
+ if (typeof value !== 'string') return null;
347
+ const input = value.trim();
348
+ if (!input) return null;
349
+ const lower = input.toLowerCase();
350
+ if (NAMED_COLORS[lower]) return NAMED_COLORS[lower];
351
+ if (/^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(input)) {
352
+ const hex = input.slice(1).toLowerCase();
353
+ if (hex.length === 3) return `#${hex.split('').map((c) => c + c).join('')}`;
354
+ return `#${hex}`;
355
+ }
356
+ const match = input.match(/^rgba?\((.*)\)$/i);
357
+ if (match) return normalizeRgbArgs(match[1]);
358
+ return null;
359
+ }
360
+
361
+ function hexToRgb01(color) {
362
+ const hex = normalizeColor(color);
363
+ if (!hex) return [0, 0, 0];
364
+ const value = Number.parseInt(hex.slice(1), 16);
365
+ return [
366
+ ((value >> 16) & 255) / 255,
367
+ ((value >> 8) & 255) / 255,
368
+ (value & 255) / 255
369
+ ];
197
370
  }
198
371
 
199
372
  const VERTEX_SHADER = `#version 300 es
@@ -421,12 +594,31 @@ class CosmicRenderer {
421
594
  this.timeOffset = preset.seed * 0.73;
422
595
  }
423
596
 
424
- setDprCap(cap) {
425
- this.options.dprCap = cap;
426
- this.resize();
427
- }
428
-
429
- randomize() {
597
+ setDprCap(cap) {
598
+ this.options.dprCap = cap;
599
+ this.resize();
600
+ }
601
+
602
+ setMouseColor(enabled) {
603
+ this.options.mouseColor = enabled !== false;
604
+ if (this.options.mouseColor) {
605
+ if (this.onPointerMove) return this;
606
+ this.#bindEvents();
607
+ return this;
608
+ }
609
+ if (this.onPointerMove) {
610
+ const target = this.eventTarget;
611
+ target.removeEventListener('pointermove', this.onPointerMove);
612
+ target.removeEventListener('pointerdown', this.onPointerMove);
613
+ target.removeEventListener('pointerleave', this.onPointerLeave);
614
+ this.onPointerMove = null;
615
+ this.onPointerLeave = null;
616
+ }
617
+ this.motionTarget = 0;
618
+ return this;
619
+ }
620
+
621
+ randomize() {
430
622
  this.preset.seed = Math.random() * 100;
431
623
  this.timeOffset = Math.random() * 40;
432
624
  }
@@ -506,10 +698,12 @@ class FallbackRenderer {
506
698
  }
507
699
  }
508
700
 
509
- drawNebula(time) {
510
- const ctx = this.context;
511
- const { width, height } = this.canvas;
512
- const gradient = ctx.createLinearGradient(0, 0, width, height);
701
+ drawNebula(time) {
702
+ const ctx = this.context;
703
+ const { width, height } = this.canvas;
704
+ const t = time * (this.preset.speed || 1);
705
+ const phase = (this.preset.seed || 0) * 0.7;
706
+ const gradient = ctx.createLinearGradient(0, 0, width, height);
513
707
  gradient.addColorStop(0, this.preset.colors[0]);
514
708
  gradient.addColorStop(0.38, this.preset.colors[1]);
515
709
  gradient.addColorStop(0.72, this.preset.colors[2]);
@@ -517,10 +711,10 @@ class FallbackRenderer {
517
711
  ctx.fillStyle = gradient;
518
712
  ctx.fillRect(0, 0, width, height);
519
713
 
520
- ctx.globalCompositeOperation = 'screen';
521
- for (let index = 0; index < 6; index += 1) {
522
- const x = (0.5 + 0.45 * Math.sin(time * 0.32 + index * 1.7)) * width;
523
- const y = (0.5 + 0.4 * Math.cos(time * 0.25 + index)) * height;
714
+ ctx.globalCompositeOperation = 'screen';
715
+ for (let index = 0; index < 6; index += 1) {
716
+ const x = (0.5 + 0.45 * Math.sin(t * 0.32 + index * 1.7 + phase)) * width;
717
+ const y = (0.5 + 0.4 * Math.cos(t * 0.25 + index + phase)) * height;
524
718
  const radius = Math.max(width, height) * (0.08 + (index % 3) * 0.04);
525
719
  const glow = ctx.createRadialGradient(x, y, 0, x, y, radius);
526
720
  glow.addColorStop(0, rgb(this.preset.colors[(index + 1) % 4], 0.24));
@@ -541,8 +735,11 @@ class FallbackRenderer {
541
735
  this.preset = preset;
542
736
  }
543
737
 
544
- randomize() {}
545
- dispose() {}
738
+ randomize() {
739
+ if (this.preset) this.preset.seed = Math.random() * 100;
740
+ }
741
+ setMouseColor() {}
742
+ dispose() {}
546
743
  }
547
744
 
548
745
  /**
@@ -642,78 +839,105 @@ function createVisibilityGuard(element) {
642
839
  };
643
840
  }
644
841
 
842
+ /**
843
+ * Run a callback asynchronously, right after the current task and before the
844
+ * next paint. Used for mount-time events (ready / error) so listeners that
845
+ * are attached after create() still receive them. Falls back for legacy
846
+ * browsers that lack queueMicrotask / Promise.
847
+ */
848
+ function nextTick(fn) {
849
+ if (typeof queueMicrotask === 'function') {
850
+ queueMicrotask(fn);
851
+ } else if (typeof Promise !== 'undefined' && typeof Promise.resolve === 'function') {
852
+ Promise.resolve().then(fn);
853
+ } else {
854
+ setTimeout(fn, 0);
855
+ }
856
+ }
857
+
645
858
  function prefersReducedMotion() {
646
859
  return typeof matchMedia !== 'undefined' && matchMedia('(prefers-reduced-motion: reduce)').matches;
647
860
  }
648
861
 
649
862
  /**
650
- * Mount a cosmic (nebula) capsule into `container`.
651
- *
652
- * Options: preset (id/code/name or object), width, height (number=px or
653
- * string with px/%/vw/vh/em/rem), colors, seed, speed, quality, interactive,
654
- * respectReducedMotion, copy, cssVars.
655
- */
656
- function createCapsule(container, options = {}) {
863
+ * Mount a cosmic (nebula) capsule into `container`.
864
+ *
865
+ * Options: preset (literary name), width, height (number=px or string with
866
+ * px/%/vw/vh/em/rem), colors, seed, speed, textRatio (0-100, text region
867
+ * width in percent), text (HTML string or DOM nodes for the text slot),
868
+ * colorContent (HTML string or DOM nodes for the color slot), quality,
869
+ * renderer, respectReducedMotion, mouseColor, cssVars.
870
+ */
871
+ function createCapsule(container, options = {}) {
657
872
  if (!container || typeof container.appendChild !== 'function') {
658
873
  throw new Error('createCapsule: container element is required');
659
874
  }
660
875
 
661
- const preset = { ...getPreset('capsule', options.preset ?? 'NC-01') };
662
- const merged = normalizeOptions(DEFAULTS.capsule, preset, options);
663
- const copy = { ...COPY, ...(merged.copy || {}) };
664
-
665
- const root = document.createElement('div');
666
- root.className = 'hj-capsule-root hj-capsule-cosmic';
667
- root.dataset.group = preset.group;
668
- root.dataset.mode = 'nebula';
669
- root.dataset.theme = preset.theme || 'light';
670
- root.setAttribute('aria-label', copy.capsuleAria.replace('{name}', preset.name));
671
-
672
- const copyEnabled = copy.enabled !== false && merged.showCopy !== false;
673
-
674
- const copyText = (key, fallback) => {
675
- const value = copy[key];
676
- return value === undefined ? fallback : value;
677
- };
678
-
679
- function buildCopyHtml() {
680
- const codeText = copyText('code', preset.code);
681
- const nameText = copyText('name', preset.name);
682
- const subtitleText = copyText('subtitle', preset.subtitle);
683
- const stateText = copyText('state', 'LIVE COSMIC STUDY');
684
- let html = '';
685
- if (codeText) html += `<span class="hj-capsule-code">${codeText}</span>`;
686
- if (nameText) html += `<span class="hj-capsule-name">${nameText}</span>`;
687
- if (subtitleText) html += `<span class="hj-capsule-brand">${subtitleText}</span>`;
688
- if (stateText) html += `<span class="hj-capsule-state">${stateText}</span>`;
689
- return html;
690
- }
691
-
692
- const copyLayer = document.createElement('div');
693
- copyLayer.className = 'hj-capsule-copy';
694
- const renderCopy = () => {
695
- if (copyEnabled) copyLayer.innerHTML = buildCopyHtml();
696
- };
697
- renderCopy();
698
-
699
- const isHexColor = (value) => typeof value === 'string' && /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(value);
700
- if (copy.background) {
701
- root.style.setProperty('--hj-copy-bg', isHexColor(copy.background)
702
- ? `linear-gradient(90deg, ${copy.background} 0%, ${copy.background} 74%, ${hexToRgba(copy.background, 0.97)} 84%, ${hexToRgba(copy.background, 0)} 100%)`
703
- : copy.background);
704
- }
705
- if (copy.color) root.style.setProperty('--hj-copy-color', copy.color);
706
-
707
- const canvas = document.createElement('canvas');
708
- canvas.className = 'hj-capsule-canvas';
709
- canvas.setAttribute('aria-hidden', 'true');
710
-
711
- if (copyEnabled) root.appendChild(copyLayer);
712
- root.appendChild(canvas);
713
- container.appendChild(root);
714
-
715
- const emitter = createEmitter();
716
- let paused = merged.respectReducedMotion && prefersReducedMotion();
876
+ const preset = { ...getPreset('capsule', options.preset ?? '初光') };
877
+ const merged = normalizeOptions(DEFAULTS.capsule, preset, options);
878
+ const normalizedColors = merged.colors.map(normalizeColor);
879
+ if (normalizedColors.every(Boolean)) preset.colors = normalizedColors;
880
+ preset.seed = merged.seed;
881
+ preset.speed = merged.speed;
882
+ let customLabel = typeof options.label === 'string' && options.label ? options.label : null;
883
+
884
+ const root = document.createElement('div');
885
+ root.className = 'hj-capsule-root hj-capsule-cosmic';
886
+ root.dataset.group = preset.group;
887
+ root.dataset.mode = 'nebula';
888
+ root.dataset.theme = preset.theme || 'light';
889
+ root.setAttribute('role', 'img');
890
+ const updateAria = () => {
891
+ root.setAttribute('aria-label', customLabel || COPY.capsuleAria.replace('{name}', preset.name));
892
+ };
893
+ updateAria();
894
+
895
+ // Slot containers are always present but transparent by default: without
896
+ // content they are invisible, so "no slot" behaves like the old
897
+ // showCopy=false. They only provide geometry — no typography, padding or
898
+ // background is imposed on user content (see docs: 插槽 CSS 约定).
899
+ const fillContent = (layer, content) => {
900
+ layer.innerHTML = '';
901
+ if (content == null) return;
902
+ if (typeof content === 'string') {
903
+ layer.innerHTML = content;
904
+ return;
905
+ }
906
+ const nodes = Array.isArray(content) ? content : [content];
907
+ for (const node of nodes) {
908
+ if (node && typeof node.nodeType === 'number') layer.appendChild(node);
909
+ }
910
+ };
911
+
912
+ const textLayer = document.createElement('div');
913
+ textLayer.className = 'hj-capsule-text hj-capsule-copy';
914
+
915
+ const visualLayer = document.createElement('div');
916
+ visualLayer.className = 'hj-capsule-visual';
917
+
918
+ fillContent(textLayer, options.text);
919
+ fillContent(visualLayer, options.colorContent);
920
+
921
+ const canvas = document.createElement('canvas');
922
+ canvas.className = 'hj-capsule-canvas';
923
+ canvas.setAttribute('aria-hidden', 'true');
924
+
925
+ root.appendChild(textLayer);
926
+ root.appendChild(visualLayer);
927
+ root.appendChild(canvas);
928
+ container.appendChild(root);
929
+
930
+ const emitter = createEmitter();
931
+ let paused = merged.respectReducedMotion && prefersReducedMotion();
932
+ let disposed = false;
933
+ const dirty = {
934
+ preset: false,
935
+ seed: false,
936
+ speed: false,
937
+ colors: false,
938
+ textRatio: false,
939
+ cssVars: false
940
+ };
717
941
 
718
942
  let renderer;
719
943
  const useWebgl = merged.renderer !== 'canvas2d';
@@ -722,19 +946,25 @@ function createCapsule(container, options = {}) {
722
946
  renderer = new CosmicRenderer(canvas, merged, { dprCap: dprCapFor(merged.quality) });
723
947
  } catch (error) {
724
948
  renderer = new FallbackRenderer(canvas, merged);
725
- emitter.emit('error', { message: String(error && error.message ? error.message : error) });
949
+ nextTick(() => {
950
+ if (disposed) return;
951
+ emitter.emit('error', { message: String(error && error.message ? error.message : error) });
952
+ });
726
953
  }
727
954
  } else {
728
955
  renderer = new FallbackRenderer(canvas, merged);
729
956
  }
730
957
 
731
- const applySize = () => {
732
- root.style.width = parseSize(merged.width);
733
- root.style.height = parseSize(merged.height);
734
- const vars = merged.cssVars || {};
735
- for (const name of Object.keys(vars)) root.style.setProperty(name, vars[name]);
736
- renderer.resize();
737
- };
958
+ const applySize = () => {
959
+ root.style.width = parseSize(merged.width);
960
+ root.style.height = parseSize(merged.height);
961
+ const vars = merged.cssVars || {};
962
+ for (const name of Object.keys(vars)) root.style.setProperty(name, vars[name]);
963
+ if (merged.textRatio !== undefined) {
964
+ root.style.setProperty('--hj-text-width', `${merged.textRatio}%`);
965
+ }
966
+ renderer.resize();
967
+ };
738
968
  applySize();
739
969
 
740
970
  const resizeObserver = typeof ResizeObserver !== 'undefined'
@@ -745,10 +975,8 @@ function createCapsule(container, options = {}) {
745
975
 
746
976
  const visibility = createVisibilityGuard(root);
747
977
 
748
- if (merged.interactive !== false) {
749
- root.addEventListener('pointerenter', () => emitter.emit('pointerenter', { preset: { ...preset } }));
750
- root.addEventListener('pointerleave', () => emitter.emit('pointerleave', { preset: { ...preset } }));
751
- }
978
+ root.addEventListener('pointerenter', () => emitter.emit('pointerenter', { preset: { ...preset } }));
979
+ root.addEventListener('pointerleave', () => emitter.emit('pointerleave', { preset: { ...preset } }));
752
980
  root.addEventListener('click', (event) => {
753
981
  emitter.emit('click', { event, preset: { ...preset } });
754
982
  });
@@ -765,45 +993,97 @@ function createCapsule(container, options = {}) {
765
993
  () => paused
766
994
  );
767
995
 
768
- emitter.emit('ready', { preset: { ...preset } });
769
-
770
- const syncCopy = () => {
771
- root.dataset.mode = 'nebula';
772
- root.dataset.theme = preset.theme || 'light';
773
- renderCopy();
774
- };
996
+ nextTick(() => {
997
+ if (disposed) return;
998
+ emitter.emit('ready', { preset: { ...preset } });
999
+ });
775
1000
 
776
- return {
1001
+ const syncTheme = () => {
1002
+ root.dataset.mode = 'nebula';
1003
+ root.dataset.theme = preset.theme || 'light';
1004
+ updateAria();
1005
+ };
1006
+
1007
+ return {
777
1008
  element: root,
778
1009
  canvas,
779
1010
  preset,
780
1011
  on: emitter.on,
781
1012
  off: emitter.off,
782
- setPreset(ref) {
783
- const next = getPreset('capsule', ref);
784
- Object.assign(preset, next);
785
- renderer.setPreset(next);
786
- syncCopy();
787
- renderer.resize();
788
- emitter.emit('presetchange', { preset: { ...next } });
789
- return this;
1013
+ setPreset(ref) {
1014
+ const next = getPreset('capsule', ref);
1015
+ dirty.preset = true;
1016
+ Object.assign(preset, next);
1017
+ const nextColors = preset.colors.map(normalizeColor);
1018
+ if (nextColors.every(Boolean)) preset.colors = nextColors;
1019
+ renderer.setPreset({ ...preset });
1020
+ syncTheme();
1021
+ renderer.resize();
1022
+ emitter.emit('presetchange', { preset: { ...next } });
1023
+ return this;
790
1024
  },
791
- setColors(colors) {
792
- if (!Array.isArray(colors) || colors.length !== 4) return this;
793
- preset.colors = colors;
794
- renderer.setPreset({ ...preset, colors });
795
- return this;
796
- },
797
- setSize(width, height) {
1025
+ setColors(colors) {
1026
+ const next = Array.isArray(colors) ? colors.map(normalizeColor) : [];
1027
+ if (next.length !== 4 || next.some((color) => !color)) return this;
1028
+ dirty.colors = true;
1029
+ preset.colors = next;
1030
+ renderer.setPreset({ ...preset, colors: next });
1031
+ return this;
1032
+ },
1033
+ setSeed(seed) {
1034
+ const value = Number(seed);
1035
+ if (!Number.isFinite(value)) return this;
1036
+ dirty.seed = true;
1037
+ preset.seed = value;
1038
+ renderer.setPreset({ ...preset });
1039
+ return this;
1040
+ },
1041
+ setSpeed(speed) {
1042
+ const value = Number(speed);
1043
+ if (!Number.isFinite(value)) return this;
1044
+ dirty.speed = true;
1045
+ preset.speed = value;
1046
+ renderer.setPreset({ ...preset });
1047
+ return this;
1048
+ },
1049
+ setText(content) {
1050
+ fillContent(textLayer, content);
1051
+ return this;
1052
+ },
1053
+ setColorContent(content) {
1054
+ fillContent(visualLayer, content);
1055
+ return this;
1056
+ },
1057
+ setTextRatio(ratio) {
1058
+ const value = Number(ratio);
1059
+ if (!Number.isFinite(value) || value < 0 || value > 100) return this;
1060
+ dirty.textRatio = true;
1061
+ merged.textRatio = value;
1062
+ root.style.setProperty('--hj-text-width', `${value}%`);
1063
+ return this;
1064
+ },
1065
+ setCssVars(vars) {
1066
+ if (!vars || typeof vars !== 'object') return this;
1067
+ dirty.cssVars = true;
1068
+ merged.cssVars = { ...(merged.cssVars || {}), ...vars };
1069
+ for (const name of Object.keys(vars)) root.style.setProperty(name, vars[name]);
1070
+ return this;
1071
+ },
1072
+ setLabel(label) {
1073
+ customLabel = typeof label === 'string' && label ? label : null;
1074
+ updateAria();
1075
+ return this;
1076
+ },
1077
+ setSize(width, height) {
798
1078
  if (width !== undefined) merged.width = width;
799
1079
  if (height !== undefined) merged.height = height;
800
1080
  applySize();
801
1081
  return this;
802
1082
  },
803
- randomize() {
804
- renderer.randomize();
805
- return this;
806
- },
1083
+ randomize() {
1084
+ this.setSeed(Math.random() * 100);
1085
+ return this;
1086
+ },
807
1087
  pause() {
808
1088
  paused = true;
809
1089
  return this;
@@ -817,15 +1097,19 @@ function createCapsule(container, options = {}) {
817
1097
  if (typeof renderer.setDprCap === 'function') renderer.setDprCap(dprCapFor(quality));
818
1098
  return this;
819
1099
  },
820
- dispose() {
821
- unsubscribe();
1100
+ dispose() {
1101
+ disposed = true;
1102
+ unsubscribe();
822
1103
  visibility.dispose();
823
1104
  if (resizeObserver) resizeObserver.disconnect();
824
1105
  else window.removeEventListener('resize', renderer.resize);
825
- renderer.dispose();
826
- root.remove();
827
- }
828
- };
1106
+ renderer.dispose();
1107
+ root.remove();
1108
+ },
1109
+ textRatio: merged.textRatio,
1110
+ cssVars: merged.cssVars,
1111
+ dirty
1112
+ };
829
1113
  }
830
1114
 
831
1115
  export { createCapsule };