@5even7/dlc-ui 0.2.0 → 0.2.2

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/index.mjs CHANGED
@@ -183,64 +183,272 @@ function getPreset(kind, ref) {
183
183
  return found;
184
184
  }
185
185
 
186
- const DEFAULTS = {
187
- capsule: {
188
- width: '100%',
189
- height: 160,
186
+ const DEFAULTS = {
187
+ capsule: {
188
+ width: '100%',
189
+ height: 160,
190
190
  quality: 'auto',
191
191
  renderer: 'auto',
192
192
  respectReducedMotion: true,
193
193
  mouseColor: true,
194
- showCopy: true
195
- },
196
- progress: {
197
- width: 454,
198
- height: 104,
199
- min: 0,
194
+ textRatio: 39
195
+ },
196
+ progress: {
197
+ width: 454,
198
+ height: 104,
199
+ min: 0,
200
200
  max: 100,
201
- draggable: true,
202
- keyboard: true,
203
- edgeStyle: 'flow',
204
- quality: 'auto',
205
- renderer: 'auto',
206
- showCopy: true,
207
- respectReducedMotion: true
208
- }
201
+ draggable: true,
202
+ keyboard: true,
203
+ disabled: false,
204
+ valueSuffix: '%',
205
+ edgeStyle: 'flow',
206
+ quality: 'auto',
207
+ renderer: 'auto',
208
+ textRatio: 54,
209
+ showValue: true,
210
+ respectReducedMotion: true
211
+ }
209
212
  };
210
213
 
211
- /**
212
- * All user-facing copy lives here so wording/brand changes never require a
213
- * global search. Templates use {brand} {code} {name} placeholders.
214
- */
215
- const COPY = {
216
- brandName: '画境观屿',
217
- dragLabel: 'DRAG',
218
- valueSuffix: '%',
214
+ /**
215
+ * Internal accessibility labels and small UI text. Since the copy API was
216
+ * removed (slots own the text area), these are no longer user-overridable.
217
+ * Templates use {brand} {code} {name} placeholders.
218
+ */
219
+ const COPY = {
220
+ brandName: '画境观屿',
221
+ valueSuffix: '%',
219
222
  progressAria: '{brand} {code} 加载进度',
220
223
  capsuleAria: '打开 {name} 沉浸预览'
221
224
  };
222
225
 
223
- function hexToRgb01$1(hex) {
224
- const normalized = hex.replace('#', '');
225
- const value = Number.parseInt(normalized, 16);
226
- return [
227
- ((value >> 16) & 255) / 255,
228
- ((value >> 8) & 255) / 255,
229
- (value & 255) / 255
230
- ];
231
- }
232
-
233
- function hexToRgba(hex, alpha = 1) {
234
- const normalized = hex.replace('#', '');
235
- const value = Number.parseInt(normalized, 16);
236
- const red = (value >> 16) & 255;
237
- const green = (value >> 8) & 255;
238
- const blue = value & 255;
239
- return `rgba(${red}, ${green}, ${blue}, ${alpha})`;
240
- }
241
-
242
- function validateColors(colors) {
243
- return Array.isArray(colors) && colors.length === 4 && colors.every((c) => typeof c === 'string' && /^#[0-9a-f]{6}$/i.test(c));
226
+ /**
227
+ * Color helpers. `normalizeColor` converts any supported CSS color
228
+ * (hex / named / rgb() / rgba()) into a 6-digit hex string, so renderers can
229
+ * keep working with #rrggbb only. rgba() alpha is intentionally dropped:
230
+ * the WebGL shader has no per-color alpha channel, and the component is
231
+ * opaque by design use component-level `opacity` for transparency.
232
+ */
233
+
234
+ const NAMED_COLORS = {
235
+ aliceblue: '#f0f8ff',
236
+ antiquewhite: '#faebd7',
237
+ aqua: '#00ffff',
238
+ aquamarine: '#7fffd4',
239
+ azure: '#f0ffff',
240
+ beige: '#f5f5dc',
241
+ bisque: '#ffe4c4',
242
+ black: '#000000',
243
+ blanchedalmond: '#ffebcd',
244
+ blue: '#0000ff',
245
+ blueviolet: '#8a2be2',
246
+ brown: '#a52a2a',
247
+ burlywood: '#deb887',
248
+ cadetblue: '#5f9ea0',
249
+ chartreuse: '#7fff00',
250
+ chocolate: '#d2691e',
251
+ coral: '#ff7f50',
252
+ cornflowerblue: '#6495ed',
253
+ cornsilk: '#fff8dc',
254
+ crimson: '#dc143c',
255
+ cyan: '#00ffff',
256
+ darkblue: '#00008b',
257
+ darkcyan: '#008b8b',
258
+ darkgoldenrod: '#b8860b',
259
+ darkgray: '#a9a9a9',
260
+ darkgreen: '#006400',
261
+ darkgrey: '#a9a9a9',
262
+ darkkhaki: '#bdb76b',
263
+ darkmagenta: '#8b008b',
264
+ darkolivegreen: '#556b2f',
265
+ darkorange: '#ff8c00',
266
+ darkorchid: '#9932cc',
267
+ darkred: '#8b0000',
268
+ darksalmon: '#e9967a',
269
+ darkseagreen: '#8fbc8f',
270
+ darkslateblue: '#483d8b',
271
+ darkslategray: '#2f4f4f',
272
+ darkslategrey: '#2f4f4f',
273
+ darkturquoise: '#00ced1',
274
+ darkviolet: '#9400d3',
275
+ deeppink: '#ff1493',
276
+ deepskyblue: '#00bfff',
277
+ dimgray: '#696969',
278
+ dimgrey: '#696969',
279
+ dodgerblue: '#1e90ff',
280
+ firebrick: '#b22222',
281
+ floralwhite: '#fffaf0',
282
+ forestgreen: '#228b22',
283
+ fuchsia: '#ff00ff',
284
+ gainsboro: '#dcdcdc',
285
+ ghostwhite: '#f8f8ff',
286
+ gold: '#ffd700',
287
+ goldenrod: '#daa520',
288
+ gray: '#808080',
289
+ green: '#008000',
290
+ greenyellow: '#adff2f',
291
+ grey: '#808080',
292
+ honeydew: '#f0fff0',
293
+ hotpink: '#ff69b4',
294
+ indianred: '#cd5c5c',
295
+ indigo: '#4b0082',
296
+ ivory: '#fffff0',
297
+ khaki: '#f0e68c',
298
+ lavender: '#e6e6fa',
299
+ lavenderblush: '#fff0f5',
300
+ lawngreen: '#7cfc00',
301
+ lemonchiffon: '#fffacd',
302
+ lightblue: '#add8e6',
303
+ lightcoral: '#f08080',
304
+ lightcyan: '#e0ffff',
305
+ lightgoldenrodyellow: '#fafad2',
306
+ lightgray: '#d3d3d3',
307
+ lightgreen: '#90ee90',
308
+ lightgrey: '#d3d3d3',
309
+ lightpink: '#ffb6c1',
310
+ lightsalmon: '#ffa07a',
311
+ lightseagreen: '#20b2aa',
312
+ lightskyblue: '#87cefa',
313
+ lightslategray: '#778899',
314
+ lightslategrey: '#778899',
315
+ lightsteelblue: '#b0c4de',
316
+ lightyellow: '#ffffe0',
317
+ lime: '#00ff00',
318
+ limegreen: '#32cd32',
319
+ linen: '#faf0e6',
320
+ magenta: '#ff00ff',
321
+ maroon: '#800000',
322
+ mediumaquamarine: '#66cdaa',
323
+ mediumblue: '#0000cd',
324
+ mediumorchid: '#ba55d3',
325
+ mediumpurple: '#9370db',
326
+ mediumseagreen: '#3cb371',
327
+ mediumslateblue: '#7b68ee',
328
+ mediumspringgreen: '#00fa9a',
329
+ mediumturquoise: '#48d1cc',
330
+ mediumvioletred: '#c71585',
331
+ midnightblue: '#191970',
332
+ mintcream: '#f5fffa',
333
+ mistyrose: '#ffe4e1',
334
+ moccasin: '#ffe4b5',
335
+ navajowhite: '#ffdead',
336
+ navy: '#000080',
337
+ oldlace: '#fdf5e6',
338
+ olive: '#808000',
339
+ olivedrab: '#6b8e23',
340
+ orange: '#ffa500',
341
+ orangered: '#ff4500',
342
+ orchid: '#da70d6',
343
+ palegoldenrod: '#eee8aa',
344
+ palegreen: '#98fb98',
345
+ paleturquoise: '#afeeee',
346
+ palevioletred: '#db7093',
347
+ papayawhip: '#ffefd5',
348
+ peachpuff: '#ffdab9',
349
+ peru: '#cd853f',
350
+ pink: '#ffc0cb',
351
+ plum: '#dda0dd',
352
+ powderblue: '#b0e0e6',
353
+ purple: '#800080',
354
+ rebeccapurple: '#663399',
355
+ red: '#ff0000',
356
+ rosybrown: '#bc8f8f',
357
+ royalblue: '#4169e1',
358
+ saddlebrown: '#8b4513',
359
+ salmon: '#fa8072',
360
+ sandybrown: '#f4a460',
361
+ seagreen: '#2e8b57',
362
+ seashell: '#fff5ee',
363
+ sienna: '#a0522d',
364
+ silver: '#c0c0c0',
365
+ skyblue: '#87ceeb',
366
+ slateblue: '#6a5acd',
367
+ slategray: '#708090',
368
+ slategrey: '#708090',
369
+ snow: '#fffafa',
370
+ springgreen: '#00ff7f',
371
+ steelblue: '#4682b4',
372
+ tan: '#d2b48c',
373
+ teal: '#008080',
374
+ thistle: '#d8bfd8',
375
+ tomato: '#ff6347',
376
+ turquoise: '#40e0d0',
377
+ violet: '#ee82ee',
378
+ wheat: '#f5deb3',
379
+ white: '#ffffff',
380
+ whitesmoke: '#f5f5f5',
381
+ yellow: '#ffff00',
382
+ yellowgreen: '#9acd32'
383
+ };
384
+
385
+ function clampChannel(value) {
386
+ return Math.min(255, Math.max(0, Math.round(value)));
387
+ }
388
+
389
+ function normalizeRgbArgs(args) {
390
+ const parts = args.split(/[,\s/]+/).filter(Boolean);
391
+ if (parts.length < 3) return null;
392
+ const to255 = (value) => {
393
+ if (typeof value !== 'string' || !value) return null;
394
+ if (value.endsWith('%')) return clampChannel((parseFloat(value) / 100) * 255);
395
+ const number = Number(value);
396
+ return Number.isFinite(number) ? clampChannel(number) : null;
397
+ };
398
+ const red = to255(parts[0]);
399
+ const green = to255(parts[1]);
400
+ const blue = to255(parts[2]);
401
+ if (red === null || green === null || blue === null) return null;
402
+ return `#${[red, green, blue].map((n) => n.toString(16).padStart(2, '0')).join('')}`;
403
+ }
404
+
405
+ /**
406
+ * Convert any supported CSS color into `#rrggbb`. Returns null when the
407
+ * value cannot be parsed. Supports: #rgb / #rrggbb (case-insensitive),
408
+ * CSS named colors, rgb() / rgba() with comma or modern space syntax,
409
+ * including percentages. Alpha in rgba() is ignored.
410
+ */
411
+ function normalizeColor(value) {
412
+ if (typeof value !== 'string') return null;
413
+ const input = value.trim();
414
+ if (!input) return null;
415
+ const lower = input.toLowerCase();
416
+ if (NAMED_COLORS[lower]) return NAMED_COLORS[lower];
417
+ if (/^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(input)) {
418
+ const hex = input.slice(1).toLowerCase();
419
+ if (hex.length === 3) return `#${hex.split('').map((c) => c + c).join('')}`;
420
+ return `#${hex}`;
421
+ }
422
+ const match = input.match(/^rgba?\((.*)\)$/i);
423
+ if (match) return normalizeRgbArgs(match[1]);
424
+ return null;
425
+ }
426
+
427
+ function hexToRgb01$1(color) {
428
+ const hex = normalizeColor(color);
429
+ if (!hex) return [0, 0, 0];
430
+ const value = Number.parseInt(hex.slice(1), 16);
431
+ return [
432
+ ((value >> 16) & 255) / 255,
433
+ ((value >> 8) & 255) / 255,
434
+ (value & 255) / 255
435
+ ];
436
+ }
437
+
438
+ function hexToRgba(color, alpha = 1) {
439
+ const hex = normalizeColor(color);
440
+ if (!hex) return 'rgba(0, 0, 0, 0)';
441
+ const value = Number.parseInt(hex.slice(1), 16);
442
+ const red = (value >> 16) & 255;
443
+ const green = (value >> 8) & 255;
444
+ const blue = value & 255;
445
+ return `rgba(${red}, ${green}, ${blue}, ${alpha})`;
446
+ }
447
+
448
+ function validateColors(colors) {
449
+ return Array.isArray(colors)
450
+ && colors.length === 4
451
+ && colors.every((color) => normalizeColor(color) !== null);
244
452
  }
245
453
 
246
454
  const VERTEX_SHADER$1 = `#version 300 es
@@ -468,12 +676,31 @@ class CosmicRenderer {
468
676
  this.timeOffset = preset.seed * 0.73;
469
677
  }
470
678
 
471
- setDprCap(cap) {
472
- this.options.dprCap = cap;
473
- this.resize();
474
- }
475
-
476
- randomize() {
679
+ setDprCap(cap) {
680
+ this.options.dprCap = cap;
681
+ this.resize();
682
+ }
683
+
684
+ setMouseColor(enabled) {
685
+ this.options.mouseColor = enabled !== false;
686
+ if (this.options.mouseColor) {
687
+ if (this.onPointerMove) return this;
688
+ this.#bindEvents();
689
+ return this;
690
+ }
691
+ if (this.onPointerMove) {
692
+ const target = this.eventTarget;
693
+ target.removeEventListener('pointermove', this.onPointerMove);
694
+ target.removeEventListener('pointerdown', this.onPointerMove);
695
+ target.removeEventListener('pointerleave', this.onPointerLeave);
696
+ this.onPointerMove = null;
697
+ this.onPointerLeave = null;
698
+ }
699
+ this.motionTarget = 0;
700
+ return this;
701
+ }
702
+
703
+ randomize() {
477
704
  this.preset.seed = Math.random() * 100;
478
705
  this.timeOffset = Math.random() * 40;
479
706
  }
@@ -553,10 +780,12 @@ class FallbackRenderer {
553
780
  }
554
781
  }
555
782
 
556
- drawNebula(time) {
557
- const ctx = this.context;
558
- const { width, height } = this.canvas;
559
- const gradient = ctx.createLinearGradient(0, 0, width, height);
783
+ drawNebula(time) {
784
+ const ctx = this.context;
785
+ const { width, height } = this.canvas;
786
+ const t = time * (this.preset.speed || 1);
787
+ const phase = (this.preset.seed || 0) * 0.7;
788
+ const gradient = ctx.createLinearGradient(0, 0, width, height);
560
789
  gradient.addColorStop(0, this.preset.colors[0]);
561
790
  gradient.addColorStop(0.38, this.preset.colors[1]);
562
791
  gradient.addColorStop(0.72, this.preset.colors[2]);
@@ -564,10 +793,10 @@ class FallbackRenderer {
564
793
  ctx.fillStyle = gradient;
565
794
  ctx.fillRect(0, 0, width, height);
566
795
 
567
- ctx.globalCompositeOperation = 'screen';
568
- for (let index = 0; index < 6; index += 1) {
569
- const x = (0.5 + 0.45 * Math.sin(time * 0.32 + index * 1.7)) * width;
570
- const y = (0.5 + 0.4 * Math.cos(time * 0.25 + index)) * height;
796
+ ctx.globalCompositeOperation = 'screen';
797
+ for (let index = 0; index < 6; index += 1) {
798
+ const x = (0.5 + 0.45 * Math.sin(t * 0.32 + index * 1.7 + phase)) * width;
799
+ const y = (0.5 + 0.4 * Math.cos(t * 0.25 + index + phase)) * height;
571
800
  const radius = Math.max(width, height) * (0.08 + (index % 3) * 0.04);
572
801
  const glow = ctx.createRadialGradient(x, y, 0, x, y, radius);
573
802
  glow.addColorStop(0, rgb(this.preset.colors[(index + 1) % 4], 0.24));
@@ -588,8 +817,11 @@ class FallbackRenderer {
588
817
  this.preset = preset;
589
818
  }
590
819
 
591
- randomize() {}
592
- dispose() {}
820
+ randomize() {
821
+ if (this.preset) this.preset.seed = Math.random() * 100;
822
+ }
823
+ setMouseColor() {}
824
+ dispose() {}
593
825
  }
594
826
 
595
827
  /**
@@ -698,78 +930,105 @@ function createVisibilityGuard(element) {
698
930
  };
699
931
  }
700
932
 
933
+ /**
934
+ * Run a callback asynchronously, right after the current task and before the
935
+ * next paint. Used for mount-time events (ready / error) so listeners that
936
+ * are attached after create() still receive them. Falls back for legacy
937
+ * browsers that lack queueMicrotask / Promise.
938
+ */
939
+ function nextTick(fn) {
940
+ if (typeof queueMicrotask === 'function') {
941
+ queueMicrotask(fn);
942
+ } else if (typeof Promise !== 'undefined' && typeof Promise.resolve === 'function') {
943
+ Promise.resolve().then(fn);
944
+ } else {
945
+ setTimeout(fn, 0);
946
+ }
947
+ }
948
+
701
949
  function prefersReducedMotion$2() {
702
950
  return typeof matchMedia !== 'undefined' && matchMedia('(prefers-reduced-motion: reduce)').matches;
703
951
  }
704
952
 
705
953
  /**
706
- * Mount a cosmic (nebula) capsule into `container`.
707
- *
708
- * Options: preset (id/code/name or object), width, height (number=px or
709
- * string with px/%/vw/vh/em/rem), colors, seed, speed, quality,
710
- * respectReducedMotion, copy, cssVars.
711
- */
712
- function createCapsule(container, options = {}) {
954
+ * Mount a cosmic (nebula) capsule into `container`.
955
+ *
956
+ * Options: preset (literary name), width, height (number=px or string with
957
+ * px/%/vw/vh/em/rem), colors, seed, speed, textRatio (0-100, text region
958
+ * width in percent), text (HTML string or DOM nodes for the text slot),
959
+ * colorContent (HTML string or DOM nodes for the color slot), quality,
960
+ * renderer, respectReducedMotion, mouseColor, cssVars.
961
+ */
962
+ function createCapsule(container, options = {}) {
713
963
  if (!container || typeof container.appendChild !== 'function') {
714
964
  throw new Error('createCapsule: container element is required');
715
965
  }
716
966
 
717
967
  const preset = { ...getPreset('capsule', options.preset ?? '初光') };
718
- const merged = normalizeOptions(DEFAULTS.capsule, preset, options);
719
- const copy = { ...COPY, ...(merged.copy || {}) };
720
-
721
- const root = document.createElement('div');
722
- root.className = 'hj-capsule-root hj-capsule-cosmic';
723
- root.dataset.group = preset.group;
724
- root.dataset.mode = 'nebula';
725
- root.dataset.theme = preset.theme || 'light';
726
- root.setAttribute('aria-label', copy.capsuleAria.replace('{name}', preset.name));
727
-
728
- const copyEnabled = copy.enabled !== false && merged.showCopy !== false;
729
-
730
- const copyText = (key, fallback) => {
731
- const value = copy[key];
732
- return value === undefined ? fallback : value;
733
- };
734
-
735
- function buildCopyHtml() {
736
- const codeText = copyText('code', preset.code);
737
- const nameText = copyText('name', preset.name);
738
- const subtitleText = copyText('subtitle', preset.subtitle);
739
- const stateText = copyText('state', 'LIVE COSMIC STUDY');
740
- let html = '';
741
- if (codeText) html += `<span class="hj-capsule-code">${codeText}</span>`;
742
- if (nameText) html += `<span class="hj-capsule-name">${nameText}</span>`;
743
- if (subtitleText) html += `<span class="hj-capsule-brand">${subtitleText}</span>`;
744
- if (stateText) html += `<span class="hj-capsule-state">${stateText}</span>`;
745
- return html;
746
- }
747
-
748
- const copyLayer = document.createElement('div');
749
- copyLayer.className = 'hj-capsule-copy';
750
- const renderCopy = () => {
751
- if (copyEnabled) copyLayer.innerHTML = buildCopyHtml();
752
- };
753
- renderCopy();
754
-
755
- const isHexColor = (value) => typeof value === 'string' && /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(value);
756
- if (copy.background) {
757
- root.style.setProperty('--hj-copy-bg', isHexColor(copy.background)
758
- ? `linear-gradient(90deg, ${copy.background} 0%, ${copy.background} 74%, ${hexToRgba(copy.background, 0.97)} 84%, ${hexToRgba(copy.background, 0)} 100%)`
759
- : copy.background);
760
- }
761
- if (copy.color) root.style.setProperty('--hj-copy-color', copy.color);
762
-
763
- const canvas = document.createElement('canvas');
764
- canvas.className = 'hj-capsule-canvas';
765
- canvas.setAttribute('aria-hidden', 'true');
766
-
767
- if (copyEnabled) root.appendChild(copyLayer);
768
- root.appendChild(canvas);
769
- container.appendChild(root);
770
-
771
- const emitter = createEmitter();
772
- let paused = merged.respectReducedMotion && prefersReducedMotion$2();
968
+ const merged = normalizeOptions(DEFAULTS.capsule, preset, options);
969
+ const normalizedColors = merged.colors.map(normalizeColor);
970
+ if (normalizedColors.every(Boolean)) preset.colors = normalizedColors;
971
+ preset.seed = merged.seed;
972
+ preset.speed = merged.speed;
973
+ let customLabel = typeof options.label === 'string' && options.label ? options.label : null;
974
+
975
+ const root = document.createElement('div');
976
+ root.className = 'hj-capsule-root hj-capsule-cosmic';
977
+ root.dataset.group = preset.group;
978
+ root.dataset.mode = 'nebula';
979
+ root.dataset.theme = preset.theme || 'light';
980
+ root.setAttribute('role', 'img');
981
+ const updateAria = () => {
982
+ root.setAttribute('aria-label', customLabel || COPY.capsuleAria.replace('{name}', preset.name));
983
+ };
984
+ updateAria();
985
+
986
+ // Slot containers are always present but transparent by default: without
987
+ // content they are invisible, so "no slot" behaves like the old
988
+ // showCopy=false. They only provide geometry — no typography, padding or
989
+ // background is imposed on user content (see docs: 插槽 CSS 约定).
990
+ const fillContent = (layer, content) => {
991
+ layer.innerHTML = '';
992
+ if (content == null) return;
993
+ if (typeof content === 'string') {
994
+ layer.innerHTML = content;
995
+ return;
996
+ }
997
+ const nodes = Array.isArray(content) ? content : [content];
998
+ for (const node of nodes) {
999
+ if (node && typeof node.nodeType === 'number') layer.appendChild(node);
1000
+ }
1001
+ };
1002
+
1003
+ const textLayer = document.createElement('div');
1004
+ textLayer.className = 'hj-capsule-text hj-capsule-copy';
1005
+
1006
+ const visualLayer = document.createElement('div');
1007
+ visualLayer.className = 'hj-capsule-visual';
1008
+
1009
+ fillContent(textLayer, options.text);
1010
+ fillContent(visualLayer, options.colorContent);
1011
+
1012
+ const canvas = document.createElement('canvas');
1013
+ canvas.className = 'hj-capsule-canvas';
1014
+ canvas.setAttribute('aria-hidden', 'true');
1015
+
1016
+ root.appendChild(textLayer);
1017
+ root.appendChild(visualLayer);
1018
+ root.appendChild(canvas);
1019
+ container.appendChild(root);
1020
+
1021
+ const emitter = createEmitter();
1022
+ let paused = merged.respectReducedMotion && prefersReducedMotion$2();
1023
+ let disposed = false;
1024
+ const dirty = {
1025
+ preset: false,
1026
+ seed: false,
1027
+ speed: false,
1028
+ colors: false,
1029
+ textRatio: false,
1030
+ cssVars: false
1031
+ };
773
1032
 
774
1033
  let renderer;
775
1034
  const useWebgl = merged.renderer !== 'canvas2d';
@@ -778,19 +1037,25 @@ function createCapsule(container, options = {}) {
778
1037
  renderer = new CosmicRenderer(canvas, merged, { dprCap: dprCapFor(merged.quality) });
779
1038
  } catch (error) {
780
1039
  renderer = new FallbackRenderer(canvas, merged);
781
- emitter.emit('error', { message: String(error && error.message ? error.message : error) });
1040
+ nextTick(() => {
1041
+ if (disposed) return;
1042
+ emitter.emit('error', { message: String(error && error.message ? error.message : error) });
1043
+ });
782
1044
  }
783
1045
  } else {
784
1046
  renderer = new FallbackRenderer(canvas, merged);
785
1047
  }
786
1048
 
787
- const applySize = () => {
788
- root.style.width = parseSize(merged.width);
789
- root.style.height = parseSize(merged.height);
790
- const vars = merged.cssVars || {};
791
- for (const name of Object.keys(vars)) root.style.setProperty(name, vars[name]);
792
- renderer.resize();
793
- };
1049
+ const applySize = () => {
1050
+ root.style.width = parseSize(merged.width);
1051
+ root.style.height = parseSize(merged.height);
1052
+ const vars = merged.cssVars || {};
1053
+ for (const name of Object.keys(vars)) root.style.setProperty(name, vars[name]);
1054
+ if (merged.textRatio !== undefined) {
1055
+ root.style.setProperty('--hj-text-width', `${merged.textRatio}%`);
1056
+ }
1057
+ renderer.resize();
1058
+ };
794
1059
  applySize();
795
1060
 
796
1061
  const resizeObserver = typeof ResizeObserver !== 'undefined'
@@ -819,38 +1084,47 @@ function createCapsule(container, options = {}) {
819
1084
  () => paused
820
1085
  );
821
1086
 
822
- emitter.emit('ready', { preset: { ...preset } });
823
-
824
- const syncCopy = () => {
825
- root.dataset.mode = 'nebula';
826
- root.dataset.theme = preset.theme || 'light';
827
- renderCopy();
828
- };
1087
+ nextTick(() => {
1088
+ if (disposed) return;
1089
+ emitter.emit('ready', { preset: { ...preset } });
1090
+ });
829
1091
 
830
- return {
1092
+ const syncTheme = () => {
1093
+ root.dataset.mode = 'nebula';
1094
+ root.dataset.theme = preset.theme || 'light';
1095
+ updateAria();
1096
+ };
1097
+
1098
+ return {
831
1099
  element: root,
832
1100
  canvas,
833
1101
  preset,
834
1102
  on: emitter.on,
835
1103
  off: emitter.off,
836
- setPreset(ref) {
837
- const next = getPreset('capsule', ref);
838
- Object.assign(preset, next);
839
- renderer.setPreset(next);
840
- syncCopy();
841
- renderer.resize();
842
- emitter.emit('presetchange', { preset: { ...next } });
843
- return this;
1104
+ setPreset(ref) {
1105
+ const next = getPreset('capsule', ref);
1106
+ dirty.preset = true;
1107
+ Object.assign(preset, next);
1108
+ const nextColors = preset.colors.map(normalizeColor);
1109
+ if (nextColors.every(Boolean)) preset.colors = nextColors;
1110
+ renderer.setPreset({ ...preset });
1111
+ syncTheme();
1112
+ renderer.resize();
1113
+ emitter.emit('presetchange', { preset: { ...next } });
1114
+ return this;
844
1115
  },
845
1116
  setColors(colors) {
846
- if (!Array.isArray(colors) || colors.length !== 4) return this;
847
- preset.colors = colors;
848
- renderer.setPreset({ ...preset, colors });
1117
+ const next = Array.isArray(colors) ? colors.map(normalizeColor) : [];
1118
+ if (next.length !== 4 || next.some((color) => !color)) return this;
1119
+ dirty.colors = true;
1120
+ preset.colors = next;
1121
+ renderer.setPreset({ ...preset, colors: next });
849
1122
  return this;
850
1123
  },
851
1124
  setSeed(seed) {
852
1125
  const value = Number(seed);
853
1126
  if (!Number.isFinite(value)) return this;
1127
+ dirty.seed = true;
854
1128
  preset.seed = value;
855
1129
  renderer.setPreset({ ...preset });
856
1130
  return this;
@@ -858,20 +1132,49 @@ function createCapsule(container, options = {}) {
858
1132
  setSpeed(speed) {
859
1133
  const value = Number(speed);
860
1134
  if (!Number.isFinite(value)) return this;
1135
+ dirty.speed = true;
861
1136
  preset.speed = value;
862
1137
  renderer.setPreset({ ...preset });
863
1138
  return this;
864
1139
  },
865
- setSize(width, height) {
1140
+ setText(content) {
1141
+ fillContent(textLayer, content);
1142
+ return this;
1143
+ },
1144
+ setColorContent(content) {
1145
+ fillContent(visualLayer, content);
1146
+ return this;
1147
+ },
1148
+ setTextRatio(ratio) {
1149
+ const value = Number(ratio);
1150
+ if (!Number.isFinite(value) || value < 0 || value > 100) return this;
1151
+ dirty.textRatio = true;
1152
+ merged.textRatio = value;
1153
+ root.style.setProperty('--hj-text-width', `${value}%`);
1154
+ return this;
1155
+ },
1156
+ setCssVars(vars) {
1157
+ if (!vars || typeof vars !== 'object') return this;
1158
+ dirty.cssVars = true;
1159
+ merged.cssVars = { ...(merged.cssVars || {}), ...vars };
1160
+ for (const name of Object.keys(vars)) root.style.setProperty(name, vars[name]);
1161
+ return this;
1162
+ },
1163
+ setLabel(label) {
1164
+ customLabel = typeof label === 'string' && label ? label : null;
1165
+ updateAria();
1166
+ return this;
1167
+ },
1168
+ setSize(width, height) {
866
1169
  if (width !== undefined) merged.width = width;
867
1170
  if (height !== undefined) merged.height = height;
868
1171
  applySize();
869
1172
  return this;
870
1173
  },
871
- randomize() {
872
- renderer.randomize();
873
- return this;
874
- },
1174
+ randomize() {
1175
+ this.setSeed(Math.random() * 100);
1176
+ return this;
1177
+ },
875
1178
  pause() {
876
1179
  paused = true;
877
1180
  return this;
@@ -885,15 +1188,19 @@ function createCapsule(container, options = {}) {
885
1188
  if (typeof renderer.setDprCap === 'function') renderer.setDprCap(dprCapFor(quality));
886
1189
  return this;
887
1190
  },
888
- dispose() {
889
- unsubscribe();
1191
+ dispose() {
1192
+ disposed = true;
1193
+ unsubscribe();
890
1194
  visibility.dispose();
891
1195
  if (resizeObserver) resizeObserver.disconnect();
892
1196
  else window.removeEventListener('resize', renderer.resize);
893
- renderer.dispose();
894
- root.remove();
895
- }
896
- };
1197
+ renderer.dispose();
1198
+ root.remove();
1199
+ },
1200
+ textRatio: merged.textRatio,
1201
+ cssVars: merged.cssVars,
1202
+ dirty
1203
+ };
897
1204
  }
898
1205
 
899
1206
  const PROGRESS_MOTION_WIDTH = 240;
@@ -1581,14 +1888,16 @@ const FLOW_PROFILES = {
1581
1888
  };
1582
1889
 
1583
1890
  class ProgressCapsuleController {
1584
- constructor({ root, canvas, valueElement, preset, emitter, options, copy }) {
1891
+ constructor({ root, canvas, valueElement, preset, emitter, options, copy, dirty }) {
1585
1892
  this.root = root;
1586
1893
  this.canvas = canvas;
1587
1894
  this.valueElement = valueElement;
1588
1895
  this.preset = preset;
1589
1896
  this.emitter = emitter;
1590
- this.options = options;
1591
- this.copy = copy;
1897
+ this.options = options;
1898
+ this.copy = copy;
1899
+ this.dirty = dirty;
1900
+ this.valueSuffix = options.valueSuffix ?? copy.valueSuffix;
1592
1901
  this.profile = preset.edgeStyle === 'tide'
1593
1902
  ? FLOW_PROFILES.tide
1594
1903
  : (FLOW_PROFILES[preset.id] || FLOW_PROFILES['visual-training']);
@@ -1629,11 +1938,22 @@ class ProgressCapsuleController {
1629
1938
  this.value = clamp(nextValue, this.min, this.max);
1630
1939
  const rounded = Math.round(this.value);
1631
1940
  this.root.style.setProperty('--progress', this.value.toFixed(2));
1632
- this.root.setAttribute('aria-valuenow', String(rounded));
1633
- this.root.setAttribute('aria-valuetext', `${rounded}${this.copy.valueSuffix}`);
1634
- this.valueElement.textContent = `${rounded}${this.copy.valueSuffix}`;
1635
- if (!this.suppressEvents) this.emitter.emit('change', { value: this.value, source });
1636
- }
1941
+ this.root.setAttribute('aria-valuenow', String(rounded));
1942
+ this.root.setAttribute('aria-valuetext', `${rounded}${this.valueSuffix}`);
1943
+ if (this.valueElement) this.valueElement.textContent = `${rounded}${this.valueSuffix}`;
1944
+ if (!this.suppressEvents) {
1945
+ if (this.dirty) this.dirty.value = true;
1946
+ this.emitter.emit('change', { value: this.value, source });
1947
+ }
1948
+ }
1949
+
1950
+ setValueSuffix(suffix) {
1951
+ this.valueSuffix = String(suffix == null ? '' : suffix);
1952
+ const rounded = Math.round(this.value);
1953
+ if (this.valueElement) this.valueElement.textContent = `${rounded}${this.valueSuffix}`;
1954
+ this.root.setAttribute('aria-valuetext', `${rounded}${this.valueSuffix}`);
1955
+ return this;
1956
+ }
1637
1957
 
1638
1958
  resizeCanvas() {
1639
1959
  const bounds = this.root.getBoundingClientRect();
@@ -1806,13 +2126,14 @@ class ProgressCapsuleController {
1806
2126
  if (clamped !== this.value) this.setProgress(clamped, 'prop');
1807
2127
  }
1808
2128
 
1809
- drawReferenceFlow() {
2129
+ drawReferenceFlow() {
1810
2130
  const ctx = this.ctx;
1811
2131
  const width = this.width;
1812
2132
  const height = this.height;
1813
2133
  if (!ctx || width <= 0 || height <= 0) return;
1814
2134
 
1815
- const shoreline = width * (this.value / 100);
2135
+ const range = Math.max(this.max - this.min, 1);
2136
+ const shoreline = width * Math.min(Math.max((this.value - this.min) / range, 0), 1);
1816
2137
  const time = this.flowTime;
1817
2138
  const [dark, accentA, accentB, glow] = this.preset.colors;
1818
2139
 
@@ -1930,32 +2251,49 @@ class ProgressCapsuleController {
1930
2251
  }
1931
2252
  }
1932
2253
 
1933
- updateFromPointer(event) {
1934
- const bounds = this.root.getBoundingClientRect();
1935
- const ratio = bounds.width > 0 ? (event.clientX - bounds.left) / bounds.width : 0;
1936
- this.setProgress(ratio * 100, 'drag');
1937
- }
1938
-
1939
- beginDrag(event) {
1940
- if (event.button !== undefined && event.button !== 0) return;
1941
- event.preventDefault();
1942
- this.dragging = true;
1943
- this.root.classList.add('is-dragging');
1944
- try { this.root.setPointerCapture?.(event.pointerId); } catch {}
1945
- this.emitter.emit('dragstart', { value: this.value });
1946
- this.updateFromPointer(event);
1947
- }
1948
-
1949
- moveDrag(event) {
1950
- if (!this.dragging) return;
1951
- event.preventDefault();
1952
- this.updateFromPointer(event);
1953
- }
1954
-
1955
- endDrag(event) {
1956
- if (!this.dragging) return;
1957
- this.dragging = false;
1958
- this.root.classList.remove('is-dragging');
2254
+ updateFromPointer(event) {
2255
+ const bounds = this.root.getBoundingClientRect();
2256
+ const ratio = bounds.width > 0 ? (event.clientX - bounds.left) / bounds.width : 0;
2257
+ this.setProgress(this.min + ratio * (this.max - this.min), 'drag');
2258
+ }
2259
+
2260
+ beginDrag(event) {
2261
+ if (event.button !== undefined && event.button !== 0) return;
2262
+ event.preventDefault();
2263
+ this.dragging = true;
2264
+ this.pendingPointer = null;
2265
+ this._dragRaf = 0;
2266
+ this.root.classList.add('is-dragging');
2267
+ try { this.root.setPointerCapture?.(event.pointerId); } catch {}
2268
+ this.emitter.emit('dragstart', { value: this.value });
2269
+ this.updateFromPointer(event);
2270
+ }
2271
+
2272
+ moveDrag(event) {
2273
+ if (!this.dragging) return;
2274
+ event.preventDefault();
2275
+ // rAF 合并:一帧最多一次 setProgress/change,视觉仍每帧更新。
2276
+ this.pendingPointer = event;
2277
+ if (this._dragRaf) return;
2278
+ this._dragRaf = requestAnimationFrame(() => {
2279
+ this._dragRaf = 0;
2280
+ const pending = this.pendingPointer;
2281
+ this.pendingPointer = null;
2282
+ if (pending) this.updateFromPointer(pending);
2283
+ });
2284
+ }
2285
+
2286
+ endDrag(event) {
2287
+ if (!this.dragging) return;
2288
+ this.dragging = false;
2289
+ if (this._dragRaf) {
2290
+ cancelAnimationFrame(this._dragRaf);
2291
+ this._dragRaf = 0;
2292
+ }
2293
+ const pending = this.pendingPointer;
2294
+ this.pendingPointer = null;
2295
+ if (pending) this.updateFromPointer(pending);
2296
+ this.root.classList.remove('is-dragging');
1959
2297
  try {
1960
2298
  if (event?.pointerId !== undefined && this.root.hasPointerCapture?.(event.pointerId)) {
1961
2299
  this.root.releasePointerCapture(event.pointerId);
@@ -1977,15 +2315,16 @@ class ProgressCapsuleController {
1977
2315
  }
1978
2316
 
1979
2317
  if (this.options.keyboard !== false) {
1980
- this.handlers.keydown = (event) => {
1981
- const actions = {
1982
- ArrowLeft: -2,
1983
- ArrowDown: -2,
1984
- ArrowRight: 2,
1985
- ArrowUp: 2,
1986
- PageDown: -10,
1987
- PageUp: 10
1988
- };
2318
+ this.handlers.keydown = (event) => {
2319
+ const step = (this.max - this.min) * 0.02;
2320
+ const actions = {
2321
+ ArrowLeft: -step,
2322
+ ArrowDown: -step,
2323
+ ArrowRight: step,
2324
+ ArrowUp: step,
2325
+ PageDown: -step * 5,
2326
+ PageUp: step * 5
2327
+ };
1989
2328
  if (event.key === 'Home') {
1990
2329
  event.preventDefault();
1991
2330
  this.setProgress(this.min, 'keyboard');
@@ -2023,8 +2362,9 @@ class ProgressCapsuleController {
2023
2362
  this.preset = { ...this.preset, colors };
2024
2363
  }
2025
2364
 
2026
- dispose() {
2027
- if (this.resizeObserver) this.resizeObserver.disconnect();
2365
+ dispose() {
2366
+ if (this._dragRaf) cancelAnimationFrame(this._dragRaf);
2367
+ if (this.resizeObserver) this.resizeObserver.disconnect();
2028
2368
  else window.removeEventListener('resize', this.resizeCanvas);
2029
2369
  for (const name of Object.keys(this.handlers)) {
2030
2370
  const handler = this.handlers[name];
@@ -2035,103 +2375,127 @@ class ProgressCapsuleController {
2035
2375
  }
2036
2376
 
2037
2377
  /**
2038
- * Mount a fluid progress capsule into `container`.
2039
- *
2040
- * Options: preset (id/code/name or object), width, height, value, min, max,
2041
- * draggable, keyboard, colors, edgeStyle, quality,
2042
- * respectReducedMotion, copy, cssVars.
2043
- */
2044
- function createProgressCapsule(container, options = {}) {
2378
+ * Mount a fluid progress capsule into `container`.
2379
+ *
2380
+ * Options: preset (id/code/name or object), width, height, value, min, max,
2381
+ * draggable, keyboard, colors, edgeStyle, textRatio (0-100, text region
2382
+ * width in percent), text (HTML string or DOM nodes for the text slot),
2383
+ * colorContent (HTML string or DOM nodes for the color slot),
2384
+ * showValue (show/hide the right-side percentage), quality,
2385
+ * respectReducedMotion, cssVars.
2386
+ */
2387
+ function createProgressCapsule(container, options = {}) {
2045
2388
  if (!container || typeof container.appendChild !== 'function') {
2046
2389
  throw new Error('createProgressCapsule: container element is required');
2047
2390
  }
2048
2391
 
2049
2392
  const preset = { ...getPreset('progress', options.preset ?? '星火') };
2050
- const merged = normalizeOptions(DEFAULTS.progress, preset, options);
2051
- const copy = { ...COPY, ...(merged.copy || {}) };
2052
-
2053
- const root = document.createElement('div');
2054
- root.className = 'hj-capsule-root hj-progress-root';
2055
- root.setAttribute('role', 'slider');
2056
- root.setAttribute('tabindex', '0');
2057
- root.setAttribute('data-draggable', String(merged.draggable !== false));
2393
+ const merged = normalizeOptions(DEFAULTS.progress, preset, options);
2394
+ const normalizedColors = merged.colors.map(normalizeColor);
2395
+ if (normalizedColors.every(Boolean)) preset.colors = normalizedColors;
2396
+ preset.edgeStyle = merged.edgeStyle;
2397
+ const copy = COPY;
2398
+ let customLabel = typeof options.label === 'string' && options.label ? options.label : null;
2399
+ // Vue 的裸布尔属性(<ProgressCapsule disabled />)会传成空字符串,
2400
+ // 这里统一按 true 处理。
2401
+ const disabled = merged.disabled === true || merged.disabled === '' || merged.disabled === 'true';
2402
+ const effectiveDraggable = disabled ? false : merged.draggable !== false;
2403
+ const effectiveKeyboard = disabled ? false : merged.keyboard !== false;
2404
+ const dirty = {
2405
+ preset: false,
2406
+ colors: false,
2407
+ textRatio: false,
2408
+ cssVars: false,
2409
+ edgeStyle: false,
2410
+ value: false
2411
+ };
2412
+
2413
+ const root = document.createElement('div');
2414
+ root.className = 'hj-capsule-root hj-progress-root';
2415
+ root.setAttribute('role', 'slider');
2416
+ root.setAttribute('tabindex', '0');
2417
+ root.setAttribute('data-draggable', String(effectiveDraggable));
2418
+ if (disabled) {
2419
+ root.setAttribute('aria-disabled', 'true');
2420
+ root.classList.add('is-disabled');
2421
+ }
2058
2422
  root.setAttribute('aria-valuemin', String(merged.min ?? 0));
2059
2423
  root.setAttribute('aria-valuemax', String(merged.max ?? 100));
2060
2424
  root.setAttribute('aria-valuenow', String(preset.initialProgress));
2061
- root.setAttribute(
2062
- 'aria-label',
2063
- interpolate(copy.progressAria, { brand: copy.brandName, code: preset.code, name: preset.name })
2064
- );
2065
- if (merged.keyboard === false) root.setAttribute('tabindex', '-1');
2425
+ const updateAria = () => {
2426
+ root.setAttribute(
2427
+ 'aria-label',
2428
+ customLabel || interpolate(copy.progressAria, { brand: copy.brandName, code: preset.code, name: preset.name })
2429
+ );
2430
+ };
2431
+ updateAria();
2432
+ if (!effectiveKeyboard) root.setAttribute('tabindex', '-1');
2066
2433
 
2067
2434
  const canvas = document.createElement('canvas');
2068
2435
  canvas.className = 'hj-progress-canvas';
2069
2436
  canvas.setAttribute('aria-hidden', 'true');
2070
2437
 
2071
- const copyEnabled = copy.enabled !== false && merged.showCopy !== false;
2072
-
2073
- const copyText = (key, fallback) => {
2074
- const value = copy[key];
2075
- return value === undefined ? fallback : value;
2076
- };
2077
-
2078
- function buildCopyHtml() {
2079
- let html = '';
2080
- const brandText = copy.brandName || '';
2081
- const subtitleText = copyText('subtitle', preset.subtitle);
2082
- if (brandText) html += `<span class="hj-progress-name">${brandText}</span>`;
2083
- if (subtitleText) html += `<span class="hj-progress-subtitle">${subtitleText}</span>`;
2084
- return html;
2085
- }
2086
-
2087
- const copyLayer = document.createElement('div');
2088
- copyLayer.className = 'hj-progress-copy';
2089
- const renderCopy = () => {
2090
- if (copyEnabled) copyLayer.innerHTML = buildCopyHtml();
2091
- };
2092
- renderCopy();
2093
-
2094
- if (copy.background) {
2095
- root.style.setProperty('--hj-progress-copy-bg', copy.background);
2096
- root.style.setProperty('--hj-progress-copy-pad', '6px 12px');
2097
- }
2098
- if (copy.color) root.style.setProperty('--hj-progress-copy-color', copy.color);
2099
-
2100
- const valueElement = document.createElement('div');
2101
- valueElement.className = 'hj-progress-value';
2102
- valueElement.setAttribute('aria-hidden', 'true');
2103
-
2104
- const dragLabel = document.createElement('span');
2105
- dragLabel.className = 'hj-progress-drag-label';
2106
- dragLabel.setAttribute('aria-hidden', 'true');
2107
- dragLabel.textContent = copy.dragLabel;
2108
-
2109
- root.appendChild(canvas);
2110
- if (copyEnabled) root.appendChild(copyLayer);
2111
- root.appendChild(valueElement);
2112
- root.appendChild(dragLabel);
2113
- container.appendChild(root);
2114
-
2115
- const emitter = createEmitter();
2116
- let paused = merged.respectReducedMotion && prefersReducedMotion$1();
2117
-
2118
- const applySize = () => {
2119
- root.style.width = parseSize(merged.width);
2120
- root.style.height = parseSize(merged.height);
2121
- const vars = merged.cssVars || {};
2122
- for (const name of Object.keys(vars)) root.style.setProperty(name, vars[name]);
2123
- };
2438
+ // Slot containers are always present but transparent by default; they only
2439
+ // provide geometry, never typography/padding/background (docs: 插槽 CSS 约定).
2440
+ const fillContent = (layer, content) => {
2441
+ layer.innerHTML = '';
2442
+ if (content == null) return;
2443
+ if (typeof content === 'string') {
2444
+ layer.innerHTML = content;
2445
+ return;
2446
+ }
2447
+ const nodes = Array.isArray(content) ? content : [content];
2448
+ for (const node of nodes) {
2449
+ if (node && typeof node.nodeType === 'number') layer.appendChild(node);
2450
+ }
2451
+ };
2452
+
2453
+ const textLayer = document.createElement('div');
2454
+ textLayer.className = 'hj-progress-text hj-progress-copy';
2455
+
2456
+ const visualLayer = document.createElement('div');
2457
+ visualLayer.className = 'hj-progress-visual';
2458
+
2459
+ fillContent(textLayer, options.text);
2460
+ fillContent(visualLayer, options.colorContent);
2461
+
2462
+ const valueElement = merged.showValue === false ? null : document.createElement('div');
2463
+ if (valueElement) {
2464
+ valueElement.className = 'hj-progress-value';
2465
+ valueElement.setAttribute('aria-hidden', 'true');
2466
+ }
2467
+
2468
+ root.appendChild(canvas);
2469
+ root.appendChild(textLayer);
2470
+ root.appendChild(visualLayer);
2471
+ if (valueElement) root.appendChild(valueElement);
2472
+ container.appendChild(root);
2473
+
2474
+ const emitter = createEmitter();
2475
+ let paused = merged.respectReducedMotion && prefersReducedMotion$1();
2476
+ let disposed = false;
2477
+
2478
+ const applySize = () => {
2479
+ root.style.width = parseSize(merged.width);
2480
+ root.style.height = parseSize(merged.height);
2481
+ const vars = merged.cssVars || {};
2482
+ for (const name of Object.keys(vars)) root.style.setProperty(name, vars[name]);
2483
+ if (merged.textRatio !== undefined) {
2484
+ root.style.setProperty('--hj-text-width', `${merged.textRatio}%`);
2485
+ }
2486
+ };
2124
2487
  applySize();
2125
2488
 
2126
- const controller = new ProgressCapsuleController({
2127
- root,
2128
- canvas,
2129
- valueElement,
2130
- preset,
2131
- emitter,
2132
- options: merged,
2133
- copy
2134
- });
2489
+ const controller = new ProgressCapsuleController({
2490
+ root,
2491
+ canvas,
2492
+ valueElement,
2493
+ preset,
2494
+ emitter,
2495
+ options: { ...merged, draggable: effectiveDraggable, keyboard: effectiveKeyboard },
2496
+ copy,
2497
+ dirty
2498
+ });
2135
2499
 
2136
2500
  let overlay = null;
2137
2501
  if (merged.renderer !== 'canvas2d') {
@@ -2142,7 +2506,13 @@ function createProgressCapsule(container, options = {}) {
2142
2506
  getProgress: () => controller.value
2143
2507
  });
2144
2508
  }
2145
- if (overlay) controller.webglActive = true;
2509
+ if (overlay) controller.webglActive = true;
2510
+ else if (merged.renderer !== 'canvas2d') {
2511
+ nextTick(() => {
2512
+ if (disposed) return;
2513
+ emitter.emit('error', { message: 'WebGL2 unavailable, using Canvas2D fallback' });
2514
+ });
2515
+ }
2146
2516
 
2147
2517
  const visibility = createVisibilityGuard(root);
2148
2518
  let flowTime = 0;
@@ -2158,15 +2528,14 @@ function createProgressCapsule(container, options = {}) {
2158
2528
  () => paused
2159
2529
  );
2160
2530
 
2161
- emitter.emit('ready', { preset: { ...preset } });
2531
+ nextTick(() => {
2532
+ if (disposed) return;
2533
+ emitter.emit('ready', { preset: { ...preset } });
2534
+ });
2162
2535
 
2163
- const syncDom = () => {
2164
- root.setAttribute(
2165
- 'aria-label',
2166
- interpolate(copy.progressAria, { brand: copy.brandName, code: preset.code, name: preset.name })
2167
- );
2168
- renderCopy();
2169
- };
2536
+ const syncDom = () => {
2537
+ updateAria();
2538
+ };
2170
2539
 
2171
2540
  return {
2172
2541
  element: root,
@@ -2187,26 +2556,30 @@ function createProgressCapsule(container, options = {}) {
2187
2556
  },
2188
2557
  setPreset(ref) {
2189
2558
  const next = getPreset('progress', ref);
2559
+ dirty.preset = true;
2190
2560
  Object.assign(preset, next);
2191
- controller.preset = next;
2192
- controller.profile = next.edgeStyle === 'tide'
2193
- ? FLOW_PROFILES.tide
2194
- : (FLOW_PROFILES[next.id] || FLOW_PROFILES['visual-training']);
2195
- controller.flowTime = stringSeed(next.id) * 31;
2196
- controller.seed = stringSeed(`${next.id}-reference`) * Math.PI * 2;
2197
- syncDom();
2198
- if (overlay) {
2199
- overlay.dispose();
2200
- overlay = attachProgressFlowOverlay({ root, canvas, preset: next, getProgress: () => controller.value });
2201
- }
2202
- controller.webglActive = Boolean(overlay);
2203
- controller.resizeCanvas();
2204
- emitter.emit('presetchange', { preset: { ...next } });
2561
+ const nextColors = preset.colors.map(normalizeColor);
2562
+ if (nextColors.every(Boolean)) preset.colors = nextColors;
2563
+ controller.preset = preset;
2564
+ controller.profile = next.edgeStyle === 'tide'
2565
+ ? FLOW_PROFILES.tide
2566
+ : (FLOW_PROFILES[next.id] || FLOW_PROFILES['visual-training']);
2567
+ controller.flowTime = stringSeed(next.id) * 31;
2568
+ controller.seed = stringSeed(`${next.id}-reference`) * Math.PI * 2;
2569
+ syncDom();
2570
+ if (overlay) {
2571
+ overlay.dispose();
2572
+ overlay = attachProgressFlowOverlay({ root, canvas, preset, getProgress: () => controller.value });
2573
+ }
2574
+ controller.webglActive = Boolean(overlay);
2575
+ controller.resizeCanvas();
2576
+ emitter.emit('presetchange', { preset: { ...preset } });
2205
2577
  return this;
2206
2578
  },
2207
2579
  setEdgeStyle(edgeStyle) {
2208
2580
  const value = String(edgeStyle || '').toLowerCase();
2209
2581
  if (value !== 'flow' && value !== 'tide') return this;
2582
+ dirty.edgeStyle = true;
2210
2583
  preset.edgeStyle = value;
2211
2584
  controller.profile = value === 'tide'
2212
2585
  ? FLOW_PROFILES.tide
@@ -2219,12 +2592,46 @@ function createProgressCapsule(container, options = {}) {
2219
2592
  controller.resizeCanvas();
2220
2593
  return this;
2221
2594
  },
2595
+ setText(content) {
2596
+ fillContent(textLayer, content);
2597
+ return this;
2598
+ },
2599
+ setColorContent(content) {
2600
+ fillContent(visualLayer, content);
2601
+ return this;
2602
+ },
2603
+ setTextRatio(ratio) {
2604
+ const value = Number(ratio);
2605
+ if (!Number.isFinite(value) || value < 0 || value > 100) return this;
2606
+ dirty.textRatio = true;
2607
+ merged.textRatio = value;
2608
+ root.style.setProperty('--hj-text-width', `${value}%`);
2609
+ return this;
2610
+ },
2611
+ setCssVars(vars) {
2612
+ if (!vars || typeof vars !== 'object') return this;
2613
+ dirty.cssVars = true;
2614
+ merged.cssVars = { ...(merged.cssVars || {}), ...vars };
2615
+ for (const name of Object.keys(vars)) root.style.setProperty(name, vars[name]);
2616
+ return this;
2617
+ },
2618
+ setLabel(label) {
2619
+ customLabel = typeof label === 'string' && label ? label : null;
2620
+ updateAria();
2621
+ return this;
2622
+ },
2623
+ setValueSuffix(suffix) {
2624
+ controller.setValueSuffix(suffix);
2625
+ return this;
2626
+ },
2222
2627
  setColors(colors) {
2223
- if (!Array.isArray(colors) || colors.length !== 4) return this;
2224
- controller.setColors(colors);
2225
- if (overlay) overlay.setColors(colors);
2226
- controller.resizeCanvas();
2227
- return this;
2628
+ const next = Array.isArray(colors) ? colors.map(normalizeColor) : [];
2629
+ if (next.length !== 4 || next.some((color) => !color)) return this;
2630
+ dirty.colors = true;
2631
+ controller.setColors(next);
2632
+ if (overlay) overlay.setColors(next);
2633
+ controller.resizeCanvas();
2634
+ return this;
2228
2635
  },
2229
2636
  setSize(width, height) {
2230
2637
  if (width !== undefined) merged.width = width;
@@ -2251,14 +2658,18 @@ function createProgressCapsule(container, options = {}) {
2251
2658
  controller.resizeCanvas();
2252
2659
  return this;
2253
2660
  },
2254
- dispose() {
2255
- unsubscribe();
2661
+ dispose() {
2662
+ disposed = true;
2663
+ unsubscribe();
2256
2664
  visibility.dispose();
2257
2665
  if (overlay) overlay.dispose();
2258
- controller.dispose();
2259
- root.remove();
2260
- }
2261
- };
2666
+ controller.dispose();
2667
+ root.remove();
2668
+ },
2669
+ textRatio: merged.textRatio,
2670
+ cssVars: merged.cssVars,
2671
+ dirty
2672
+ };
2262
2673
  }
2263
2674
 
2264
2675
  function prefersReducedMotion() {
@@ -2284,7 +2695,7 @@ function createColorBackground(host, options = {}) {
2284
2695
  }
2285
2696
 
2286
2697
  const preset = { ...getPreset('capsule', options.preset ?? '初光') };
2287
- const merged = normalizeOptions(
2698
+ const merged = normalizeOptions(
2288
2699
  {
2289
2700
  quality: DEFAULTS.capsule.quality,
2290
2701
  renderer: DEFAULTS.capsule.renderer,
@@ -2294,8 +2705,12 @@ function createColorBackground(host, options = {}) {
2294
2705
  fallbackColor: true
2295
2706
  },
2296
2707
  preset,
2297
- options
2298
- );
2708
+ options
2709
+ );
2710
+ const normalizedColors = merged.colors.map(normalizeColor);
2711
+ if (normalizedColors.every(Boolean)) preset.colors = normalizedColors;
2712
+ preset.seed = merged.seed;
2713
+ preset.speed = merged.speed;
2299
2714
 
2300
2715
  const computed = getComputedStyle(host);
2301
2716
  if (computed.position === 'static' || computed.position === '') {
@@ -2324,8 +2739,18 @@ function createColorBackground(host, options = {}) {
2324
2739
  layer.appendChild(canvas);
2325
2740
  host.appendChild(layer);
2326
2741
 
2327
- const emitter = createEmitter();
2328
- let paused = merged.respectReducedMotion && prefersReducedMotion();
2742
+ const emitter = createEmitter();
2743
+ let paused = merged.respectReducedMotion && prefersReducedMotion();
2744
+ let disposed = false;
2745
+ const dirty = {
2746
+ preset: false,
2747
+ seed: false,
2748
+ speed: false,
2749
+ colors: false,
2750
+ opacity: false,
2751
+ fallbackColor: false,
2752
+ mouseColor: false
2753
+ };
2329
2754
 
2330
2755
  let renderer;
2331
2756
  const useWebgl = merged.renderer !== 'canvas2d';
@@ -2338,9 +2763,12 @@ function createColorBackground(host, options = {}) {
2338
2763
  });
2339
2764
  } catch (error) {
2340
2765
  renderer = new FallbackRenderer(canvas, merged);
2341
- emitter.emit('error', {
2342
- message: String(error && error.message ? error.message : error)
2343
- });
2766
+ nextTick(() => {
2767
+ if (disposed) return;
2768
+ emitter.emit('error', {
2769
+ message: String(error && error.message ? error.message : error)
2770
+ });
2771
+ });
2344
2772
  }
2345
2773
  } else {
2346
2774
  renderer = new FallbackRenderer(canvas, merged);
@@ -2364,7 +2792,10 @@ function createColorBackground(host, options = {}) {
2364
2792
  () => paused
2365
2793
  );
2366
2794
 
2367
- emitter.emit('ready', { preset: { ...preset } });
2795
+ nextTick(() => {
2796
+ if (disposed) return;
2797
+ emitter.emit('ready', { preset: { ...preset } });
2798
+ });
2368
2799
 
2369
2800
  return {
2370
2801
  element: host,
@@ -2373,38 +2804,72 @@ function createColorBackground(host, options = {}) {
2373
2804
  preset,
2374
2805
  on: emitter.on,
2375
2806
  off: emitter.off,
2376
- setPreset(ref) {
2377
- const next = getPreset('capsule', ref);
2378
- Object.assign(preset, next);
2379
- renderer.setPreset(next);
2380
- renderer.resize();
2381
- syncLayerVars();
2382
- emitter.emit('presetchange', { preset: { ...next } });
2383
- return this;
2384
- },
2385
- setColors(colors) {
2386
- if (!Array.isArray(colors) || colors.length !== 4) return this;
2387
- preset.colors = colors;
2388
- renderer.setPreset({ ...preset, colors });
2389
- syncLayerVars();
2390
- return this;
2391
- },
2807
+ setPreset(ref) {
2808
+ const next = getPreset('capsule', ref);
2809
+ dirty.preset = true;
2810
+ Object.assign(preset, next);
2811
+ const nextColors = preset.colors.map(normalizeColor);
2812
+ if (nextColors.every(Boolean)) preset.colors = nextColors;
2813
+ renderer.setPreset({ ...preset });
2814
+ renderer.resize();
2815
+ syncLayerVars();
2816
+ emitter.emit('presetchange', { preset: { ...preset } });
2817
+ return this;
2818
+ },
2819
+ setColors(colors) {
2820
+ const next = Array.isArray(colors) ? colors.map(normalizeColor) : [];
2821
+ if (next.length !== 4 || next.some((color) => !color)) return this;
2822
+ dirty.colors = true;
2823
+ preset.colors = next;
2824
+ renderer.setPreset({ ...preset, colors: next });
2825
+ syncLayerVars();
2826
+ return this;
2827
+ },
2828
+ setSeed(seed) {
2829
+ const value = Number(seed);
2830
+ if (!Number.isFinite(value)) return this;
2831
+ dirty.seed = true;
2832
+ preset.seed = value;
2833
+ renderer.setPreset({ ...preset });
2834
+ return this;
2835
+ },
2836
+ setSpeed(speed) {
2837
+ const value = Number(speed);
2838
+ if (!Number.isFinite(value)) return this;
2839
+ dirty.speed = true;
2840
+ preset.speed = value;
2841
+ renderer.setPreset({ ...preset });
2842
+ return this;
2843
+ },
2844
+ setFallbackColor(value) {
2845
+ dirty.fallbackColor = true;
2846
+ merged.fallbackColor = value;
2847
+ syncLayerVars();
2848
+ return this;
2849
+ },
2850
+ setMouseColor(value) {
2851
+ dirty.mouseColor = true;
2852
+ merged.mouseColor = value !== false;
2853
+ if (typeof renderer.setMouseColor === 'function') renderer.setMouseColor(merged.mouseColor);
2854
+ return this;
2855
+ },
2392
2856
  setQuality(quality) {
2393
2857
  merged.quality = quality;
2394
2858
  if (typeof renderer.setDprCap === 'function') renderer.setDprCap(dprCapFor(quality));
2395
2859
  return this;
2396
2860
  },
2397
- setOpacity(value) {
2398
- const opacity = Number(value);
2399
- if (!Number.isFinite(opacity)) return this;
2400
- merged.opacity = Math.min(1, Math.max(0, opacity));
2861
+ setOpacity(value) {
2862
+ const opacity = Number(value);
2863
+ if (!Number.isFinite(opacity)) return this;
2864
+ dirty.opacity = true;
2865
+ merged.opacity = Math.min(1, Math.max(0, opacity));
2401
2866
  layer.style.setProperty('--dlc-color-opacity', String(merged.opacity));
2402
2867
  return this;
2403
2868
  },
2404
- randomize() {
2405
- renderer.randomize();
2406
- return this;
2407
- },
2869
+ randomize() {
2870
+ this.setSeed(Math.random() * 100);
2871
+ return this;
2872
+ },
2408
2873
  pause() {
2409
2874
  paused = true;
2410
2875
  return this;
@@ -2413,15 +2878,20 @@ function createColorBackground(host, options = {}) {
2413
2878
  paused = false;
2414
2879
  return this;
2415
2880
  },
2416
- dispose() {
2417
- unsubscribe();
2881
+ dispose() {
2882
+ disposed = true;
2883
+ unsubscribe();
2418
2884
  visibility.dispose();
2419
2885
  if (resizeObserver) resizeObserver.disconnect();
2420
2886
  else window.removeEventListener('resize', renderer.resize);
2421
- renderer.dispose();
2422
- layer.remove();
2423
- }
2424
- };
2887
+ renderer.dispose();
2888
+ layer.remove();
2889
+ },
2890
+ opacity: merged.opacity,
2891
+ fallbackColor: merged.fallbackColor,
2892
+ mouseColor: merged.mouseColor,
2893
+ dirty
2894
+ };
2425
2895
  }
2426
2896
 
2427
- export { ALL_PRESETS, COPY, CosmicRenderer, DEFAULTS, FallbackRenderer, PRESETS, PROGRESS_PRESETS, ProgressCapsuleController, ProgressFlowRenderer, QUALITY_TIERS, createCapsule, createColorBackground, createProgressCapsule, dprCapFor, getPreset, hexToRgb01$1 as hexToRgb01, hexToRgba, parseSize, pauseAll, resumeAll, validateColors };
2897
+ export { ALL_PRESETS, COPY, CosmicRenderer, DEFAULTS, FallbackRenderer, PRESETS, PROGRESS_PRESETS, ProgressCapsuleController, ProgressFlowRenderer, QUALITY_TIERS, createCapsule, createColorBackground, createProgressCapsule, dprCapFor, getPreset, hexToRgb01$1 as hexToRgb01, hexToRgba, normalizeColor, parseSize, pauseAll, resumeAll, validateColors };