shaderfont 0.5.3 → 0.5.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.
package/README.ko.md CHANGED
@@ -33,6 +33,19 @@ if (!result.ok) throw new Error(result.error);
33
33
 
34
34
  루트 import는 SSR에서도 가능합니다. `registerShaderFont()`는 브라우저에서만 호출하고, 즉시 등록하는 `shaderfont/browser`도 브라우저에서만 사용합니다.
35
35
 
36
+ ### CSP와 색상
37
+
38
+ 인라인 스타일을 제한하는 사이트에서는 `<shader-font>`를 등록할 때 서버가 발급한 nonce를 전달합니다.
39
+
40
+ ```js
41
+ const result = await registerShaderFont({ styleNonce: trustedNonce });
42
+ if (!result.ok) throw new Error(result.error);
43
+ ```
44
+
45
+ 패키지는 스타일시트가 실제로 적용됐는지 확인합니다. 차단되면 `STYLES_BLOCKED`를 반환하며, 올바른 nonce로 다시 호출할 수 있습니다. DOM에서 임의의 nonce를 읽거나 CSP를 `unsafe-inline`으로 완화할 필요는 없습니다. 즉시 등록하는 `shaderfont/browser`에는 nonce 인자가 없으므로 CSP가 엄격한 페이지에서는 루트 등록 API를 사용합니다. 이미지도 제한한다면 절차적 재질과 디졸브·등장 마스크를 위해 `img-src`에 `data:`를 허용해야 합니다.
46
+
47
+ 색상 우선순위는 `primary`·`secondary`·`accent` 속성, 요소나 상위에서 지정한 `--sf-primary`·`--sf-secondary`·`--sf-accent` CSS 변수, 기본 밝은 팔레트 순서입니다. 페이지 테마가 바뀌어도 저장된 색상은 자동으로 덮지 않습니다. 어두운 배경에서는 `SHADER_FONT_PALETTES.find(p => p.name === "dark")`의 세 색을 요소 속성에 지정하고, 밝은 배경으로 돌아올 때 다시 밝은 팔레트를 지정하면 됩니다. 전역 흰색 변수가 글자에 직접 지정한 색을 덮지도 않습니다.
48
+
36
49
  ## 데모
37
50
 
38
51
  저장소를 복제한 뒤 실행합니다.
@@ -40,8 +53,11 @@ if (!result.ok) throw new Error(result.error);
40
53
  ```sh
41
54
  npm run demo
42
55
  npm run check
56
+ npm run check:browser
43
57
  ```
44
58
 
59
+ 브라우저 검사는 로컬 Chrome에 엄격한 CSP를 적용해 nonce 실패·재시도, 밝고 어두운 색상, 홀로그램 애니메이션, 절차적 재질, 선택 디졸브, 타오르는 등장 효과와 움직임 줄이기를 확인합니다.
60
+
45
61
  [영어 데모](http://127.0.0.1:8766/demo/index.html)와 [한글 데모](http://127.0.0.1:8766/demo/index.ko.html)는 같은 설정과 편집 가능한 텍스트를 제공합니다.
46
62
 
47
63
  [MIT 라이선스](./LICENSE).
package/README.md CHANGED
@@ -33,6 +33,19 @@ The browser lays out the text. CSS `background-clip: text` clips animated patter
33
33
 
34
34
  The root import is safe for SSR. Call `registerShaderFont()` only in the browser; `shaderfont/browser` registers immediately and is browser-only.
35
35
 
36
+ ### CSP and colors
37
+
38
+ On a site that disallows inline styles, pass the nonce supplied by your server **when registering** `<shader-font>`:
39
+
40
+ ```js
41
+ const result = await registerShaderFont({ styleNonce: trustedNonce });
42
+ if (!result.ok) throw new Error(result.error);
43
+ ```
44
+
45
+ The package checks that its stylesheet actually loaded. A blocked style returns `STYLES_BLOCKED`; a later call with the right nonce can retry. Do not read an arbitrary nonce from the DOM or relax CSP with `unsafe-inline`. The immediate `shaderfont/browser` entry has no nonce parameter, so use the root registration API on strict-CSP pages. If your policy also restricts images, allow `data:` in `img-src` for procedural materials and dissolve/reveal masks.
46
+
47
+ Colors follow this order: `primary`/`secondary`/`accent` attributes, then element or inherited `--sf-primary`/`--sf-secondary`/`--sf-accent` CSS variables, then the light palette. The package does not silently replace stored colors when the page changes theme. For a dark background, opt into `SHADER_FONT_PALETTES.find(p => p.name === "dark")` and set its three colors on the element; switch back to the light palette when needed. This also keeps an unrelated global white CSS variable from covering authored colors.
48
+
36
49
  ## Try the demo
37
50
 
38
51
  Clone this repository and run:
@@ -40,8 +53,11 @@ Clone this repository and run:
40
53
  ```sh
41
54
  npm run demo
42
55
  npm run check
56
+ npm run check:browser
43
57
  ```
44
58
 
59
+ The browser check uses local Chrome and sends a strict CSP header. It covers nonce rejection/retry, light and dark colors, hologram animation, procedural material, selection dissolve, burn reveal and reduced motion.
60
+
45
61
  Open the [English demo](http://127.0.0.1:8766/demo/index.html) or [Korean demo](http://127.0.0.1:8766/demo/index.ko.html). Both have the same controls and editable text.
46
62
 
47
63
  [MIT license](./LICENSE).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shaderfont",
3
- "version": "0.5.3",
3
+ "version": "0.5.4",
4
4
  "type": "module",
5
5
  "description": "Animated materials and selection effects on DOM text, with Canvas 2D and WebGL renderers.",
6
6
  "license": "MIT",
@@ -17,7 +17,9 @@
17
17
  "sideEffects": ["./src/shader-font.js"],
18
18
  "scripts": {
19
19
  "check": "node --check src/core.js && node --check src/index.js && node --check src/options.js && node --check src/presets.js && node --check src/paint-layout.js && node --check src/selection-range.js && node --check src/selection-gesture.js && node --check src/selection-indicator.js && node --check src/webgl-surface.js && node --check src/material-core.js && node --check src/material-renderer.js && node --check src/mask-renderer.js && node --check src/reveal-core.js && node --check src/reveal-renderer.js && node --check src/shader-font-element.js && node --check demo/demo.js && node --check demo/copy.js && node --test",
20
+ "check:browser": "node tools/csp-browser-check.mjs",
20
21
  "demo": "node tools/serve.mjs",
21
22
  "prepublishOnly": "npm run check"
22
- }
23
+ },
24
+ "devDependencies": { "playwright": "1.62.1" }
23
25
  }
package/src/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  export { SHADER_FONT_DEFAULTS, SHADER_FONT_PALETTES, SHADER_PRESETS, parseShaderFontOptions, shaderFontAttributes } from './options.js';
2
2
  export type { ShaderFontOptions } from './options.js';
3
- export function registerShaderFont(): Promise<
3
+ export function registerShaderFont(options?: { styleNonce?: string }): Promise<
4
4
  | { ok: true }
5
- | { ok: false; error: 'BROWSER_REQUIRED' | 'BROWSER_FEATURE_UNAVAILABLE' | 'REGISTRATION_FAILED'; cause?: string }
5
+ | { ok: false; error: 'BROWSER_REQUIRED' | 'BROWSER_FEATURE_UNAVAILABLE' | 'INVALID_STYLE_NONCE' | 'STYLES_CONFLICT' | 'STYLES_BLOCKED' | 'REGISTRATION_FAILED'; cause?: string }
6
6
  >;
package/src/index.js CHANGED
@@ -1,9 +1,9 @@
1
1
  export { SHADER_FONT_DEFAULTS, SHADER_FONT_PALETTES, SHADER_PRESETS, parseShaderFontOptions, shaderFontAttributes } from "./options.js";
2
2
 
3
- let registration;
3
+ const STYLE_ERRORS = new Set(["INVALID_STYLE_NONCE", "STYLES_CONFLICT", "STYLES_BLOCKED"]);
4
4
 
5
5
  /** No DOM globals are evaluated until a browser explicitly requests effects. */
6
- export async function registerShaderFont() {
6
+ export async function registerShaderFont({ styleNonce } = {}) {
7
7
  if (typeof window === "undefined" || typeof document === "undefined") {
8
8
  return { ok: false, error: "BROWSER_REQUIRED" };
9
9
  }
@@ -12,15 +12,14 @@ export async function registerShaderFont() {
12
12
  || typeof IntersectionObserver === "undefined" || typeof matchMedia !== "function") {
13
13
  return { ok: false, error: "BROWSER_FEATURE_UNAVAILABLE" };
14
14
  }
15
- registration ??= import("./shader-font-element.js")
15
+ return import("./shader-font-element.js")
16
16
  .then(module => {
17
- module.registerShaderFontElement();
17
+ module.registerShaderFontElement({ styleNonce });
18
18
  return { ok: true };
19
19
  })
20
20
  .catch(cause => ({
21
21
  ok: false,
22
- error: "REGISTRATION_FAILED",
22
+ error: STYLE_ERRORS.has(cause?.code) ? cause.code : "REGISTRATION_FAILED",
23
23
  cause: cause instanceof Error ? cause.name : typeof cause
24
24
  }));
25
- return registration;
26
25
  }
@@ -45,11 +45,7 @@ const DEFAULT_COLORS = Object.freeze({
45
45
  accent: SHADER_FONT_DEFAULTS.accent
46
46
  });
47
47
 
48
- function ensureStyles() {
49
- if (document.getElementById(STYLE_ID)) return;
50
- const style = document.createElement("style");
51
- style.id = STYLE_ID;
52
- style.textContent = `
48
+ const COMPONENT_STYLES = `
53
49
  shader-font,
54
50
  shaderfont {
55
51
  --sf-x: 0px;
@@ -69,6 +65,9 @@ function ensureStyles() {
69
65
  --_sf-primary: ${DEFAULT_COLORS.primary};
70
66
  --_sf-secondary: ${DEFAULT_COLORS.secondary};
71
67
  --_sf-accent: ${DEFAULT_COLORS.accent};
68
+ --_sf-author-primary: initial;
69
+ --_sf-author-secondary: initial;
70
+ --_sf-author-accent: initial;
72
71
  display: inline;
73
72
  box-decoration-break: slice;
74
73
  -webkit-box-decoration-break: slice;
@@ -80,25 +79,25 @@ function ensureStyles() {
80
79
  var(--sf-burn-edge-image, none),
81
80
  repeating-linear-gradient(
82
81
  180deg,
83
- var(--sf-accent, var(--_sf-accent)) 0 2px,
84
- var(--sf-secondary, var(--_sf-secondary)) 2px 4px,
85
- var(--sf-primary, var(--_sf-primary)) 4px 5px,
86
- var(--sf-accent, var(--_sf-accent)) 5px 7px
82
+ var(--_sf-author-accent, var(--sf-accent, var(--_sf-accent))) 0 2px,
83
+ var(--_sf-author-secondary, var(--sf-secondary, var(--_sf-secondary))) 2px 4px,
84
+ var(--_sf-author-primary, var(--sf-primary, var(--_sf-primary))) 4px 5px,
85
+ var(--_sf-author-accent, var(--sf-accent, var(--_sf-accent))) 5px 7px
87
86
  ),
88
87
  linear-gradient(
89
88
  135deg,
90
- var(--sf-primary, var(--_sf-primary)) 0%,
91
- var(--sf-accent, var(--_sf-accent)) 38%,
92
- var(--sf-secondary, var(--_sf-secondary)) 72%,
93
- var(--sf-primary, var(--_sf-primary)) 100%
89
+ var(--_sf-author-primary, var(--sf-primary, var(--_sf-primary))) 0%,
90
+ var(--_sf-author-accent, var(--sf-accent, var(--_sf-accent))) 38%,
91
+ var(--_sf-author-secondary, var(--sf-secondary, var(--_sf-secondary))) 72%,
92
+ var(--_sf-author-primary, var(--sf-primary, var(--_sf-primary))) 100%
94
93
  );
95
94
  background-size: 100% 100%, 100% 7px, 100% 100%;
96
95
  background-clip: text;
97
96
  -webkit-background-clip: text;
98
97
  background-blend-mode: normal;
99
98
  text-shadow:
100
- -.018em 0 color-mix(in srgb, var(--sf-primary, var(--_sf-primary)) 70%, transparent),
101
- .018em 0 color-mix(in srgb, var(--sf-secondary, var(--_sf-secondary)) 62%, transparent);
99
+ -.018em 0 color-mix(in srgb, var(--_sf-author-primary, var(--sf-primary, var(--_sf-primary))) 70%, transparent),
100
+ .018em 0 color-mix(in srgb, var(--_sf-author-secondary, var(--sf-secondary, var(--_sf-secondary))) 62%, transparent);
102
101
  filter: none;
103
102
  transform:
104
103
  translate3d(var(--sf-x), var(--sf-y), 0)
@@ -107,7 +106,7 @@ function ensureStyles() {
107
106
  scale(var(--sf-effect-scale-x), var(--sf-effect-scale-y));
108
107
  transform-origin: center;
109
108
  opacity: var(--sf-fade);
110
- caret-color: var(--sf-primary, var(--_sf-primary));
109
+ caret-color: var(--_sf-author-primary, var(--sf-primary, var(--_sf-primary)));
111
110
  -webkit-mask-repeat: no-repeat;
112
111
  mask-repeat: no-repeat;
113
112
  -webkit-mask-position: 0 0;
@@ -133,7 +132,7 @@ function ensureStyles() {
133
132
  }
134
133
  shader-font[interaction="none"] { pointer-events: none; }
135
134
  shader-font[contenteditable]:focus {
136
- outline: 1px solid color-mix(in srgb, var(--sf-primary, var(--_sf-primary)) 38%, transparent);
135
+ outline: 1px solid color-mix(in srgb, var(--_sf-author-primary, var(--sf-primary, var(--_sf-primary))) 38%, transparent);
137
136
  outline-offset: .16em;
138
137
  }
139
138
  shader-font::selection, shaderfont::selection {
@@ -223,12 +222,12 @@ function ensureStyles() {
223
222
 
224
223
  @keyframes shader-font-scan { to { background-position: 0 0, 0 7px, 0 0; } }
225
224
  @keyframes shader-font-outline-jitter {
226
- 0%, 100% { text-shadow: -.018em 0 var(--sf-primary, var(--_sf-primary)), .018em 0 var(--sf-secondary, var(--_sf-secondary)); }
227
- 14% { text-shadow: -.052em -.004em var(--sf-primary, var(--_sf-primary)), .026em .003em var(--sf-secondary, var(--_sf-secondary)); }
228
- 31% { text-shadow: .024em 0 var(--sf-primary, var(--_sf-primary)), -.036em 0 var(--sf-secondary, var(--_sf-secondary)); }
229
- 48% { text-shadow: -.028em .005em var(--sf-primary, var(--_sf-primary)), .058em -.003em var(--sf-secondary, var(--_sf-secondary)); }
230
- 67% { text-shadow: -.062em 0 var(--sf-primary, var(--_sf-primary)), .032em .004em var(--sf-secondary, var(--_sf-secondary)); }
231
- 83% { text-shadow: .036em -.003em var(--sf-primary, var(--_sf-primary)), -.022em .003em var(--sf-secondary, var(--_sf-secondary)); }
225
+ 0%, 100% { text-shadow: -.018em 0 var(--_sf-author-primary, var(--sf-primary, var(--_sf-primary))), .018em 0 var(--_sf-author-secondary, var(--sf-secondary, var(--_sf-secondary))); }
226
+ 14% { text-shadow: -.052em -.004em var(--_sf-author-primary, var(--sf-primary, var(--_sf-primary))), .026em .003em var(--_sf-author-secondary, var(--sf-secondary, var(--_sf-secondary))); }
227
+ 31% { text-shadow: .024em 0 var(--_sf-author-primary, var(--sf-primary, var(--_sf-primary))), -.036em 0 var(--_sf-author-secondary, var(--sf-secondary, var(--_sf-secondary))); }
228
+ 48% { text-shadow: -.028em .005em var(--_sf-author-primary, var(--sf-primary, var(--_sf-primary))), .058em -.003em var(--_sf-author-secondary, var(--sf-secondary, var(--_sf-secondary))); }
229
+ 67% { text-shadow: -.062em 0 var(--_sf-author-primary, var(--sf-primary, var(--_sf-primary))), .032em .004em var(--_sf-author-secondary, var(--sf-secondary, var(--_sf-secondary))); }
230
+ 83% { text-shadow: .036em -.003em var(--_sf-author-primary, var(--sf-primary, var(--_sf-primary))), -.022em .003em var(--_sf-author-secondary, var(--sf-secondary, var(--_sf-secondary))); }
232
231
  }
233
232
  @keyframes shader-font-drag-shake {
234
233
  0%, 100% { translate: var(--sf-shake-neg-small) var(--sf-shake-small); rotate: var(--sf-shake-neg-angle); }
@@ -241,7 +240,39 @@ function ensureStyles() {
241
240
  shader-font, shaderfont { animation: none !important; translate: none; rotate: none; }
242
241
  }
243
242
  `;
243
+
244
+ let installedStyleNonce;
245
+
246
+ function styleError(code) {
247
+ return Object.assign(new Error(code), { code });
248
+ }
249
+
250
+ function ensureStyles(styleNonce = installedStyleNonce) {
251
+ if (styleNonce !== undefined && (typeof styleNonce !== "string" || !styleNonce || /\s/.test(styleNonce))) {
252
+ throw styleError("INVALID_STYLE_NONCE");
253
+ }
254
+ const existing = document.getElementById(STYLE_ID);
255
+ if (existing) {
256
+ if (existing.tagName !== "STYLE" || existing.textContent !== COMPONENT_STYLES) {
257
+ throw styleError("STYLES_CONFLICT");
258
+ }
259
+ if (existing.sheet && !existing.sheet.disabled) {
260
+ if (styleNonce && existing.nonce === styleNonce) installedStyleNonce = styleNonce;
261
+ return;
262
+ }
263
+ if (!styleNonce) throw styleError("STYLES_BLOCKED");
264
+ existing.remove();
265
+ }
266
+ const style = document.createElement("style");
267
+ style.id = STYLE_ID;
268
+ if (styleNonce) style.nonce = styleNonce;
269
+ style.textContent = COMPONENT_STYLES;
244
270
  document.head.append(style);
271
+ if (!style.sheet || style.sheet.disabled) {
272
+ style.remove();
273
+ throw styleError("STYLES_BLOCKED");
274
+ }
275
+ installedStyleNonce = styleNonce;
245
276
  }
246
277
 
247
278
  function colorOrDefault(element, attribute) {
@@ -367,7 +398,15 @@ export class ShaderFont extends HTMLElement {
367
398
  }
368
399
 
369
400
  connectedCallback() {
370
- ensureStyles();
401
+ try {
402
+ ensureStyles();
403
+ this.dataset.styleStatus = "ready";
404
+ delete this.dataset.styleError;
405
+ } catch (error) {
406
+ this.dataset.styleStatus = "error";
407
+ this.dataset.styleError = error.code ?? "STYLE_INSTALL_FAILED";
408
+ return;
409
+ }
371
410
  this.applyAttributes();
372
411
  if (this.dataset.shaderFontBound === "true") return;
373
412
  this.dataset.shaderFontBound = "true";
@@ -456,7 +495,9 @@ export class ShaderFont extends HTMLElement {
456
495
 
457
496
  applyAttributes() {
458
497
  for (const name of Object.keys(DEFAULT_COLORS)) {
459
- this.style.setProperty(`--_sf-${name}`, colorOrDefault(this, name));
498
+ const value = this.getAttribute(name)?.trim();
499
+ if (value && CSS.supports("color", value)) this.style.setProperty(`--_sf-author-${name}`, value);
500
+ else this.style.removeProperty(`--_sf-author-${name}`);
460
501
  }
461
502
  this.dataset.shader = this.shaderPreset();
462
503
  this.dataset.dragEffect = this.dragEffect();
@@ -571,6 +612,10 @@ export class ShaderFont extends HTMLElement {
571
612
  const tick = now => {
572
613
  this.materialFrame = 0;
573
614
  if (!this.isConnected || !this.inViewport || document.hidden || this.rendererError) return;
615
+ if (this.motionQuery.matches) {
616
+ this.syncMaterialPlayback();
617
+ return;
618
+ }
574
619
  const speed = numberAttribute(this, "speed", SHADER_FONT_DEFAULTS.speed, 0, 4);
575
620
  if (this.materialLastTime !== null) this.materialElapsed += Math.max(0, now - this.materialLastTime) / 1000 * speed;
576
621
  this.materialLastTime = now;
@@ -598,7 +643,8 @@ export class ShaderFont extends HTMLElement {
598
643
  if (!this.materialInput) {
599
644
  const computed = getComputedStyle(this);
600
645
  const colors = ["primary", "secondary", "accent"].map(name => {
601
- const color = computed.getPropertyValue(`--sf-${name}`).trim() || colorOrDefault(this, name);
646
+ const color = computed.getPropertyValue(`--_sf-author-${name}`).trim()
647
+ || computed.getPropertyValue(`--sf-${name}`).trim() || colorOrDefault(this, name);
602
648
  if (!/^#[0-9a-f]{6}$/i.test(color)) throw rendererError("INVALID_MATERIAL_COLOR", "Procedural materials require six-digit hex colors");
603
649
  return [1, 3, 5].map(index => Number.parseInt(color.slice(index, index + 2), 16) / 255);
604
650
  });
@@ -690,7 +736,11 @@ export class ShaderFont extends HTMLElement {
690
736
  if (this.revealFrame || this.revealTimer) return;
691
737
  const tick = now => {
692
738
  this.revealFrame = 0;
693
- if (!this.isConnected || !this.inViewport || document.hidden || this.rendererError || this.motionQuery.matches) return;
739
+ if (this.motionQuery.matches) {
740
+ this.syncRevealPlayback();
741
+ return;
742
+ }
743
+ if (!this.isConnected || !this.inViewport || document.hidden || this.rendererError) return;
694
744
  if (this.revealLastTime !== null) this.revealElapsed += Math.max(0, now - this.revealLastTime);
695
745
  this.revealLastTime = now;
696
746
  const duration = numberAttribute(this, "reveal-ms", SHADER_FONT_DEFAULTS.revealMs, 200, 10000);
@@ -1310,8 +1360,8 @@ export function upgradeLegacyShaderfont(root = document) {
1310
1360
 
1311
1361
  let aliasObserver;
1312
1362
 
1313
- export function registerShaderFontElement({ legacyAliases = false } = {}) {
1314
- ensureStyles();
1363
+ export function registerShaderFontElement({ legacyAliases = false, styleNonce } = {}) {
1364
+ ensureStyles(styleNonce);
1315
1365
  if (!customElements.get("shader-font")) customElements.define("shader-font", ShaderFont);
1316
1366
  // Only the legacy script API needs a document-wide alias observer.
1317
1367
  if (legacyAliases && !aliasObserver) {