@bakery-framework/plugin-dashboard 2.0.0-alpha.11 → 2.0.0-alpha.13

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.
@@ -31,7 +31,7 @@ export function setText(id: string, value: string) {
31
31
  * stats panels build markup the same way from data they did not write.
32
32
  */
33
33
  export function emptyBox(message: string, isError = false): string {
34
- const style = isError ? ' style="color: var(--accent-red);"' : ''
34
+ const style = isError ? ' style="color: var(--danger);"' : ''
35
35
  return `<div class="results-empty"${style}><span>${escapeHTML(message)}</span></div>`
36
36
  }
37
37
 
@@ -57,8 +57,9 @@ export function icon(d: string, size: string): string {
57
57
  )
58
58
  }
59
59
 
60
- export const ICON_EDIT =
61
- 'M5 19h1.425L16.2 9.225L14.775 7.8L5 17.575zm-2 2v-4.25L16.2 3.575q.3-.275.663-.425t.762-.15t.775.15t.65.45L20.425 5q.3.275.438.65T21 6.4q0 .4-.137.763t-.438.662L7.25 21zM19 6.4L17.6 5zm-3.525 2.125l-.7-.725L16.2 9.225z'
60
+ // `ICON_EDIT` was here. It drew the pencil on the session key editor's Edit
61
+ // button, and that button went with the editor it could not open see the
62
+ // note in `sessions.ts`. The path data has no other caller.
62
63
  export const ICON_DELETE =
63
64
  'M7 21q-.825 0-1.412-.587T5 19V6H4V4h5V3h6v1h5v2h-1v13q0 .825-.587 1.413T17 21zM17 6H7v13h10zM9 17h2V8H9zm4 0h2V8h-2zM7 6v13z'
64
65
  /** POST a JSON body to a dashboard endpoint and unwrap the envelope. */
@@ -84,67 +85,20 @@ export function setPager(
84
85
  if (nextBtn) nextBtn.disabled = page >= totalPages
85
86
  }
86
87
 
87
- export class SegmentedProgress {
88
- private container: HTMLElement
89
- private percent: number
90
- private barWidth: number
91
- private barGap: number
92
- private resizeObserver: ResizeObserver | null = null
93
-
94
- constructor(
95
- container: HTMLElement,
96
- percent: number,
97
- barWidth = 4,
98
- barGap = 6,
99
- ) {
100
- this.container = container
101
- this.percent = percent
102
- this.barWidth = barWidth
103
- this.barGap = barGap
104
- this.init()
105
- }
106
-
107
- private init() {
108
- this.container.classList.add('segmented-progress-container')
109
- if (this.barGap !== 6) {
110
- this.container.style.gap = `${this.barGap}px`
111
- }
112
-
113
- if (typeof ResizeObserver !== 'undefined') {
114
- this.resizeObserver = new ResizeObserver(() => this.draw())
115
- this.resizeObserver.observe(this.container)
116
- }
117
-
118
- this.draw()
119
- }
120
-
121
- public destroy() {
122
- if (this.resizeObserver) {
123
- this.resizeObserver.disconnect()
124
- }
125
- }
126
-
127
- public draw() {
128
- const containerWidth = this.container.clientWidth
129
- if (containerWidth === 0) return
130
-
131
- const count = Math.floor(
132
- (containerWidth + this.barGap) / (this.barWidth + this.barGap),
133
- )
134
- const activeCount = Math.round((this.percent / 100) * count)
135
-
136
- let html = ''
137
- for (let i = 0; i < count; i++) {
138
- const className = i < activeCount ? 'active' : 'inactive'
139
- let styleAttr = ''
140
- if (this.barWidth !== 4) {
141
- styleAttr = ` style="width: ${this.barWidth}px;"`
142
- }
143
- html += `<div class="segmented-bar-segment ${className}"${styleAttr}></div>`
144
- }
145
- this.container.innerHTML = html
146
- }
147
- }
88
+ /*
89
+ * `SegmentedProgress` was here, and nothing it drew was ever visible.
90
+ *
91
+ * It filled a `.segmented-progress-container` with one `div` per 10px of
92
+ * width and watched the container with a `ResizeObserver`. Neither that
93
+ * class nor `.segmented-bar-segment` has a rule in the sheet - grep it - so
94
+ * the container is a bare block with no height and the segments are bare
95
+ * blocks with no width. Measured against a running console on the Traffic
96
+ * panel: 10 containers, 98 children each, every one of them 0px tall.
97
+ *
98
+ * So it cost 980 nodes and 10 `ResizeObserver`s per render of that panel,
99
+ * redrawn on every resize, to show nothing. The list it sat under - path and
100
+ * hit count - works and stays.
101
+ */
148
102
 
149
103
  export function getWebSocketUrl(path: string) {
150
104
  const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'
@@ -24,7 +24,6 @@ export function renderDatabaseBrowser() {
24
24
  <div class="browser-header">
25
25
  <div class="table-info">
26
26
  <h2>
27
- <iconify-icon icon="lucide:database"></iconify-icon>
28
27
  <span>Database</span>
29
28
  </h2>
30
29
  </div>
@@ -43,7 +42,6 @@ export function renderDatabaseBrowser() {
43
42
  </p>
44
43
  <div class="modal-actions">
45
44
  <a class="btn btn-primary" href="/_db">
46
- <iconify-icon icon="lucide:external-link"></iconify-icon>
47
45
  <span>Open Database Explorer</span>
48
46
  </a>
49
47
  </div>
@@ -10,14 +10,12 @@ export function renderLogsPanel() {
10
10
  class="btn btn-secondary"
11
11
  onclick="toggleLogsPlay()"
12
12
  id="btn-logs-play">
13
- <iconify-icon icon="lucide:pause"></iconify-icon>
14
13
  <span>Pause</span>
15
14
  </button>
16
15
  <button
17
16
  type="button"
18
17
  class="btn btn-secondary btn-danger"
19
18
  onclick="clearLogs()">
20
- <iconify-icon icon="lucide:trash-2"></iconify-icon>
21
19
  <span>Clear Logs</span>
22
20
  </button>
23
21
  <label>
@@ -13,7 +13,6 @@ export function renderSessionsPanel() {
13
13
  type="button"
14
14
  class="btn btn-secondary"
15
15
  onclick="loadSessions()">
16
- <iconify-icon icon="lucide:refresh-cw"></iconify-icon>
17
16
  <span>Refresh Sessions</span>
18
17
  </button>
19
18
  </div>
@@ -62,7 +61,6 @@ export function renderSessionsPanel() {
62
61
  id="session-page-prev"
63
62
  class="btn btn-secondary"
64
63
  onclick="prevSessionPage()">
65
- <iconify-icon icon="lucide:chevron-left"></iconify-icon>
66
64
  <span>Prev</span>
67
65
  </button>
68
66
  <span id="session-page-info">Page 1 of 1</span>
@@ -72,7 +70,6 @@ export function renderSessionsPanel() {
72
70
  class="btn btn-secondary"
73
71
  onclick="nextSessionPage()">
74
72
  <span>Next</span>
75
- <iconify-icon icon="lucide:chevron-right"></iconify-icon>
76
73
  </button>
77
74
  </div>
78
75
  <div class="rows-meta">
@@ -54,7 +54,6 @@ export function renderStatsPanel() {
54
54
 
55
55
  <div class="section-header">
56
56
  <h2>
57
- <iconify-icon icon="lucide:area-chart"></iconify-icon>
58
57
  <span>Performance History</span>
59
58
  </h2>
60
59
  <div class="timescale-selector">
@@ -223,24 +222,6 @@ export function renderStatsPanel() {
223
222
  </span>
224
223
  </div>
225
224
  </div>
226
- <div class="chart-card glass-effect" id="chart-db-hits">
227
- <span class="card-title">DB Hits History</span>
228
- <span class="card-sub">
229
- Database query executions (last 1 min, 1s resolution)
230
- </span>
231
- <canvas id="canvas-db-hits" class="big-chart"></canvas>
232
- <div class="chart-stats">
233
- <span>
234
- MIN: <strong id="dbHits-min">-</strong>
235
- </span>
236
- <span>
237
- MAX: <strong id="dbHits-max">-</strong>
238
- </span>
239
- <span>
240
- AVG: <strong id="dbHits-avg">-</strong>
241
- </span>
242
- </div>
243
- </div>
244
225
  <div class="chart-card glass-effect" id="chart-error-page-hits">
245
226
  <span class="card-title">Error Page Hits History</span>
246
227
  <span class="card-sub">
@@ -3,7 +3,6 @@ export function renderTopPagesPanel() {
3
3
  <div id="panel-top-pages" class="panel">
4
4
  <div class="section-header">
5
5
  <h2>
6
- <iconify-icon icon="lucide:file-text"></iconify-icon>
7
6
  <span>Top Visited Pages</span>
8
7
  </h2>
9
8
  <div class="timescale-selector">
package/src/setup.ts CHANGED
@@ -139,10 +139,58 @@ async function handleDashboardView() {
139
139
  return injected ? injected : response.html(htmlContent)
140
140
  }
141
141
 
142
- async function handleJsAsset() {
142
+ /**
143
+ * A bare `import` left in the bundle, or `null`.
144
+ *
145
+ * `bundleModule` marks every *installed* package external, so a bare specifier
146
+ * survives into the output and resolves through the page's import map. That is
147
+ * correct for the `/_nm/` bundles, which are loaded as modules. It is fatal
148
+ * here: this file is served as a classic `<script>`, an import map does not
149
+ * apply to one, and a top-level `import` is a syntax error — so the *entire*
150
+ * bundle fails to execute and the console does nothing at all.
151
+ *
152
+ * It is worth a check rather than a convention because of how it failed.
153
+ * `Bun.build` reported success, the file was served with a 200 and the right
154
+ * length, typecheck passed, and 2,376 tests passed — because nothing
155
+ * *requested the page*. That is the same gap CLAUDE.md already records for
156
+ * `apps/starter`: a process that boots is not a page that works.
157
+ *
158
+ * Matched on a line starting with `import`, which is what an ESM bundle emits
159
+ * for an external. A dynamic `import(...)` inside a function body is fine and
160
+ * does not start a line.
161
+ */
162
+ export function bareImportIn(bundle: string): string | null {
163
+ const match = bundle.match(/^import\s[^\n]*?from\s*['"]([^'"]+)['"]/m)
164
+ return match ? match[1]! : null
165
+ }
166
+
167
+ /**
168
+ * Test seams (convention 9). The cached branch is **production-only** -
169
+ * `isDevWorker` skips it, and in development the bundle is returned as a
170
+ * string, which `response.type` does give an ETag. That is why the defect was
171
+ * invisible to a dev server and had to be reproduced under `bun run serve`,
172
+ * and why a test has to reach the branch directly rather than through a mode.
173
+ */
174
+ export function __setCachedDashboardJs(path: string | null): void {
175
+ cachedDashboardJsPath = path
176
+ }
177
+
178
+ export async function handleJsAsset() {
143
179
  if (cachedDashboardJsPath && !isDevWorker) {
144
180
  const cachedFile = Bun.file(cachedDashboardJsPath)
145
- if (cachedFile.size > 0) return response.type(cachedFile, 'text/javascript')
181
+ // **The file, not a `Response` wrapping it.** `processResponse` sends a
182
+ // `Blob` through `ETag.sendFile`, which computes an ETag from the file,
183
+ // negotiates a precompressed variant and answers a conditional request
184
+ // with a 304. A `Response` skips all of that: `ETag.sendResponse` returns
185
+ // early when there is no ETag header already set, so it gains neither an
186
+ // ETag nor a `Cache-Control`.
187
+ //
188
+ // The first request took this path correctly - it returns the written
189
+ // file below - and every request after it did not, so the console
190
+ // re-downloaded 30.6 KB on every load. The content type comes from the
191
+ // `.js` extension on the cached path, which is what the first request
192
+ // already relied on.
193
+ if (cachedFile.size > 0) return cachedFile
146
194
  }
147
195
 
148
196
  const bundleResult = await bundleModule(pluginPath('client/dashboard.ts'))
@@ -157,6 +205,19 @@ async function handleJsAsset() {
157
205
  )
158
206
  }
159
207
 
208
+ // See `bareImportIn`. A build that "succeeded" and cannot run is worse than
209
+ // one that failed, because it is silent.
210
+ const bare = bareImportIn(bundleResult.content as string)
211
+ if (bare) {
212
+ pluginLog.DASHBOARD_BUNDLE_ERR({
213
+ error: `dashboard.js kept a bare import of "${bare}". It is served as a classic script, so an import map cannot resolve it and the whole bundle fails to parse. The console client must not import across a package boundary.`,
214
+ })
215
+ return response.error(
216
+ `dashboard.js kept a bare import of "${bare}"`,
217
+ 500,
218
+ )
219
+ }
220
+
160
221
  if (!isDevWorker) {
161
222
  const tmpPath = `${Bakery.cacheDir}/_dashboard.js`
162
223
  await Bun.write(tmpPath, bundleResult.content)
package/src/shell.tsx CHANGED
@@ -95,11 +95,37 @@ function NavItem({ id, label, href }: NavEntry) {
95
95
  )
96
96
  }
97
97
 
98
+ /**
99
+ * The rendered shell, and the one fact it depends on.
100
+ *
101
+ * Everything in this document is fixed for the life of the process except
102
+ * whether an explorer is mounted at `/_db`, which decides one nav entry, and
103
+ * the framework version, which cannot change at all. Rendering it again per
104
+ * request produced an identical 11.8 KB string every time.
105
+ *
106
+ * Measured at **0.50 ms per render** with a CPU-bound control flat at 27-30 ms
107
+ * - the backlog recorded 12.6 ms, which is 25 times the figure this actually
108
+ * measures. Small, then, and still pure waste on every console page load.
109
+ *
110
+ * Keyed on the mounted flag rather than held unconditionally, so a registry
111
+ * that changes after the first render is followed rather than remembered
112
+ * wrongly. Two entries at most, which is why a plain pair is enough and
113
+ * convention 6 has nothing to say about it.
114
+ */
115
+ let shellCache: { mounted: boolean; html: string } | null = null
116
+
117
+ /** Test seam (convention 9): one test's shell must not answer for another's. */
118
+ export function __resetShellCache(): void {
119
+ shellCache = null
120
+ }
121
+
98
122
  export default function Dashboard() {
99
123
  const explorerMounted = explorerIsMounted()
124
+ if (shellCache?.mounted === explorerMounted) return shellCache.html
125
+
100
126
  const NAV = navSections(explorerMounted)
101
127
 
102
- return (
128
+ const html = (
103
129
  <html lang="en">
104
130
  <head>
105
131
  <meta charSet="UTF-8" />
@@ -197,5 +223,12 @@ export default function Dashboard() {
197
223
  <script src="/_dashboard/dashboard.js"></script>
198
224
  </body>
199
225
  </html>
200
- )
226
+ ) as unknown as string
227
+ // The same cast `raw()` makes in `core/jsx.ts`: `createElement` is declared
228
+ // to return `string` and actually returns a `SafeHtml`, a String subclass
229
+ // that behaves like one everywhere. Cached as it came rather than through
230
+ // `String()`, so nothing about the value changes by being remembered.
231
+
232
+ shellCache = { mounted: explorerMounted, html }
233
+ return html
201
234
  }
@@ -1,327 +0,0 @@
1
- export let refreshShimmerCache = () => {}
2
-
3
- /**
4
- * Both effects below run on import. Without this guard, importing anything that
5
- * transitively reaches this file outside a browser throws on the first `window`
6
- * access — which made every module in `client/parts/` untestable, including the
7
- * hand-built HTML in the since-retired `database.ts` that produced every XSS
8
- * found in this repo. A DOM effect with no DOM is a no-op, not an error.
9
- */
10
- const HAS_DOM = typeof window !== 'undefined' && typeof document !== 'undefined'
11
-
12
- ;(function initDashboardShimmer() {
13
- if (!HAS_DOM) return
14
- if (!window.matchMedia('(hover: hover) and (pointer: fine)').matches) return
15
-
16
- let cachedCards: {
17
- el: HTMLElement
18
- width: number
19
- height: number
20
- pageLeft: number
21
- pageTop: number
22
- }[] = []
23
- const mouse = { x: -9999, y: -9999 }
24
- let rafId = 0
25
-
26
- refreshShimmerCache = function updateRects() {
27
- const cards = Array.from(
28
- document.querySelectorAll('.glass-effect'),
29
- ) as HTMLElement[]
30
-
31
- cachedCards = cards.map(el => {
32
- const rect = el.getBoundingClientRect()
33
- return {
34
- el,
35
- width: rect.width,
36
- height: rect.height,
37
- pageLeft: rect.left + window.scrollX,
38
- pageTop: rect.top + window.scrollY,
39
- }
40
- })
41
- }
42
-
43
- /**
44
- * The observer below fires for every subtree mutation on the page, and
45
- * `refreshShimmerCache` reads `getBoundingClientRect` once per glass card —
46
- * a forced synchronous layout. The log console appends one node per
47
- * websocket frame, each in its own task, so a busy server bought one full
48
- * layout pass per log line. Coalescing into a frame makes that cost
49
- * proportional to frames, not to mutations.
50
- */
51
- let refreshRafId = 0
52
- function scheduleRefresh() {
53
- if (refreshRafId) return
54
- refreshRafId = requestAnimationFrame(() => {
55
- refreshRafId = 0
56
- refreshShimmerCache()
57
- })
58
- }
59
-
60
- window.addEventListener('resize', scheduleRefresh, { passive: true })
61
-
62
- const observer = new MutationObserver(scheduleRefresh)
63
- observer.observe(document.body, {
64
- childList: true,
65
- subtree: true,
66
- attributes: true,
67
- attributeFilter: ['class'],
68
- })
69
-
70
- refreshShimmerCache()
71
-
72
- function updateStyles() {
73
- const scrollX = window.scrollX
74
- const scrollY = window.scrollY
75
-
76
- for (let i = 0; i < cachedCards.length; i++) {
77
- const c = cachedCards[i]
78
- const left = c.pageLeft - scrollX
79
- const top = c.pageTop - scrollY
80
-
81
- const rx = mouse.x - left
82
- const ry = mouse.y - top
83
-
84
- const closestX = Math.max(left, Math.min(mouse.x, left + c.width))
85
- const closestY = Math.max(top, Math.min(mouse.y, top + c.height))
86
- const dx = mouse.x - closestX
87
- const dy = mouse.y - closestY
88
- const dist = Math.sqrt(dx * dx + dy * dy)
89
-
90
- if (dist < 200) {
91
- c.el.style.setProperty('--mouse-x', `${rx}px`)
92
- c.el.style.setProperty('--mouse-y', `${ry}px`)
93
- } else {
94
- c.el.style.removeProperty('--mouse-x')
95
- c.el.style.removeProperty('--mouse-y')
96
- }
97
- }
98
- rafId = 0
99
- }
100
-
101
- window.addEventListener(
102
- 'pointermove',
103
- e => {
104
- mouse.x = e.clientX
105
- mouse.y = e.clientY
106
- if (!rafId) rafId = requestAnimationFrame(updateStyles)
107
- },
108
- { passive: true },
109
- )
110
-
111
- window.addEventListener(
112
- 'scroll',
113
- () => {
114
- if (mouse.x === -9999) return
115
- if (!rafId) rafId = requestAnimationFrame(updateStyles)
116
- },
117
- { passive: true },
118
- )
119
-
120
- window.addEventListener('mouseleave', () => {
121
- mouse.x = -9999
122
- mouse.y = -9999
123
- if (!rafId) rafId = requestAnimationFrame(updateStyles)
124
- })
125
- })()
126
-
127
- ;(function initDotPattern() {
128
- if (!HAS_DOM) return
129
-
130
- const canvas = document.createElement('canvas')
131
- canvas.id = 'dot-pattern-canvas'
132
- canvas.style.cssText = [
133
- 'position: fixed',
134
- 'inset: 0',
135
- 'width: 100%',
136
- 'height: 100%',
137
- 'z-index: -1',
138
- 'pointer-events: none',
139
- ].join(';')
140
- document.documentElement.prepend(canvas)
141
-
142
- const ctx = canvas.getContext('2d')!
143
- const DOT_SPACING = 28
144
- const DOT_RADIUS = 1.1
145
- const GLOW_RADIUS = 130
146
-
147
- interface Dot {
148
- ox: number
149
- oy: number
150
- x: number
151
- y: number
152
- }
153
-
154
- let dots: Dot[] = []
155
- const mouse = { x: -9999, y: -9999 }
156
- let isAnimating = false
157
- let isPressed = false
158
- /**
159
- * The loop below keeps requesting frames while the pointer is anywhere on
160
- * the page, which on a hidden tab is pure waste — and this console is the
161
- * kind of page that lives in a background tab for hours. Ported from the
162
- * example app's copy of this effect, which had the throttle where this one
163
- * did not; the two files are separate copies on purpose (a plugin's client
164
- * bundle and an app's script cannot share a module), so a fix to one is
165
- * worth checking against the other.
166
- */
167
- let isTabVisible = !document.hidden
168
-
169
- function initDots() {
170
- dots = []
171
- const cols = Math.ceil(canvas.width / DOT_SPACING) + 1
172
- const rows = Math.ceil(canvas.height / DOT_SPACING) + 1
173
-
174
- for (let r = 0; r < rows; r++) {
175
- for (let c = 0; c < cols; c++) {
176
- const ox = c * DOT_SPACING
177
- const oy = r * DOT_SPACING
178
- dots.push({ ox, oy, x: ox, y: oy })
179
- }
180
- }
181
- }
182
-
183
- function resize() {
184
- canvas.width = window.innerWidth
185
- canvas.height = window.innerHeight
186
- initDots()
187
- if (!window.matchMedia('(hover: hover) and (pointer: fine)').matches) {
188
- draw()
189
- } else {
190
- if (!isAnimating) {
191
- isAnimating = true
192
- requestAnimationFrame(draw)
193
- }
194
- }
195
- }
196
-
197
- interface DotTarget {
198
- x: number
199
- y: number
200
- alpha: number
201
- radius: number
202
- }
203
-
204
- function computeDotTarget(dot: Dot): DotTarget {
205
- const dx = mouse.x - dot.ox
206
- const dy = mouse.y - dot.oy
207
- const dist = Math.sqrt(dx * dx + dy * dy)
208
-
209
- let targetX = dot.ox
210
- let targetY = dot.oy
211
- let alpha = 0.07
212
- let radius = DOT_RADIUS
213
-
214
- if (dist < GLOW_RADIUS) {
215
- const t = 1 - dist / GLOW_RADIUS
216
- alpha = 0.07 + t * 0.35
217
- radius = DOT_RADIUS + t * 0.9
218
-
219
- if (dist > 0.01) {
220
- const force = t * (isPressed ? 32 : 18)
221
- if (isPressed) {
222
- targetX = dot.ox + (dx / dist) * force
223
- targetY = dot.oy + (dy / dist) * force
224
- } else {
225
- targetX = dot.ox - (dx / dist) * force
226
- targetY = dot.oy - (dy / dist) * force
227
- }
228
- }
229
- }
230
-
231
- return { x: targetX, y: targetY, alpha, radius }
232
- }
233
-
234
- function moveDot(dot: Dot, target: DotTarget): boolean {
235
- const dx = target.x - dot.x
236
- const dy = target.y - dot.y
237
- if (Math.abs(dx) > 0.01 || Math.abs(dy) > 0.01) {
238
- dot.x += dx * 0.15
239
- dot.y += dy * 0.15
240
- return true
241
- }
242
- dot.x = target.x
243
- dot.y = target.y
244
- return false
245
- }
246
-
247
- function draw() {
248
- ctx.clearRect(0, 0, canvas.width, canvas.height)
249
-
250
- let needsMoreFrames = false
251
-
252
- for (let i = 0; i < dots.length; i++) {
253
- const dot = dots[i]
254
- const target = computeDotTarget(dot)
255
- if (moveDot(dot, target)) needsMoreFrames = true
256
-
257
- ctx.beginPath()
258
- ctx.arc(dot.x, dot.y, target.radius, 0, Math.PI * 2)
259
- ctx.fillStyle = `rgba(255, 255, 255, ${target.alpha})`
260
- ctx.fill()
261
- }
262
-
263
- if (
264
- window.matchMedia('(hover: hover) and (pointer: fine)').matches &&
265
- isTabVisible
266
- ) {
267
- if (mouse.x !== -9999 || needsMoreFrames) {
268
- requestAnimationFrame(draw)
269
- } else {
270
- isAnimating = false
271
- }
272
- } else {
273
- // Hidden tab: stop the loop and let visibilitychange restart it, or the
274
- // flag would stay true and the resume below would never fire.
275
- isAnimating = false
276
- }
277
- }
278
-
279
- if (window.matchMedia('(hover: hover) and (pointer: fine)').matches) {
280
- window.addEventListener('pointermove', e => {
281
- mouse.x = e.clientX
282
- mouse.y = e.clientY
283
- if (!isAnimating) {
284
- isAnimating = true
285
- requestAnimationFrame(draw)
286
- }
287
- })
288
-
289
- window.addEventListener('mouseleave', () => {
290
- mouse.x = -9999
291
- mouse.y = -9999
292
- isPressed = false
293
- if (!isAnimating) {
294
- isAnimating = true
295
- requestAnimationFrame(draw)
296
- }
297
- })
298
-
299
- window.addEventListener('pointerdown', () => {
300
- isPressed = true
301
- if (!isAnimating) {
302
- isAnimating = true
303
- requestAnimationFrame(draw)
304
- }
305
- })
306
-
307
- window.addEventListener('pointerup', () => {
308
- isPressed = false
309
- if (!isAnimating) {
310
- isAnimating = true
311
- requestAnimationFrame(draw)
312
- }
313
- })
314
- }
315
-
316
- document.addEventListener('visibilitychange', () => {
317
- isTabVisible = !document.hidden
318
- if (isTabVisible && !isAnimating) {
319
- isAnimating = true
320
- requestAnimationFrame(draw)
321
- }
322
- })
323
-
324
- window.addEventListener('resize', resize)
325
- resize()
326
- draw()
327
- })()