@mhmo91/schmancy 0.10.31 → 0.10.33

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.
Files changed (95) hide show
  1. package/dist/agent/schmancy.agent.js +2217 -2140
  2. package/dist/agent/schmancy.agent.js.map +1 -1
  3. package/dist/avatar.cjs +1 -1
  4. package/dist/avatar.js +1 -1
  5. package/dist/badge.cjs +1 -1
  6. package/dist/badge.js +1 -1
  7. package/dist/{button-DlqCWuk-.cjs → button-nDZQe1ES.cjs} +1 -1
  8. package/dist/{button-DlqCWuk-.cjs.map → button-nDZQe1ES.cjs.map} +1 -1
  9. package/dist/{button-BnGN6SsV.js → button-qARUurjf.js} +1 -1
  10. package/dist/{button-BnGN6SsV.js.map → button-qARUurjf.js.map} +1 -1
  11. package/dist/button.cjs +1 -1
  12. package/dist/button.js +2 -2
  13. package/dist/{chips-lipKBK9P.cjs → chips-DIZFWnDZ.cjs} +1 -1
  14. package/dist/{chips-lipKBK9P.cjs.map → chips-DIZFWnDZ.cjs.map} +1 -1
  15. package/dist/{chips-B8HM25xv.js → chips-xaoSmwBK.js} +1 -1
  16. package/dist/{chips-B8HM25xv.js.map → chips-xaoSmwBK.js.map} +1 -1
  17. package/dist/chips.cjs +1 -1
  18. package/dist/chips.js +1 -1
  19. package/dist/content-drawer.cjs +1 -1
  20. package/dist/content-drawer.js +1 -1
  21. package/dist/{date-range-CObvXmZ4.js → date-range-8OkCahnR.js} +1 -1
  22. package/dist/{date-range-CObvXmZ4.js.map → date-range-8OkCahnR.js.map} +1 -1
  23. package/dist/{date-range-DXct0_Jg.cjs → date-range-Bbzg9aym.cjs} +1 -1
  24. package/dist/{date-range-DXct0_Jg.cjs.map → date-range-Bbzg9aym.cjs.map} +1 -1
  25. package/dist/date-range.cjs +1 -1
  26. package/dist/date-range.js +1 -1
  27. package/dist/{details-DSGEewvZ.cjs → details-3X9YKpuP.cjs} +1 -1
  28. package/dist/{details-DSGEewvZ.cjs.map → details-3X9YKpuP.cjs.map} +1 -1
  29. package/dist/{details-nRdT8J-W.js → details-BO_3CCNn.js} +1 -1
  30. package/dist/{details-nRdT8J-W.js.map → details-BO_3CCNn.js.map} +1 -1
  31. package/dist/details.cjs +1 -1
  32. package/dist/details.js +1 -1
  33. package/dist/{directives-DJbBHfID.cjs → directives-BOsvcH83.cjs} +11 -11
  34. package/dist/directives-BOsvcH83.cjs.map +1 -0
  35. package/dist/{directives-DZzxV3Hh.js → directives-D7AoVfPK.js} +231 -158
  36. package/dist/directives-D7AoVfPK.js.map +1 -0
  37. package/dist/directives.cjs +1 -1
  38. package/dist/directives.js +2 -2
  39. package/dist/form.cjs +1 -1
  40. package/dist/form.js +2 -2
  41. package/dist/handover/agent-runtime-followups.md +1 -1
  42. package/dist/handover/agent-runtime-v1.md +3 -3
  43. package/dist/index.cjs +1 -1
  44. package/dist/index.js +11 -11
  45. package/dist/{magnetic-MQ3HMHJi.cjs → magnetic-DKtc4umC.cjs} +1 -1
  46. package/dist/magnetic-DKtc4umC.cjs.map +1 -0
  47. package/dist/{magnetic-B2VKNfDu.js → magnetic-DaOOv5Dz.js} +13 -9
  48. package/dist/magnetic-DaOOv5Dz.js.map +1 -0
  49. package/dist/{menu-BNPbrAmd.cjs → menu-B5EKUeeD.cjs} +1 -1
  50. package/dist/{menu-BNPbrAmd.cjs.map → menu-B5EKUeeD.cjs.map} +1 -1
  51. package/dist/{menu-D2cZSp74.js → menu-CgdXrzir.js} +1 -1
  52. package/dist/{menu-D2cZSp74.js.map → menu-CgdXrzir.js.map} +1 -1
  53. package/dist/menu.cjs +1 -1
  54. package/dist/menu.js +1 -1
  55. package/dist/nav-drawer.cjs +1 -1
  56. package/dist/nav-drawer.js +1 -1
  57. package/dist/navigation-bar.cjs +1 -1
  58. package/dist/navigation-bar.js +1 -1
  59. package/dist/{overlay-U5jr3OYG.cjs → overlay-5PMZ75PO.cjs} +1 -1
  60. package/dist/{overlay-U5jr3OYG.cjs.map → overlay-5PMZ75PO.cjs.map} +1 -1
  61. package/dist/{overlay-BVdgWkIj.js → overlay-BWcB2pRx.js} +2 -2
  62. package/dist/{overlay-BVdgWkIj.js.map → overlay-BWcB2pRx.js.map} +1 -1
  63. package/dist/overlay.cjs +1 -1
  64. package/dist/{overlay.confirm-body-BYPEKZtR.js → overlay.confirm-body-B7W0DOGS.js} +1 -1
  65. package/dist/{overlay.confirm-body-BYPEKZtR.js.map → overlay.confirm-body-B7W0DOGS.js.map} +1 -1
  66. package/dist/{overlay.confirm-body-BCWt92R7.cjs → overlay.confirm-body-CsvwcBvG.cjs} +1 -1
  67. package/dist/{overlay.confirm-body-BCWt92R7.cjs.map → overlay.confirm-body-CsvwcBvG.cjs.map} +1 -1
  68. package/dist/overlay.js +3 -3
  69. package/dist/{overlay.service-D4_SgGuT.cjs → overlay.service-CC4zckoV.cjs} +1 -1
  70. package/dist/{overlay.service-D4_SgGuT.cjs.map → overlay.service-CC4zckoV.cjs.map} +1 -1
  71. package/dist/{overlay.service-Dq2X6ibl.js → overlay.service-zx465FI8.js} +2 -2
  72. package/dist/{overlay.service-Dq2X6ibl.js.map → overlay.service-zx465FI8.js.map} +1 -1
  73. package/dist/{select-gRJb1TEf.js → select-C_ljy5k4.js} +1 -1
  74. package/dist/{select-gRJb1TEf.js.map → select-C_ljy5k4.js.map} +1 -1
  75. package/dist/{select-Gb9fTA4M.cjs → select-D61MbhmA.cjs} +1 -1
  76. package/dist/{select-Gb9fTA4M.cjs.map → select-D61MbhmA.cjs.map} +1 -1
  77. package/dist/select.cjs +1 -1
  78. package/dist/select.js +1 -1
  79. package/dist/{src-DavVEUeO.js → src-CdX0NekF.js} +8 -8
  80. package/dist/{src-DavVEUeO.js.map → src-CdX0NekF.js.map} +1 -1
  81. package/dist/{src-C_7k7YhE.cjs → src-DEgL_xJv.cjs} +1 -1
  82. package/dist/{src-C_7k7YhE.cjs.map → src-DEgL_xJv.cjs.map} +1 -1
  83. package/dist/teleport.cjs +1 -1
  84. package/dist/teleport.js +1 -1
  85. package/package.json +21 -21
  86. package/src/directives/art/art.directive.ts +4 -2
  87. package/src/directives/art/effects/starfield.ts +222 -77
  88. package/src/directives/art/types.ts +41 -6
  89. package/src/directives/magnetic.ts +8 -1
  90. package/types/src/directives/art/effects/starfield.d.ts +11 -4
  91. package/types/src/directives/art/types.d.ts +41 -6
  92. package/dist/directives-DJbBHfID.cjs.map +0 -1
  93. package/dist/directives-DZzxV3Hh.js.map +0 -1
  94. package/dist/magnetic-B2VKNfDu.js.map +0 -1
  95. package/dist/magnetic-MQ3HMHJi.cjs.map +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mhmo91/schmancy",
3
- "version": "0.10.31",
3
+ "version": "0.10.33",
4
4
  "description": "UI library build with web components",
5
5
  "main": "./dist/index.js",
6
6
  "customElements": "custom-elements.json",
@@ -89,42 +89,42 @@
89
89
  "dependencies": {
90
90
  "@floating-ui/dom": "^1.7.6",
91
91
  "@lit-labs/motion": "^1.1.0",
92
- "@lit-labs/signals": "^0.2.0",
92
+ "@lit-labs/signals": "^0.3.0",
93
93
  "@lit-labs/virtualizer": "^2.1.1",
94
94
  "@lit/context": "^1.1.6",
95
95
  "@material/material-color-utilities": "^0.4.0",
96
96
  "@material/web": "^2.4.1",
97
97
  "dayjs": "^1.11.20",
98
- "immer": "^11.1.4",
99
- "lit": "^3.3.2",
98
+ "immer": "^11.1.8",
99
+ "lit": "^3.3.3",
100
100
  "rxjs": "^7.8.2",
101
101
  "ts-is-present": "^1.2.2"
102
102
  },
103
103
  "devDependencies": {
104
104
  "@rollup/plugin-strip": "^3.0.4",
105
105
  "@rollup/plugin-terser": "^1.0.0",
106
- "@tailwindcss/postcss": "^4.2.1",
107
- "@tailwindcss/vite": "^4.2.1",
108
- "@types/node": "^25.5.0",
109
- "@vitest/browser-playwright": "^4.1.4",
110
- "@vitest/coverage-v8": "^4.1.5",
111
- "@vitest/ui": "^4.1.0",
112
- "axe-core": "^4.11.3",
113
- "happy-dom": "^20.8.4",
114
- "knip": "^6.6.0",
106
+ "@tailwindcss/postcss": "^4.3.0",
107
+ "@tailwindcss/vite": "^4.3.0",
108
+ "@types/node": "^25.8.0",
109
+ "@vitest/browser-playwright": "^4.1.6",
110
+ "@vitest/coverage-v8": "^4.1.6",
111
+ "@vitest/ui": "^4.1.6",
112
+ "axe-core": "^4.11.4",
113
+ "happy-dom": "^20.9.0",
114
+ "knip": "^6.13.1",
115
115
  "lit-analyzer": "^2.0.3",
116
- "oxlint": "^1.61.0",
117
- "playwright": "^1.59.1",
118
- "postcss": "^8.5.8",
119
- "prettier": "^3.8.1",
116
+ "oxlint": "^1.64.0",
117
+ "playwright": "^1.60.0",
118
+ "postcss": "^8.5.14",
119
+ "prettier": "^3.8.3",
120
120
  "rollup-plugin-copy": "^3.5.0",
121
- "sass": "^1.98.0",
122
- "tailwindcss": "^4.2.1",
121
+ "sass": "^1.99.0",
122
+ "tailwindcss": "^4.3.0",
123
123
  "ts-lit-plugin": "^2.0.2",
124
124
  "ts-morph": "^28.0.0",
125
125
  "typescript": "^5.9.3",
126
- "vite": "^8.0.0",
127
- "vitest": "^4.1.0",
126
+ "vite": "^8.0.13",
127
+ "vitest": "^4.1.6",
128
128
  "web-component-analyzer": "^2.0.0"
129
129
  },
130
130
  "peerDependencies": {
@@ -29,14 +29,15 @@ class ArtDirective extends AsyncDirective {
29
29
 
30
30
  override update(part: ElementPart, [options]: [ArtOptions]) {
31
31
  const element = part.element as HTMLElement
32
- const { name, color, intensity = 1, speed = 1 } = options
32
+ const { name, color, intensity = 1, speed = 1, density = 1 } = options
33
33
 
34
34
  if (
35
35
  this.state &&
36
36
  (this.state.effect !== name ||
37
37
  this.state.color !== color ||
38
38
  this.state.intensity !== intensity ||
39
- this.state.speed !== speed)
39
+ this.state.speed !== speed ||
40
+ this.state.density !== density)
40
41
  ) {
41
42
  this.cleanup()
42
43
  }
@@ -49,6 +50,7 @@ class ArtDirective extends AsyncDirective {
49
50
  color,
50
51
  intensity,
51
52
  speed,
53
+ density,
52
54
  element,
53
55
  isVisible: true,
54
56
  initialized: false,
@@ -1,107 +1,252 @@
1
1
  /**
2
- * Starfield Effect — Night sky with twinkling stars.
2
+ * Starfield Effect — surreal deep-space drift with rare comets.
3
3
  *
4
- * 6 groups × 7 stars using CSS radial-gradient backgrounds.
5
- * Wave-by-wave fade-in (group 0 first, then 1, 2, …); gentle twinkle once visible.
6
- * Opacity driven by RAF instead of CSS @keyframes to avoid Shadow DOM scoping.
4
+ * A recessive page backdrop. One <canvas>; stars live in
5
+ * structure-of-arrays Float32Array tables (zero per-frame allocation) and
6
+ * render as pre-rendered glow sprites via drawImage no gradient is built
7
+ * inside the RAF loop. Three parallax depth layers drift on slow orbit paths;
8
+ * colour temperature runs blue-white → warm; entrance reveals far→near in
9
+ * waves behind a faint corner nebula; a pooled comet streaks by rarely.
10
+ * Sparse and dim by design — tune reach with the `density` ArtOption.
11
+ *
12
+ * Performance budget: ≤ MAX_STAR_COUNT drawImage calls + one nebula blit per
13
+ * frame, dpr capped at 2, the whole field idle under prefers-reduced-motion.
7
14
  */
8
15
 
9
16
  import type { ArtState } from '../types'
10
17
  import { createOverlayContainer } from '../utils'
11
18
 
12
- const GROUP_COUNT = 6
13
- const STARS_PER_GROUP = 7
14
-
15
- function generateStarGroups() {
16
- return Array.from({ length: GROUP_COUNT }, (_, g) => {
17
- const stops: string[] = []
18
- for (let i = 0; i < STARS_PER_GROUP; i++) {
19
- const x = Math.random() * 96 + 2
20
- const y = Math.random() * 94 + 3
21
- const size = 0.8 + Math.random() * 1.5
22
- const alpha = 0.3 + Math.random() * 0.7
23
- stops.push(
24
- `radial-gradient(circle ${size}px at ${x}% ${y}%, rgba(255,255,255,${alpha}) 0%, transparent 100%)`,
25
- )
26
- }
27
- const appearDelay = g * 0.6
28
- return {
29
- bg: stops.join(','),
30
- appearDelay,
31
- twinkleDuration: 5 + g * 0.7,
32
- twinkleDelay: appearDelay + 2.5,
33
- }
34
- })
35
- }
19
+ const BASE_STAR_COUNT = 90
20
+ // Hard ceiling: a wide monitor must never carpet, whatever the density prop asks.
21
+ const MAX_STAR_COUNT = 140
22
+ const COMET_POOL = 3
23
+ const APPEAR_DURATION = 2.5
24
+ const SPRITE_PX = 64
25
+ // Cool warm. Index 0 is the dominant blue-white; the tail warms toward amber.
26
+ const TEMPERATURE_RGB = [
27
+ [205, 222, 255],
28
+ [224, 233, 255],
29
+ [255, 255, 255],
30
+ [255, 240, 214],
31
+ [255, 222, 184],
32
+ ]
36
33
 
37
34
  export function createStarfieldOverlay(state: ArtState): void {
38
35
  const { element } = state
39
36
  const overlay = createOverlayContainer('starfield-overlay')
40
37
 
41
- const starArea = document.createElement('div')
42
- starArea.style.cssText = 'position:absolute;bottom:0;right:0;width:66vw;height:66vw;'
43
- overlay.appendChild(starArea)
44
-
45
- const generated = generateStarGroups()
46
- const groups: NonNullable<ArtState['starfield']>['groups'] = []
47
-
48
- for (const g of generated) {
49
- const div = document.createElement('div')
50
- div.style.cssText = `position:absolute;inset:0;opacity:0;will-change:opacity;background:${g.bg};`
51
- starArea.appendChild(div)
52
- groups.push({
53
- element: div,
54
- appearDelay: g.appearDelay,
55
- twinkleDuration: g.twinkleDuration,
56
- twinkleDelay: g.twinkleDelay,
57
- })
38
+ const canvas = document.createElement('canvas')
39
+ canvas.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;'
40
+ overlay.appendChild(canvas)
41
+ element.appendChild(overlay)
42
+
43
+ const ctx = canvas.getContext('2d')
44
+ if (!ctx) return
45
+
46
+ const dpr = Math.min(window.devicePixelRatio || 1, 2)
47
+ const rect = element.getBoundingClientRect()
48
+ const deviceWidth = Math.max(1, Math.round(rect.width * dpr))
49
+ const deviceHeight = Math.max(1, Math.round(rect.height * dpr))
50
+ canvas.width = deviceWidth
51
+ canvas.height = deviceHeight
52
+
53
+ const sprites = TEMPERATURE_RGB.map(([r, g, b]) => {
54
+ const s = document.createElement('canvas')
55
+ s.width = SPRITE_PX
56
+ s.height = SPRITE_PX
57
+ const sc = s.getContext('2d')!
58
+ const grad = sc.createRadialGradient(SPRITE_PX / 2, SPRITE_PX / 2, 0, SPRITE_PX / 2, SPRITE_PX / 2, SPRITE_PX / 2)
59
+ grad.addColorStop(0, `rgba(${r},${g},${b},1)`)
60
+ grad.addColorStop(0.18, `rgba(${r},${g},${b},0.35)`)
61
+ grad.addColorStop(0.5, `rgba(${r},${g},${b},0)`)
62
+ sc.fillStyle = grad
63
+ sc.fillRect(0, 0, SPRITE_PX, SPRITE_PX)
64
+ return s
65
+ })
66
+
67
+ const nebula = document.createElement('canvas')
68
+ nebula.width = 512
69
+ nebula.height = 512
70
+ const nc = nebula.getContext('2d')!
71
+ const ng = nc.createRadialGradient(256, 256, 0, 256, 256, 256)
72
+ ng.addColorStop(0, 'rgba(150,170,255,0.16)')
73
+ ng.addColorStop(0.4, 'rgba(120,110,210,0.07)')
74
+ ng.addColorStop(1, 'rgba(0,0,0,0)')
75
+ nc.fillStyle = ng
76
+ nc.fillRect(0, 0, 512, 512)
77
+
78
+ const starCount = Math.min(
79
+ MAX_STAR_COUNT,
80
+ Math.round(BASE_STAR_COUNT * Math.min(1, Math.max(0.5, (rect.width * rect.height) / (1280 * 720))) * state.density),
81
+ )
82
+ const sx = new Float32Array(starCount)
83
+ const sy = new Float32Array(starCount)
84
+ const sr = new Float32Array(starCount)
85
+ const sphase = new Float32Array(starCount)
86
+ const stwinkle = new Float32Array(starCount)
87
+ const sdepth = new Float32Array(starCount)
88
+ const sbucket = new Uint8Array(starCount)
89
+ const sappear = new Float32Array(starCount)
90
+
91
+ for (let i = 0; i < starCount; i++) {
92
+ const depthBand = i % 3 // 0 far, 1 mid, 2 near — even split across layers
93
+ const depth = depthBand === 0 ? 0.18 : depthBand === 1 ? 0.5 : 1
94
+ sx[i] = Math.random()
95
+ sy[i] = Math.random()
96
+ sr[i] = (0.7 + Math.random() * 1.6) * (0.6 + 0.7 * depth)
97
+ sphase[i] = Math.random() * Math.PI * 2
98
+ stwinkle[i] = 0.6 + Math.random() * 1.8
99
+ sdepth[i] = depth
100
+ // Field skews blue-white; only a minority warms — that asymmetry reads as authored.
101
+ sbucket[i] = Math.random() < 0.7 ? (Math.random() < 0.5 ? 0 : 1) : 2 + Math.floor(Math.random() * 3)
102
+ // Far layer arrives first, near layer last → the depth reveals as waves.
103
+ sappear[i] = (1 - depth) * 1.6 + Math.random() * 0.8
58
104
  }
59
105
 
60
- element.appendChild(overlay)
61
106
  state.overlayElement = overlay
62
- state.starfield = { groups, startTime: performance.now() }
107
+ state.starfield = {
108
+ canvas,
109
+ ctx,
110
+ sprites,
111
+ nebula,
112
+ starCount,
113
+ sx,
114
+ sy,
115
+ sr,
116
+ sphase,
117
+ stwinkle,
118
+ sdepth,
119
+ sbucket,
120
+ sappear,
121
+ cx: new Float32Array(COMET_POOL),
122
+ cy: new Float32Array(COMET_POOL),
123
+ cvx: new Float32Array(COMET_POOL),
124
+ cvy: new Float32Array(COMET_POOL),
125
+ clife: new Float32Array(COMET_POOL),
126
+ cometCount: COMET_POOL,
127
+ nextCometAt: performance.now() + 14000 + Math.random() * 16000,
128
+ dpr,
129
+ deviceWidth,
130
+ deviceHeight,
131
+ reducedMotion: window.matchMedia('(prefers-reduced-motion: reduce)').matches,
132
+ drawnStatic: false,
133
+ lastFrame: performance.now(),
134
+ startTime: performance.now(),
135
+ }
63
136
  }
64
137
 
65
138
  export function animateStarfield(state: ArtState, currentTime: number): void {
66
- if (!state.starfield) return
139
+ const sf = state.starfield
140
+ if (!sf) return
67
141
 
68
142
  const { intensity = 1, speed = 1 } = state
69
- const { groups, startTime } = state.starfield
70
- const elapsed = ((currentTime - startTime) / 1000) * speed
143
+ const { ctx, canvas } = sf
144
+
145
+ if (intensity <= 0) {
146
+ ctx.clearRect(0, 0, sf.deviceWidth, sf.deviceHeight)
147
+ return
148
+ }
71
149
 
72
- for (const g of groups) {
73
- if (intensity <= 0) {
74
- g.element.style.opacity = '0'
150
+ // One layout read per frame; resize only when the backing store must change.
151
+ const cssW = canvas.clientWidth
152
+ const cssH = canvas.clientHeight
153
+ const wantW = Math.max(1, Math.round(cssW * sf.dpr))
154
+ const wantH = Math.max(1, Math.round(cssH * sf.dpr))
155
+ if (wantW !== sf.deviceWidth || wantH !== sf.deviceHeight) {
156
+ canvas.width = wantW
157
+ canvas.height = wantH
158
+ sf.deviceWidth = wantW
159
+ sf.deviceHeight = wantH
160
+ sf.drawnStatic = false
161
+ }
162
+
163
+ const W = sf.deviceWidth
164
+ const H = sf.deviceHeight
165
+
166
+ if (sf.reducedMotion && sf.drawnStatic) return
167
+
168
+ const elapsed = ((currentTime - sf.startTime) / 1000) * speed
169
+ const dt = Math.min((currentTime - sf.lastFrame) / 1000, 0.05)
170
+ sf.lastFrame = currentTime
171
+
172
+ ctx.clearRect(0, 0, W, H)
173
+
174
+ // Entrance nebula: blooms in over the first ~4s, then holds at a faint floor.
175
+ const nebulaAlpha = (Math.min(1, elapsed / 4) * 0.7 + 0.3) * 0.14 * intensity
176
+ if (nebulaAlpha > 0.001) {
177
+ const nSize = Math.max(W, H) * 1.3
178
+ ctx.globalAlpha = nebulaAlpha
179
+ ctx.drawImage(sf.nebula, W * 0.78 - nSize / 2, H * 0.28 - nSize / 2, nSize, nSize)
180
+ }
181
+
182
+ // Normal compositing — additive blend is what turned overlap into a glowing carpet.
183
+ for (let i = 0; i < sf.starCount; i++) {
184
+ const appear = sf.sappear[i]
185
+ let reveal: number
186
+ if (sf.reducedMotion) {
187
+ reveal = 1
188
+ } else if (elapsed < appear) {
75
189
  continue
190
+ } else if (elapsed < appear + APPEAR_DURATION) {
191
+ const t = (elapsed - appear) / APPEAR_DURATION
192
+ reveal = 1 - (1 - t) * (1 - t) * (1 - t)
193
+ } else {
194
+ reveal = 1
76
195
  }
77
196
 
78
- const appearStart = g.appearDelay
79
- const appearEnd = appearStart + 2.5
80
- let opacity: number
197
+ const depth = sf.sdepth[i]
198
+ const twinkle = sf.reducedMotion ? 0.9 : 0.82 + 0.18 * Math.sin(elapsed * sf.stwinkle[i] + sf.sphase[i])
81
199
 
82
- if (elapsed < appearStart) {
83
- opacity = 0
84
- } else if (elapsed < appearEnd) {
85
- const t = (elapsed - appearStart) / 2.5
86
- opacity = 1 - Math.pow(1 - t, 3)
87
- } else {
88
- const twinkleElapsed = elapsed - g.twinkleDelay
89
- if (twinkleElapsed < 0) {
90
- opacity = 1
91
- } else {
92
- const cycle = (twinkleElapsed % g.twinkleDuration) / g.twinkleDuration
93
- if (cycle < 0.25) {
94
- opacity = 1 - (cycle / 0.25) * 0.4
95
- } else if (cycle < 0.5) {
96
- opacity = 0.6 + ((cycle - 0.25) / 0.25) * 0.25
97
- } else if (cycle < 0.75) {
98
- opacity = 0.85 - ((cycle - 0.5) / 0.25) * 0.3
99
- } else {
100
- opacity = 0.55 + ((cycle - 0.75) / 0.25) * 0.45
101
- }
200
+ // Slow orbit drift, amplitude scaled by depth — the parallax is the surreality.
201
+ const driftAmp = sf.reducedMotion ? 0 : depth * 14 * sf.dpr
202
+ const px = sf.sx[i] * W + Math.sin(elapsed * 0.05 + sf.sphase[i]) * driftAmp
203
+ const py = sf.sy[i] * H + Math.cos(elapsed * 0.04 + sf.sphase[i]) * driftAmp * 0.6
204
+
205
+ const size = sf.sr[i] * sf.dpr * 2.3
206
+ ctx.globalAlpha = reveal * twinkle * (0.3 + 0.5 * depth) * intensity
207
+ ctx.drawImage(sf.sprites[sf.sbucket[i]], px - size, py - size, size * 2, size * 2)
208
+ }
209
+
210
+ if (!sf.reducedMotion) {
211
+ if (currentTime >= sf.nextCometAt) {
212
+ for (let c = 0; c < sf.cometCount; c++) {
213
+ if (sf.clife[c] > 0) continue
214
+ const fromLeft = Math.random() < 0.5
215
+ sf.cx[c] = (fromLeft ? -0.05 : 1.05) * W
216
+ sf.cy[c] = Math.random() * 0.45 * H
217
+ const sp = (520 + Math.random() * 420) * sf.dpr
218
+ sf.cvx[c] = (fromLeft ? 1 : -1) * sp
219
+ sf.cvy[c] = (0.35 + Math.random() * 0.35) * sp
220
+ sf.clife[c] = 1
221
+ break
102
222
  }
223
+ sf.nextCometAt = currentTime + (20000 + Math.random() * 25000) / speed
103
224
  }
104
225
 
105
- g.element.style.opacity = (opacity * intensity).toFixed(3)
226
+ for (let c = 0; c < sf.cometCount; c++) {
227
+ if (sf.clife[c] <= 0) continue
228
+ sf.cx[c] += sf.cvx[c] * dt * speed
229
+ sf.cy[c] += sf.cvy[c] * dt * speed
230
+ sf.clife[c] -= dt / 1.6
231
+
232
+ const tailX = sf.cx[c] - sf.cvx[c] * 0.16
233
+ const tailY = sf.cy[c] - sf.cvy[c] * 0.16
234
+ const grad = ctx.createLinearGradient(sf.cx[c], sf.cy[c], tailX, tailY)
235
+ const headA = Math.max(0, Math.min(1, sf.clife[c])) * 0.55 * intensity
236
+ grad.addColorStop(0, `rgba(255,255,255,${headA})`)
237
+ grad.addColorStop(0.4, `rgba(190,210,255,${headA * 0.4})`)
238
+ grad.addColorStop(1, 'rgba(190,210,255,0)')
239
+ ctx.globalAlpha = 1
240
+ ctx.strokeStyle = grad
241
+ ctx.lineWidth = 2 * sf.dpr
242
+ ctx.lineCap = 'round'
243
+ ctx.beginPath()
244
+ ctx.moveTo(sf.cx[c], sf.cy[c])
245
+ ctx.lineTo(tailX, tailY)
246
+ ctx.stroke()
247
+ }
106
248
  }
249
+
250
+ ctx.globalAlpha = 1
251
+ sf.drawnStatic = true
107
252
  }
@@ -15,6 +15,8 @@ export interface ArtOptions {
15
15
  intensity?: number
16
16
  /** Animation speed multiplier: 0.5 = half speed, 1 = normal, 2 = double (default: 1) */
17
17
  speed?: number
18
+ /** Particle density multiplier: 0.5 = sparse, 1 = baseline, 2 = dense (default: 1; starfield only) */
19
+ density?: number
18
20
  }
19
21
 
20
22
  export interface Particle<T extends SVGElement = SVGElement> {
@@ -52,6 +54,7 @@ export interface ArtState {
52
54
  color: string
53
55
  intensity: number
54
56
  speed: number
57
+ density: number
55
58
  element: HTMLElement
56
59
  overlayElement?: HTMLElement
57
60
  animationId?: number
@@ -118,12 +121,44 @@ export interface ArtState {
118
121
  }
119
122
 
120
123
  starfield?: {
121
- groups: Array<{
122
- element: HTMLDivElement
123
- appearDelay: number
124
- twinkleDuration: number
125
- twinkleDelay: number
126
- }>
124
+ canvas: HTMLCanvasElement
125
+ ctx: CanvasRenderingContext2D
126
+ /** Pre-rendered glow sprites, cool→warm. Drawn per star so no per-frame gradient is built. */
127
+ sprites: HTMLCanvasElement[]
128
+ /** Pre-rendered entrance nebula bloom, drawn once per frame at a computed alpha. */
129
+ nebula: HTMLCanvasElement
130
+ starCount: number
131
+ /** Structure-of-arrays star table — cache-friendly, zero allocation per frame. */
132
+ sx: Float32Array
133
+ sy: Float32Array
134
+ sr: Float32Array
135
+ sphase: Float32Array
136
+ stwinkle: Float32Array
137
+ /** 0 = far (drifts least, dimmest) → 1 = near (drifts most, brightest). */
138
+ sdepth: Float32Array
139
+ /** Index into `sprites` — fixes each star's colour temperature for its life. */
140
+ sbucket: Uint8Array
141
+ /** Per-star entrance delay (s) so the field reveals far→near in waves. */
142
+ sappear: Float32Array
143
+ /** Fixed comet pool. `life <= 0` marks a free slot; no array churn. */
144
+ cx: Float32Array
145
+ cy: Float32Array
146
+ cvx: Float32Array
147
+ cvy: Float32Array
148
+ clife: Float32Array
149
+ cometCount: number
150
+ /** ms timestamp of the next comet spawn — gates the rare streak. */
151
+ nextCometAt: number
152
+ /** Capped device-pixel-ratio; bounds backing-store fill cost on retina. */
153
+ dpr: number
154
+ /** Cached device-pixel canvas size; a change is the only resize trigger. */
155
+ deviceWidth: number
156
+ deviceHeight: number
157
+ /** Static-render once, then idle the RAF loop. */
158
+ reducedMotion: boolean
159
+ drawnStatic: boolean
160
+ /** Previous frame ms — frame-rate-independent comet motion + jump clamp. */
161
+ lastFrame: number
127
162
  startTime: number
128
163
  }
129
164
  }
@@ -1,7 +1,7 @@
1
1
  import { directive, type ElementPart, PartType } from 'lit/directive.js'
2
2
  import { AsyncDirective } from 'lit/async-directive.js'
3
3
  import { animationFrameScheduler, fromEvent, merge, Subject } from 'rxjs'
4
- import { auditTime, map, takeUntil } from 'rxjs/operators'
4
+ import { auditTime, filter, map, takeUntil } from 'rxjs/operators'
5
5
  import { SPRING_SNAPPY } from '../utils/animation'
6
6
  import { reducedMotion$ } from './reduced-motion'
7
7
 
@@ -77,6 +77,13 @@ class MagneticDirective extends AsyncDirective {
77
77
 
78
78
  const move$ = fromEvent<MouseEvent>(target, 'mousemove').pipe(
79
79
  auditTime(0, animationFrameScheduler),
80
+ filter(() =>
81
+ this.element.checkVisibility?.({
82
+ contentVisibilityAuto: true,
83
+ checkOpacity: true,
84
+ checkVisibilityCSS: true,
85
+ } as CheckVisibilityOptions) ?? true,
86
+ ),
80
87
  map(e => {
81
88
  const rect = this.cachedRect ?? this.element.getBoundingClientRect()
82
89
  const centerX = rect.left + rect.width / 2
@@ -1,9 +1,16 @@
1
1
  /**
2
- * Starfield Effect — Night sky with twinkling stars.
2
+ * Starfield Effect — surreal deep-space drift with rare comets.
3
3
  *
4
- * 6 groups × 7 stars using CSS radial-gradient backgrounds.
5
- * Wave-by-wave fade-in (group 0 first, then 1, 2, …); gentle twinkle once visible.
6
- * Opacity driven by RAF instead of CSS @keyframes to avoid Shadow DOM scoping.
4
+ * A recessive page backdrop. One <canvas>; stars live in
5
+ * structure-of-arrays Float32Array tables (zero per-frame allocation) and
6
+ * render as pre-rendered glow sprites via drawImage no gradient is built
7
+ * inside the RAF loop. Three parallax depth layers drift on slow orbit paths;
8
+ * colour temperature runs blue-white → warm; entrance reveals far→near in
9
+ * waves behind a faint corner nebula; a pooled comet streaks by rarely.
10
+ * Sparse and dim by design — tune reach with the `density` ArtOption.
11
+ *
12
+ * Performance budget: ≤ MAX_STAR_COUNT drawImage calls + one nebula blit per
13
+ * frame, dpr capped at 2, the whole field idle under prefers-reduced-motion.
7
14
  */
8
15
  import type { ArtState } from '../types';
9
16
  export declare function createStarfieldOverlay(state: ArtState): void;
@@ -12,6 +12,8 @@ export interface ArtOptions {
12
12
  intensity?: number;
13
13
  /** Animation speed multiplier: 0.5 = half speed, 1 = normal, 2 = double (default: 1) */
14
14
  speed?: number;
15
+ /** Particle density multiplier: 0.5 = sparse, 1 = baseline, 2 = dense (default: 1; starfield only) */
16
+ density?: number;
15
17
  }
16
18
  export interface Particle<T extends SVGElement = SVGElement> {
17
19
  element: T;
@@ -46,6 +48,7 @@ export interface ArtState {
46
48
  color: string;
47
49
  intensity: number;
48
50
  speed: number;
51
+ density: number;
49
52
  element: HTMLElement;
50
53
  overlayElement?: HTMLElement;
51
54
  animationId?: number;
@@ -115,12 +118,44 @@ export interface ArtState {
115
118
  startTime: number;
116
119
  };
117
120
  starfield?: {
118
- groups: Array<{
119
- element: HTMLDivElement;
120
- appearDelay: number;
121
- twinkleDuration: number;
122
- twinkleDelay: number;
123
- }>;
121
+ canvas: HTMLCanvasElement;
122
+ ctx: CanvasRenderingContext2D;
123
+ /** Pre-rendered glow sprites, cool→warm. Drawn per star so no per-frame gradient is built. */
124
+ sprites: HTMLCanvasElement[];
125
+ /** Pre-rendered entrance nebula bloom, drawn once per frame at a computed alpha. */
126
+ nebula: HTMLCanvasElement;
127
+ starCount: number;
128
+ /** Structure-of-arrays star table — cache-friendly, zero allocation per frame. */
129
+ sx: Float32Array;
130
+ sy: Float32Array;
131
+ sr: Float32Array;
132
+ sphase: Float32Array;
133
+ stwinkle: Float32Array;
134
+ /** 0 = far (drifts least, dimmest) → 1 = near (drifts most, brightest). */
135
+ sdepth: Float32Array;
136
+ /** Index into `sprites` — fixes each star's colour temperature for its life. */
137
+ sbucket: Uint8Array;
138
+ /** Per-star entrance delay (s) so the field reveals far→near in waves. */
139
+ sappear: Float32Array;
140
+ /** Fixed comet pool. `life <= 0` marks a free slot; no array churn. */
141
+ cx: Float32Array;
142
+ cy: Float32Array;
143
+ cvx: Float32Array;
144
+ cvy: Float32Array;
145
+ clife: Float32Array;
146
+ cometCount: number;
147
+ /** ms timestamp of the next comet spawn — gates the rare streak. */
148
+ nextCometAt: number;
149
+ /** Capped device-pixel-ratio; bounds backing-store fill cost on retina. */
150
+ dpr: number;
151
+ /** Cached device-pixel canvas size; a change is the only resize trigger. */
152
+ deviceWidth: number;
153
+ deviceHeight: number;
154
+ /** Static-render once, then idle the RAF loop. */
155
+ reducedMotion: boolean;
156
+ drawnStatic: boolean;
157
+ /** Previous frame ms — frame-rate-independent comet motion + jump clamp. */
158
+ lastFrame: number;
124
159
  startTime: number;
125
160
  };
126
161
  }