@ozsarman/clarityjs 0.6.0

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/src/font.js ADDED
@@ -0,0 +1,513 @@
1
+ /**
2
+ * Clarity.js — Font Optimization
3
+ *
4
+ * next/font eşdeğeri.
5
+ * Google Fonts otomatik self-hosting, lokal font preload, sıfır layout shift,
6
+ * font-display:swap otomasyonu, subset & unicode-range desteği.
7
+ *
8
+ * ── Google Fonts kullanımı (.clarity şablonunda) ─────────────────────────────
9
+ *
10
+ * import { GoogleFont } from '@ozsarman/clarityjs/font'
11
+ *
12
+ * const inter = GoogleFont('Inter', {
13
+ * weights: [400, 500, 700],
14
+ * subsets: ['latin', 'latin-ext'],
15
+ * display: 'swap', // varsayılan
16
+ * variable: '--font-inter',
17
+ * });
18
+ *
19
+ * component App() {
20
+ * render {
21
+ * <div class={inter.className}>
22
+ * <h1 style={`font-family: ${inter.style.fontFamily}`}>Başlık</h1>
23
+ * </div>
24
+ * }
25
+ * }
26
+ *
27
+ * ── Lokal font kullanımı ──────────────────────────────────────────────────────
28
+ *
29
+ * import { LocalFont } from '@ozsarman/clarityjs/font'
30
+ *
31
+ * const myFont = LocalFont({
32
+ * src: [
33
+ * { path: '/fonts/MyFont-Regular.woff2', weight: '400', style: 'normal' },
34
+ * { path: '/fonts/MyFont-Bold.woff2', weight: '700', style: 'normal' },
35
+ * ],
36
+ * variable: '--font-my',
37
+ * display: 'swap',
38
+ * preload: true,
39
+ * });
40
+ *
41
+ * ── CSS Variable kullanımı ────────────────────────────────────────────────────
42
+ *
43
+ * // CSS'te:
44
+ * body { font-family: var(--font-inter); }
45
+ *
46
+ * // Component'te:
47
+ * <html class={`${inter.variable} ${mono.variable}`}>
48
+ *
49
+ * ── SSR / Head entegrasyonu ───────────────────────────────────────────────────
50
+ *
51
+ * import { renderFontHead } from '@ozsarman/clarityjs/font'
52
+ *
53
+ * // SSR layout'unda:
54
+ * const headHTML = renderFontHead(); // <link rel="preload"> + <style> etiketleri
55
+ *
56
+ * ── Vite plugin entegrasyonu (otomatik) ──────────────────────────────────────
57
+ *
58
+ * clarityPlugin({ fontOptimization: true })
59
+ * // Build sırasında Google Fonts'u local'e indirir, CSS'i inline eder
60
+ *
61
+ * Author: Claude (Anthropic) + Özdemir Sarman
62
+ */
63
+
64
+ // ─── Kayıtlı fontlar (SSR head injection için) ────────────────────────────────
65
+
66
+ const _registeredFonts = [];
67
+
68
+ // ─── Unique class name üretici ────────────────────────────────────────────────
69
+
70
+ let _fontCounter = 0;
71
+ function _uid() { return `clarity-font-${++_fontCounter}`; }
72
+
73
+ // ─── Google Fonts ─────────────────────────────────────────────────────────────
74
+
75
+ /**
76
+ * Google Fonts entegrasyonu — next/font/google eşdeğeri.
77
+ * Runtime'da preconnect + stylesheet URL üretir.
78
+ * Build sırasında (Vite plugin ile) fontları self-host eder.
79
+ *
80
+ * @param {string} family – Font ailesi adı (örn: 'Inter', 'Roboto Mono')
81
+ * @param {object} opts
82
+ * @param {number[]} [opts.weights=[400]] – Yüklenecek font ağırlıkları
83
+ * @param {string[]} [opts.subsets=['latin']] – unicode subset'leri
84
+ * @param {string} [opts.display='swap'] – CSS font-display değeri
85
+ * @param {boolean} [opts.preload=true] – <link rel="preload"> ekle
86
+ * @param {string[]} [opts.axes] – Variable font axis'leri (örn: ['wght'])
87
+ * @param {string} [opts.variable] – CSS custom property adı (örn: '--font-inter')
88
+ * @param {boolean} [opts.italic=false] – italic varyantları da yükle
89
+ * @returns {FontObject}
90
+ */
91
+ export function GoogleFont(family, opts = {}) {
92
+ const {
93
+ weights = [400],
94
+ subsets = ['latin'],
95
+ display = 'swap',
96
+ preload = true,
97
+ axes = null,
98
+ variable = null,
99
+ italic = false,
100
+ } = opts;
101
+
102
+ const className = _uid();
103
+ const fontFamily = `'${family}', sans-serif`;
104
+ const cssVariable = variable ?? `--clarity-font-${family.toLowerCase().replace(/\s+/g, '-')}`;
105
+
106
+ // Google Fonts URL oluştur (v2 API)
107
+ const gFamilyStr = _buildGoogleFamilyStr(family, weights, italic, axes);
108
+ const gSubsets = subsets.join(',');
109
+ const googleUrl = `https://fonts.googleapis.com/css2?family=${gFamilyStr}&subset=${gSubsets}&display=${display}`;
110
+
111
+ const fontObj = {
112
+ className,
113
+ variable: cssVariable,
114
+ style: { fontFamily },
115
+ // SSR için gerekli bilgiler
116
+ __clarity_font__: true,
117
+ __type__: 'google',
118
+ family,
119
+ googleUrl,
120
+ preload,
121
+ display,
122
+ weights,
123
+ subsets,
124
+ cssVariable,
125
+ };
126
+
127
+ _registeredFonts.push(fontObj);
128
+
129
+ // Client-side: DOM'a inject et (SSR değilse)
130
+ if (typeof document !== 'undefined') {
131
+ _injectGoogleFont(fontObj);
132
+ }
133
+
134
+ return fontObj;
135
+ }
136
+
137
+ function _buildGoogleFamilyStr(family, weights, italic, axes) {
138
+ const name = family.replace(/ /g, '+');
139
+ if (axes) {
140
+ // Variable font — axis değerleri
141
+ const axisStr = axes.join(',');
142
+ const wghtRange = `${Math.min(...weights)}..${Math.max(...weights)}`;
143
+ return `${name}:${axisStr}@${wghtRange}`;
144
+ }
145
+ if (italic) {
146
+ const parts = weights.flatMap(w => [`0,${w}`, `1,${w}`]).join(';');
147
+ return `${name}:ital,wght@${parts}`;
148
+ }
149
+ const wStr = weights.join(';');
150
+ return `${name}:wght@${wStr}`;
151
+ }
152
+
153
+ function _injectGoogleFont(fontObj) {
154
+ // Preconnect
155
+ _injectLinkOnce('preconnect', 'https://fonts.googleapis.com');
156
+ _injectLinkOnce('preconnect', 'https://fonts.gstatic.com', { crossorigin: true });
157
+
158
+ // Stylesheet
159
+ _injectLinkOnce('stylesheet', fontObj.googleUrl, { id: `gf-${fontObj.className}` });
160
+
161
+ // CSS variable → className rule
162
+ _injectFontClassStyle(fontObj.className, fontObj.cssVariable, fontObj.style.fontFamily);
163
+ }
164
+
165
+ // ─── Local Fonts ──────────────────────────────────────────────────────────────
166
+
167
+ /**
168
+ * Lokal font tanımı — next/font/local eşdeğeri.
169
+ * Sistemde kayıtlı veya /public/fonts/ altındaki fontlar için @font-face üretir.
170
+ *
171
+ * @param {object} opts
172
+ * @param {Array<{path:string, weight?:string, style?:string, format?:string}>} opts.src
173
+ * @param {string} [opts.variable] – CSS custom property adı
174
+ * @param {string} [opts.display='swap']
175
+ * @param {boolean} [opts.preload=true]
176
+ * @param {string} [opts.family] – Belirtilmezse dosya adından çıkarılır
177
+ * @param {string} [opts.unicodeRange]
178
+ * @returns {FontObject}
179
+ */
180
+ export function LocalFont(opts = {}) {
181
+ const {
182
+ src,
183
+ variable = null,
184
+ display = 'swap',
185
+ preload = true,
186
+ family = null,
187
+ unicodeRange = null,
188
+ } = opts;
189
+
190
+ if (!src || !Array.isArray(src) || src.length === 0) {
191
+ throw new Error('[clarity/font] LocalFont: src dizisi zorunludur');
192
+ }
193
+
194
+ const className = _uid();
195
+ const fontFamily = family ?? _inferFamilyFromPath(src[0].path);
196
+ const cssVariable = variable ?? `--clarity-font-${fontFamily.toLowerCase().replace(/\s+/g, '-')}`;
197
+
198
+ const fontFaceCSS = src.map(({ path, weight = '400', style: fStyle = 'normal', format }) =>
199
+ _buildFontFaceRule(fontFamily, path, weight, fStyle, display, unicodeRange, format)
200
+ ).join('\n');
201
+
202
+ const fontObj = {
203
+ className,
204
+ variable: cssVariable,
205
+ style: { fontFamily: `'${fontFamily}', sans-serif` },
206
+ __clarity_font__: true,
207
+ __type__: 'local',
208
+ family: fontFamily,
209
+ src,
210
+ preload,
211
+ display,
212
+ fontFaceCSS,
213
+ cssVariable,
214
+ };
215
+
216
+ _registeredFonts.push(fontObj);
217
+
218
+ if (typeof document !== 'undefined') {
219
+ _injectLocalFont(fontObj);
220
+ }
221
+
222
+ return fontObj;
223
+ }
224
+
225
+ function _inferFamilyFromPath(path) {
226
+ const name = path.split('/').pop().replace(/\.(woff2?|ttf|otf|eot)$/i, '');
227
+ return name.replace(/[-_](Regular|Bold|Light|Medium|SemiBold|Italic|Black)/i, '').trim() || 'CustomFont';
228
+ }
229
+
230
+ function _buildFontFaceRule(family, path, weight, style, display, unicodeRange, format) {
231
+ const formatHint = format
232
+ ? `format('${format}')`
233
+ : path.endsWith('.woff2') ? "format('woff2')"
234
+ : path.endsWith('.woff') ? "format('woff')"
235
+ : path.endsWith('.ttf') ? "format('truetype')"
236
+ : path.endsWith('.otf') ? "format('opentype')"
237
+ : '';
238
+
239
+ return `@font-face {
240
+ font-family: '${family}';
241
+ src: url('${path}') ${formatHint};
242
+ font-weight: ${weight};
243
+ font-style: ${style};
244
+ font-display: ${display};${unicodeRange ? `\n unicode-range: ${unicodeRange};` : ''}
245
+ }`;
246
+ }
247
+
248
+ function _injectLocalFont(fontObj) {
249
+ // @font-face kurallarını <style> olarak göm
250
+ const styleId = `lf-${fontObj.className}`;
251
+ if (!document.getElementById(styleId)) {
252
+ const style = document.createElement('style');
253
+ style.id = styleId;
254
+ style.textContent = fontObj.fontFaceCSS;
255
+ document.head.appendChild(style);
256
+ }
257
+
258
+ // Preload: sadece woff2 dosyalarını preload et
259
+ if (fontObj.preload) {
260
+ for (const { path, format } of fontObj.src) {
261
+ const isWoff2 = (format === 'woff2') || path.endsWith('.woff2');
262
+ if (isWoff2) {
263
+ const link = document.createElement('link');
264
+ link.rel = 'preload';
265
+ link.as = 'font';
266
+ link.href = path;
267
+ link.type = 'font/woff2';
268
+ link.setAttribute('crossorigin', 'anonymous');
269
+ document.head.appendChild(link);
270
+ }
271
+ }
272
+ }
273
+
274
+ // className CSS rule
275
+ _injectFontClassStyle(fontObj.className, fontObj.cssVariable, fontObj.style.fontFamily);
276
+ }
277
+
278
+ // ─── SSR: head HTML üret ──────────────────────────────────────────────────────
279
+
280
+ /**
281
+ * Kayıtlı tüm fontlar için SSR <head> HTML'i üret.
282
+ * `renderToDocument()` içinde veya manuel layout'larda çağrılır.
283
+ *
284
+ * @returns {string} HTML string — <link> + <style> etiketleri
285
+ */
286
+ export function renderFontHead() {
287
+ const parts = [];
288
+
289
+ for (const font of _registeredFonts) {
290
+ if (font.__type__ === 'google') {
291
+ // Preconnect
292
+ parts.push(`<link rel="preconnect" href="https://fonts.googleapis.com">`);
293
+ parts.push(`<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>`);
294
+ // Stylesheet — preload ilk, sonra stylesheet
295
+ if (font.preload) {
296
+ parts.push(`<link rel="preload" as="style" href="${_escAttr(font.googleUrl)}">`);
297
+ }
298
+ parts.push(`<link rel="stylesheet" href="${_escAttr(font.googleUrl)}">`);
299
+ } else if (font.__type__ === 'local') {
300
+ // @font-face CSS
301
+ parts.push(`<style id="lf-${font.className}">${font.fontFaceCSS}</style>`);
302
+ // woff2 preload
303
+ if (font.preload) {
304
+ for (const { path, format } of font.src) {
305
+ const isWoff2 = (format === 'woff2') || path.endsWith('.woff2');
306
+ if (isWoff2) {
307
+ parts.push(`<link rel="preload" as="font" type="font/woff2" href="${_escAttr(path)}" crossorigin="anonymous">`);
308
+ }
309
+ }
310
+ }
311
+ }
312
+
313
+ // CSS variable + className rule
314
+ parts.push(`<style>.${font.className}{font-family:${font.style.fontFamily};${font.cssVariable}:${font.style.fontFamily}}</style>`);
315
+ }
316
+
317
+ return parts.join('\n');
318
+ }
319
+
320
+ /**
321
+ * Kayıtlı fontları temizle (SSR arasında state sıfırlama için).
322
+ */
323
+ export function resetFontRegistry() {
324
+ _registeredFonts.length = 0;
325
+ _fontCounter = 0;
326
+ }
327
+
328
+ /**
329
+ * Kayıtlı font listesini döndür (Vite plugin build entegrasyonu için).
330
+ */
331
+ export function getRegisteredFonts() {
332
+ return [..._registeredFonts];
333
+ }
334
+
335
+ // ─── Build-time: Google Fonts self-hosting ────────────────────────────────────
336
+
337
+ /**
338
+ * Google Fonts CSS'ini indir ve @font-face kurallarını woff2 dosyaları
339
+ * ile birlikte outDir'e kaydet. Vite plugin `generateBundle` hook'undan çağrılır.
340
+ *
341
+ * @param {Array} fonts – getRegisteredFonts() çıktısı (sadece google tipi)
342
+ * @param {string} outDir – dist/ dizini
343
+ * @param {string} fontsDir – font dosyaları alt dizini (default: 'fonts')
344
+ * @returns {Promise<{ css: string, downloaded: number }>}
345
+ */
346
+ export async function downloadGoogleFonts(fonts, outDir, fontsDir = 'fonts') {
347
+ const { mkdir, writeFile } = await import('node:fs/promises');
348
+ const { join, resolve } = await import('node:path');
349
+ const { fetch: nodeFetch } = await _getFetch();
350
+
351
+ const outFontsDir = join(resolve(outDir), fontsDir);
352
+ await mkdir(outFontsDir, { recursive: true });
353
+
354
+ let allCSS = '';
355
+ let downloaded = 0;
356
+
357
+ for (const font of fonts) {
358
+ if (font.__type__ !== 'google') continue;
359
+
360
+ // Google Fonts CSS'ini çek (woff2 URL'lerini bulmak için)
361
+ let css;
362
+ try {
363
+ const resp = await nodeFetch(font.googleUrl, {
364
+ headers: { 'User-Agent': 'Mozilla/5.0 (compatible; clarity-js build)' },
365
+ });
366
+ css = await resp.text();
367
+ } catch (err) {
368
+ console.warn(`[clarity/font] Google Fonts indirilemedi (${font.family}): ${err.message}`);
369
+ // Fallback: orijinal URL'yi kullan
370
+ allCSS += `/* ${font.family} — bağlantı hatası, CDN kullanılıyor */\n`;
371
+ continue;
372
+ }
373
+
374
+ // @font-face içindeki woff2 URL'lerini bul ve indir
375
+ const urlRegex = /url\((https:\/\/fonts\.gstatic\.com\/[^)]+\.woff2)\)/g;
376
+ let match;
377
+ let localCSS = css;
378
+
379
+ while ((match = urlRegex.exec(css)) !== null) {
380
+ const remoteUrl = match[1];
381
+ const fileName = remoteUrl.split('/').pop().split('?')[0];
382
+ const localPath = join(outFontsDir, fileName);
383
+
384
+ try {
385
+ const fontResp = await nodeFetch(remoteUrl);
386
+ const buffer = Buffer.from(await fontResp.arrayBuffer());
387
+ await writeFile(localPath, buffer);
388
+ localCSS = localCSS.replace(remoteUrl, `/${fontsDir}/${fileName}`);
389
+ downloaded++;
390
+ } catch (err) {
391
+ console.warn(`[clarity/font] Font dosyası indirilemedi (${fileName}): ${err.message}`);
392
+ }
393
+ }
394
+
395
+ allCSS += localCSS + '\n';
396
+ }
397
+
398
+ // Self-hosted CSS dosyasını yaz
399
+ if (allCSS) {
400
+ await writeFile(join(resolve(outDir), 'fonts.css'), allCSS, 'utf8');
401
+ console.log(`[clarity/font] ✅ ${downloaded} font dosyası indirildi → ${fontsDir}/`);
402
+ }
403
+
404
+ return { css: allCSS, downloaded };
405
+ }
406
+
407
+ // ─── Vite plugin entegrasyon hook'u ──────────────────────────────────────────
408
+
409
+ /**
410
+ * Vite plugin'i için font optimization hook'u.
411
+ * clarityPlugin({ fontOptimization: true }) ile otomatik aktif olur.
412
+ *
413
+ * @param {object} opts
414
+ * @param {string} opts.outDir
415
+ * @param {string} [opts.fontsDir='fonts']
416
+ */
417
+ export async function optimizeFonts(opts = {}) {
418
+ const { outDir = 'dist', fontsDir = 'fonts' } = opts;
419
+ const googleFonts = _registeredFonts.filter(f => f.__type__ === 'google');
420
+ if (googleFonts.length === 0) return;
421
+ return downloadGoogleFonts(googleFonts, outDir, fontsDir);
422
+ }
423
+
424
+ // ─── Font display utility ─────────────────────────────────────────────────────
425
+
426
+ /**
427
+ * Tüm @font-face kurallarına font-display değerini uygula.
428
+ * Mevcut bir CSS string'ine uygulanır.
429
+ *
430
+ * @param {string} css – Mevcut @font-face CSS
431
+ * @param {string} [display='swap']
432
+ * @returns {string} Güncellenmiş CSS
433
+ */
434
+ export function applyFontDisplay(css, display = 'swap') {
435
+ // Zaten font-display varsa güncelle, yoksa ekle
436
+ return css.replace(
437
+ /(@font-face\s*\{[^}]*?)(font-display\s*:[^;]*;)?([^}]*?\})/gs,
438
+ (match, before, existing, after) => {
439
+ if (existing) {
440
+ return `${before}font-display: ${display};${after}`;
441
+ }
442
+ return `${before}font-display: ${display};\n ${after}`;
443
+ }
444
+ );
445
+ }
446
+
447
+ // ─── Sistem fontları (zero-layout-shift, zero-download) ──────────────────────
448
+
449
+ /**
450
+ * Hazır sistem font stack'leri — indirme yok, layout shift yok.
451
+ * next/font/local + system-ui stack eşdeğeri.
452
+ */
453
+ export const SystemFonts = {
454
+ /** Modern cross-platform sans-serif */
455
+ sans: {
456
+ className: 'clarity-font-system-sans',
457
+ variable: '--font-system-sans',
458
+ style: { fontFamily: 'ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif' },
459
+ },
460
+ /** Sistem mono */
461
+ mono: {
462
+ className: 'clarity-font-system-mono',
463
+ variable: '--font-system-mono',
464
+ style: { fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace' },
465
+ },
466
+ /** Serif */
467
+ serif: {
468
+ className: 'clarity-font-system-serif',
469
+ variable: '--font-system-serif',
470
+ style: { fontFamily: 'ui-serif, Georgia, Cambria, "Times New Roman", Times, serif' },
471
+ },
472
+ };
473
+
474
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
475
+
476
+ function _injectLinkOnce(rel, href, attrs = {}) {
477
+ if (typeof document === 'undefined') return;
478
+ const existing = document.querySelector(`link[rel="${rel}"][href="${href}"]`);
479
+ if (existing) return;
480
+ const link = document.createElement('link');
481
+ link.rel = rel;
482
+ link.href = href;
483
+ for (const [k, v] of Object.entries(attrs)) {
484
+ if (v === true) link.setAttribute(k, '');
485
+ else if (k !== 'id' || !v) link.setAttribute(k, v);
486
+ }
487
+ if (attrs.id) link.id = attrs.id;
488
+ document.head.appendChild(link);
489
+ }
490
+
491
+ function _injectFontClassStyle(className, cssVar, fontFamily) {
492
+ if (typeof document === 'undefined') return;
493
+ const styleId = `cls-${className}`;
494
+ if (document.getElementById(styleId)) return;
495
+ const style = document.createElement('style');
496
+ style.id = styleId;
497
+ style.textContent = `.${className}{font-family:${fontFamily};${cssVar}:${fontFamily}}`;
498
+ document.head.appendChild(style);
499
+ }
500
+
501
+ function _escAttr(str) {
502
+ return String(str ?? '').replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;');
503
+ }
504
+
505
+ async function _getFetch() {
506
+ // Node 18+ global fetch destekler; daha eski sürümlerde node-fetch
507
+ if (typeof globalThis.fetch === 'function') return { fetch: globalThis.fetch };
508
+ try {
509
+ return await import('node-fetch');
510
+ } catch {
511
+ throw new Error('[clarity/font] fetch API gerekli (Node 18+ veya node-fetch paketi)');
512
+ }
513
+ }
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Clarity.js — game / animation loop helper
3
+ *
4
+ * A small, honest requestAnimationFrame loop. Clarity is a DOM/UI framework,
5
+ * not a WebGL engine — but DOM games and animations are perfectly doable, and
6
+ * this gives them a clean fixed/variable timestep loop that integrates with the
7
+ * component lifecycle.
8
+ *
9
+ * import { createGameLoop, useRaf } from '@ozsarman/clarityjs/game-loop';
10
+ *
11
+ * const loop = createGameLoop({
12
+ * fps: 60, // fixed timestep (0 = uncapped, native rAF)
13
+ * update: (dt) => { ... }, // dt in seconds
14
+ * render: () => { ... }, // optional, after each update batch
15
+ * });
16
+ * loop.start(); loop.stop(); loop.toggle();
17
+ *
18
+ * // Inside a component — auto-stops on unmount:
19
+ * useRaf((dt) => { x += speed * dt; });
20
+ *
21
+ * Author: Claude (Anthropic) + Özdemir Şarman
22
+ */
23
+
24
+ import { onCleanup } from './runtime.js';
25
+
26
+ const _raf = (cb) =>
27
+ (typeof globalThis.requestAnimationFrame === 'function')
28
+ ? globalThis.requestAnimationFrame(cb)
29
+ : setTimeout(() => cb(Date.now()), 16);
30
+
31
+ const _caf = (id) =>
32
+ (typeof globalThis.cancelAnimationFrame === 'function')
33
+ ? globalThis.cancelAnimationFrame(id)
34
+ : clearTimeout(id);
35
+
36
+ /**
37
+ * Create a game loop.
38
+ * @param {object} opts
39
+ * @param {(dt:number)=>void} [opts.update] per-step callback, dt in seconds
40
+ * @param {()=>void} [opts.render] called once after the update(s) each frame
41
+ * @param {number} [opts.fps=0] fixed timestep target; 0 = uncapped
42
+ * @param {number} [opts.maxStepsPerFrame=5] clamp to avoid spiral-of-death
43
+ * @returns {{ start, stop, toggle, isRunning }}
44
+ */
45
+ export function createGameLoop(opts = {}) {
46
+ const { update, render, fps = 0, maxStepsPerFrame = 5 } = opts;
47
+ const step = fps > 0 ? 1 / fps : 0; // seconds per fixed step
48
+ let rafId = null;
49
+ let last = 0;
50
+ let acc = 0;
51
+ let running = false;
52
+
53
+ function frame(now) {
54
+ if (!running) return;
55
+ const dt = last ? (now - last) / 1000 : 0;
56
+ last = now;
57
+
58
+ if (step > 0) {
59
+ acc += dt;
60
+ let steps = 0;
61
+ while (acc >= step && steps < maxStepsPerFrame) {
62
+ if (update) update(step);
63
+ acc -= step;
64
+ steps++;
65
+ }
66
+ if (steps === maxStepsPerFrame) acc = 0; // dropped frames — resync
67
+ } else if (update) {
68
+ update(dt);
69
+ }
70
+
71
+ if (render) render();
72
+ rafId = _raf(frame);
73
+ }
74
+
75
+ return {
76
+ start() {
77
+ if (running) return;
78
+ running = true;
79
+ last = 0;
80
+ acc = 0;
81
+ rafId = _raf(frame);
82
+ },
83
+ stop() {
84
+ running = false;
85
+ if (rafId != null) _caf(rafId);
86
+ rafId = null;
87
+ },
88
+ toggle() { this.isRunning() ? this.stop() : this.start(); },
89
+ isRunning() { return running; },
90
+ };
91
+ }
92
+
93
+ /**
94
+ * Run a callback every animation frame, auto-stopping when the surrounding
95
+ * component unmounts. Returns the loop so you can stop/toggle it manually too.
96
+ *
97
+ * @param {(dt:number)=>void} fn per-frame callback, dt in seconds
98
+ * @param {object} [opts] same options as createGameLoop (minus update)
99
+ * @returns {{ start, stop, toggle, isRunning }}
100
+ */
101
+ export function useRaf(fn, opts = {}) {
102
+ const loop = createGameLoop({ ...opts, update: fn });
103
+ loop.start();
104
+ try { onCleanup(() => loop.stop()); } catch { /* outside a component — caller owns lifecycle */ }
105
+ return loop;
106
+ }