unit.gl 0.2.3 → 0.3.1

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 (41) hide show
  1. package/README.md +329 -1
  2. package/css/unit.gl.css +35819 -6
  3. package/css/unit.gl.docs.css +4156 -0
  4. package/css/unit.gl.docs.min.css +2 -0
  5. package/css/unit.gl.min.css +1 -1
  6. package/js/unit.gl.demo.js +708 -0
  7. package/js/unit.gl.demo.js.map +1 -0
  8. package/js/unit.gl.js +25 -0
  9. package/js/unit.gl.js.map +1 -1
  10. package/package.json +16 -3
  11. package/scss/classes/_docs.scss +4690 -0
  12. package/scss/classes/_index.scss +1 -0
  13. package/scss/classes/_utilities.scss +1488 -0
  14. package/scss/docs.scss +11 -0
  15. package/scss/formats.scss +27 -0
  16. package/scss/functions/_density.scss +311 -0
  17. package/scss/functions/_index.scss +3 -0
  18. package/scss/functions/_scale.scss +211 -1
  19. package/scss/guide.scss +22 -0
  20. package/scss/maps/_density.scss +141 -0
  21. package/scss/maps/_device.scss +13 -20
  22. package/scss/maps/_index.scss +6 -0
  23. package/scss/maps/_scale.scss +47 -4
  24. package/scss/mixins/_device.scss +1 -3
  25. package/scss/mixins/_display.scss +256 -0
  26. package/scss/mixins/_format.scss +75 -0
  27. package/scss/mixins/_index.scss +2 -1
  28. package/scss/mixins/_unit.scss +115 -6
  29. package/scss/mixins/_utilities.scss +303 -0
  30. package/scss/mixins/_view.scss +41 -11
  31. package/scss/tags/_global.scss +0 -3
  32. package/scss/tags/_unit.scss +1 -3
  33. package/scss/utilities.scss +20 -0
  34. package/scss/variables/_format.scss +80 -0
  35. package/scss/variables/_index.scss +4 -0
  36. package/scss/variables/_scale.scss +434 -63
  37. package/scss/variables/_view.scss +222 -64
  38. package/ts/demo.ts +889 -0
  39. package/ts/index.d.ts +72 -0
  40. package/ts/index.ts +45 -0
  41. package/scss/mixins/_paper.scss +0 -52
package/ts/demo.ts ADDED
@@ -0,0 +1,889 @@
1
+ /**
2
+ * Demo Site JavaScript
3
+ * ====================
4
+ *
5
+ * This file contains all JavaScript functionality for the unit.gl demo/docs site.
6
+ * It is separate from the library code and should be loaded after unit.gl.js.
7
+ */
8
+
9
+ // ============================================================================
10
+ // Utility Functions
11
+ // ============================================================================
12
+
13
+ /**
14
+ * Shorthand for matchMedia queries
15
+ */
16
+ function mq(query: string): boolean {
17
+ try {
18
+ return window.matchMedia(query).matches;
19
+ } catch (_) {
20
+ return false;
21
+ }
22
+ }
23
+
24
+ /**
25
+ * Set text content of an element by ID
26
+ */
27
+ function setText(id: string, value: string | number): void {
28
+ const el = document.getElementById(id);
29
+ if (el) el.textContent = String(value);
30
+ }
31
+
32
+ /**
33
+ * Format number with specified decimal places
34
+ */
35
+ function fmt(n: number, digits = 2): string {
36
+ return (Math.round(n * (10 ** digits)) / (10 ** digits)).toFixed(digits);
37
+ }
38
+
39
+ /**
40
+ * Get viewport dimensions
41
+ */
42
+ function getViewport(): { width: number; height: number } {
43
+ const width = Math.max(document.documentElement.clientWidth || 0, window.innerWidth || 0);
44
+ const height = Math.max(document.documentElement.clientHeight || 0, window.innerHeight || 0);
45
+ return { width, height };
46
+ }
47
+
48
+ // ============================================================================
49
+ // Theme Toggle (base.html.jinja)
50
+ // ============================================================================
51
+
52
+ function initThemeToggle(): void {
53
+ const themeToggle = document.querySelector('[data-toggle="theme"]');
54
+ const html = document.documentElement;
55
+
56
+ // Check for saved theme or system preference
57
+ const savedTheme = localStorage.getItem('theme');
58
+ const systemPrefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
59
+
60
+ if (savedTheme) {
61
+ html.setAttribute('data-theme', savedTheme);
62
+ } else if (systemPrefersDark) {
63
+ html.setAttribute('data-theme', 'dark');
64
+ }
65
+
66
+ themeToggle?.addEventListener('click', function () {
67
+ const currentTheme = html.getAttribute('data-theme');
68
+ const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
69
+ html.setAttribute('data-theme', newTheme);
70
+ localStorage.setItem('theme', newTheme);
71
+ });
72
+ }
73
+
74
+ // ============================================================================
75
+ // Grid Toggle (base.html.jinja)
76
+ // ============================================================================
77
+
78
+ function initGridToggle(): void {
79
+ document.querySelectorAll('.grid-controls button').forEach(btn => {
80
+ btn.addEventListener('click', function (this: HTMLButtonElement) {
81
+ const gridType = this.dataset.toggle;
82
+ const grid = document.querySelector(`[data-grid="${gridType}"]`);
83
+ if (grid) {
84
+ grid.classList.toggle('active');
85
+ this.classList.toggle('active');
86
+ }
87
+ });
88
+ });
89
+ }
90
+
91
+ // ============================================================================
92
+ // Mobile Navigation Toggle
93
+ // ============================================================================
94
+
95
+ function initMobileNav(): void {
96
+ const toggle = document.getElementById('nav-mobile-toggle');
97
+ const menu = document.getElementById('nav-menu');
98
+ const navWrapper = document.querySelector('.nav-wrapper');
99
+
100
+ if (!toggle || !menu) return;
101
+
102
+ toggle.addEventListener('click', function () {
103
+ const isOpen = menu.classList.toggle('is-open');
104
+ navWrapper?.classList.toggle('nav-open', isOpen);
105
+ this.setAttribute('aria-expanded', String(isOpen));
106
+
107
+ // Prevent body scroll when menu is open
108
+ document.body.classList.toggle('nav-open', isOpen);
109
+ });
110
+
111
+ // Close menu when clicking a link
112
+ menu.querySelectorAll('a').forEach(link => {
113
+ link.addEventListener('click', () => {
114
+ menu.classList.remove('is-open');
115
+ navWrapper?.classList.remove('nav-open');
116
+ toggle.setAttribute('aria-expanded', 'false');
117
+ document.body.classList.remove('nav-open');
118
+ });
119
+ });
120
+
121
+ // Close menu on escape
122
+ document.addEventListener('keydown', function (e) {
123
+ if (e.key === 'Escape' && menu.classList.contains('is-open')) {
124
+ menu.classList.remove('is-open');
125
+ navWrapper?.classList.remove('nav-open');
126
+ toggle.setAttribute('aria-expanded', 'false');
127
+ document.body.classList.remove('nav-open');
128
+ toggle.focus();
129
+ }
130
+ });
131
+
132
+ // Close menu when resizing to desktop
133
+ const mediaQuery = window.matchMedia('(min-width: 768px)');
134
+ mediaQuery.addEventListener('change', (e) => {
135
+ if (e.matches && menu.classList.contains('is-open')) {
136
+ menu.classList.remove('is-open');
137
+ navWrapper?.classList.remove('nav-open');
138
+ toggle.setAttribute('aria-expanded', 'false');
139
+ document.body.classList.remove('nav-open');
140
+ }
141
+ });
142
+ }
143
+
144
+ // ============================================================================
145
+ // Dropdown Menu (base.html.jinja)
146
+ // ============================================================================
147
+
148
+ function initDropdown(): void {
149
+ const dropdown = document.getElementById('nav-dropdown');
150
+ const toggle = dropdown?.querySelector('.nav__dropdown-toggle') as HTMLElement | null;
151
+
152
+ toggle?.addEventListener('click', function (e) {
153
+ e.stopPropagation();
154
+ const isOpen = dropdown!.classList.toggle('open');
155
+ this.setAttribute('aria-expanded', String(isOpen));
156
+ });
157
+
158
+ // Close dropdown when clicking outside
159
+ document.addEventListener('click', function (e) {
160
+ if (dropdown && !dropdown.contains(e.target as Node)) {
161
+ dropdown.classList.remove('open');
162
+ toggle?.setAttribute('aria-expanded', 'false');
163
+ }
164
+ });
165
+
166
+ // Close dropdown when pressing Escape
167
+ document.addEventListener('keydown', function (e) {
168
+ if (e.key === 'Escape' && dropdown?.classList.contains('open')) {
169
+ dropdown.classList.remove('open');
170
+ toggle?.setAttribute('aria-expanded', 'false');
171
+ toggle?.focus();
172
+ }
173
+ });
174
+ }
175
+
176
+ // ============================================================================
177
+ // Layers Demo (layers.html.jinja)
178
+ // ============================================================================
179
+
180
+ function initLayersDemo(): void {
181
+ // Expose functions globally for onclick handlers
182
+ (window as any).toggleLayer = function (id: string): void {
183
+ const layer = document.getElementById('layer-' + id);
184
+ const btn = document.getElementById('btn-' + id);
185
+
186
+ layer?.classList.toggle('layer-box--hidden');
187
+ btn?.classList.toggle('layer-btn--active');
188
+ };
189
+
190
+ (window as any).showAll = function (): void {
191
+ document.querySelectorAll('.layer-box').forEach(el => {
192
+ el.classList.remove('layer-box--hidden');
193
+ });
194
+ document.querySelectorAll('.layer-btn').forEach(el => {
195
+ if (el.id.startsWith('btn-')) {
196
+ el.classList.add('layer-btn--active');
197
+ }
198
+ });
199
+ };
200
+
201
+ (window as any).hideAll = function (): void {
202
+ document.querySelectorAll('.layer-box').forEach(el => {
203
+ el.classList.add('layer-box--hidden');
204
+ });
205
+ document.querySelectorAll('.layer-btn').forEach(el => {
206
+ if (el.id.startsWith('btn-')) {
207
+ el.classList.remove('layer-btn--active');
208
+ }
209
+ });
210
+ };
211
+ }
212
+
213
+ // ============================================================================
214
+ // Device Detection Demo (test_device.html.jinja)
215
+ // ============================================================================
216
+
217
+ function initDeviceDemo(): void {
218
+ if (!document.getElementById('dev-width')) return;
219
+
220
+ function classifyOrientation(width: number, height: number): string {
221
+ if (mq('(orientation: portrait)')) return 'portrait';
222
+ if (mq('(orientation: landscape)')) return 'landscape';
223
+ return width >= height ? 'landscape' : 'portrait';
224
+ }
225
+
226
+ function getColorScheme(): string {
227
+ if (mq('(prefers-color-scheme: dark)')) return 'dark';
228
+ if (mq('(prefers-color-scheme: light)')) return 'light';
229
+ return 'no-preference';
230
+ }
231
+
232
+ function getReducedMotion(): string {
233
+ if (mq('(prefers-reduced-motion: reduce)')) return 'reduce';
234
+ if (mq('(prefers-reduced-motion: no-preference)')) return 'no-preference';
235
+ return 'unknown';
236
+ }
237
+
238
+ function getPrefersContrast(): string {
239
+ if (mq('(prefers-contrast: more)')) return 'more';
240
+ if (mq('(prefers-contrast: less)')) return 'less';
241
+ if (mq('(prefers-contrast: custom)')) return 'custom';
242
+ if (mq('(prefers-contrast: no-preference)')) return 'no-preference';
243
+ return 'unknown';
244
+ }
245
+
246
+ function getForcedColors(): string {
247
+ if (mq('(forced-colors: active)')) return 'active';
248
+ if (mq('(forced-colors: none)')) return 'none';
249
+ return 'unknown';
250
+ }
251
+
252
+ function getHover(): string {
253
+ if (mq('(hover: hover)')) return 'hover';
254
+ if (mq('(hover: none)')) return 'none';
255
+ return 'unknown';
256
+ }
257
+
258
+ function getPointer(): string {
259
+ if (mq('(pointer: fine)')) return 'fine';
260
+ if (mq('(pointer: coarse)')) return 'coarse';
261
+ if (mq('(pointer: none)')) return 'none';
262
+ return 'unknown';
263
+ }
264
+
265
+ function getAnyHover(): string {
266
+ if (mq('(any-hover: hover)')) return 'hover';
267
+ if (mq('(any-hover: none)')) return 'none';
268
+ return 'unknown';
269
+ }
270
+
271
+ function getAnyPointer(): string {
272
+ if (mq('(any-pointer: fine)')) return 'fine';
273
+ if (mq('(any-pointer: coarse)')) return 'coarse';
274
+ if (mq('(any-pointer: none)')) return 'none';
275
+ return 'unknown';
276
+ }
277
+
278
+ function getColorGamut(): string {
279
+ if (mq('(color-gamut: rec2020)')) return 'rec2020';
280
+ if (mq('(color-gamut: p3)')) return 'p3';
281
+ if (mq('(color-gamut: srgb)')) return 'srgb';
282
+ return 'unknown';
283
+ }
284
+
285
+ function getDisplayMode(): string {
286
+ if (mq('(display-mode: fullscreen)')) return 'fullscreen';
287
+ if (mq('(display-mode: standalone)')) return 'standalone';
288
+ if (mq('(display-mode: minimal-ui)')) return 'minimal-ui';
289
+ if (mq('(display-mode: browser)')) return 'browser';
290
+ return 'unknown';
291
+ }
292
+
293
+ function getReducedTransparency(): string {
294
+ if (mq('(prefers-reduced-transparency: reduce)')) return 'reduce';
295
+ if (mq('(prefers-reduced-transparency: no-preference)')) return 'no-preference';
296
+ return 'unknown';
297
+ }
298
+
299
+ function getReducedData(): string {
300
+ if (mq('(prefers-reduced-data: reduce)')) return 'reduce';
301
+ if (mq('(prefers-reduced-data: no-preference)')) return 'no-preference';
302
+ return 'unknown';
303
+ }
304
+
305
+ function scoreDeviceMatch(
306
+ { width, dpr }: { width: number; dpr: number },
307
+ row: HTMLElement
308
+ ): { match: boolean; label: string } {
309
+ const min = Number(row.dataset.min || '0');
310
+ const max = Number(row.dataset.max || '0');
311
+ const targetDpr = Number(row.dataset.dpr || '1');
312
+
313
+ const inRange = width >= min && width <= max;
314
+ const dprDelta = Math.abs((dpr || 1) - targetDpr);
315
+ const dprOk = dprDelta <= 0.6;
316
+
317
+ if (!inRange) return { match: false, label: '—' };
318
+ if (!dprOk) return { match: true, label: 'width' };
319
+ return { match: true, label: 'width + dpr' };
320
+ }
321
+
322
+ function updateUI(): void {
323
+ const { width, height } = getViewport();
324
+ const dpr = Number(window.devicePixelRatio || 1);
325
+
326
+ setText('dev-width', width);
327
+ setText('dev-height', height);
328
+ setText('dev-dpr', dpr.toFixed(2));
329
+ setText('dev-orientation', classifyOrientation(width, height));
330
+ setText('dev-touch', (navigator.maxTouchPoints || 0));
331
+
332
+ setText('mf-hover', getHover());
333
+ setText('mf-pointer', getPointer());
334
+ setText('mf-any-hover', getAnyHover());
335
+ setText('mf-any-pointer', getAnyPointer());
336
+
337
+ setText('mf-scheme', getColorScheme());
338
+ setText('mf-motion', getReducedMotion());
339
+ setText('mf-contrast', getPrefersContrast());
340
+ setText('mf-forced', getForcedColors());
341
+
342
+ setText('mf-gamut', getColorGamut());
343
+ setText('mf-display', getDisplayMode());
344
+ setText('mf-transparency', getReducedTransparency());
345
+ setText('mf-data', getReducedData());
346
+
347
+ const filterEl = document.getElementById('dev-filter') as HTMLInputElement | null;
348
+ const filterValue = String(filterEl?.value || '').trim().toLowerCase();
349
+
350
+ let matches = 0;
351
+ document.querySelectorAll<HTMLElement>('.device-row').forEach(row => {
352
+ const key = String(row.dataset.key || '').toLowerCase();
353
+ const visible = !filterValue || key.includes(filterValue);
354
+ row.classList.toggle('is-filtered', !visible);
355
+
356
+ const badge = row.querySelector('[data-role="match"]');
357
+ if (!visible) {
358
+ row.classList.remove('is-match');
359
+ if (badge) badge.textContent = '—';
360
+ return;
361
+ }
362
+
363
+ const result = scoreDeviceMatch({ width, dpr }, row);
364
+ row.classList.toggle('is-match', result.match);
365
+ if (badge) badge.textContent = result.label;
366
+ if (result.match) matches += 1;
367
+ });
368
+
369
+ setText('dev-matches', matches);
370
+ }
371
+
372
+ window.addEventListener('resize', updateUI);
373
+ window.addEventListener('orientationchange', updateUI);
374
+
375
+ const filterEl = document.getElementById('dev-filter');
376
+ if (filterEl) filterEl.addEventListener('input', updateUI);
377
+
378
+ updateUI();
379
+ }
380
+
381
+ // ============================================================================
382
+ // Q Scale Demo (test_qscale.html.jinja)
383
+ // ============================================================================
384
+
385
+ function initQScaleDemo(): void {
386
+ if (!document.getElementById('qs-root')) return;
387
+
388
+ const qSteps = [0, 1, 2, 4, 6, 8, 12, 16, 20, 24, 32, 40, 48, 56, 64, 72, 80, 96, 112, 128, 144, 160, 192, 224, 256];
389
+ const slider = document.getElementById('qs-range') as HTMLInputElement | null;
390
+
391
+ function rootPx(): number {
392
+ const raw = getComputedStyle(document.documentElement).fontSize;
393
+ const value = Number.parseFloat(raw);
394
+ return Number.isFinite(value) ? value : 16;
395
+ }
396
+
397
+ function toPx(step: number): number {
398
+ return (step * rootPx()) / 16;
399
+ }
400
+
401
+ function toRem(step: number): number {
402
+ return step / 16;
403
+ }
404
+
405
+ function setStep(step: number): void {
406
+ const stepNum = Number(step);
407
+ const px = toPx(stepNum);
408
+ const rem = toRem(stepNum);
409
+
410
+ setText('qs-root', fmt(rootPx(), 2));
411
+ setText('qs-step', stepNum);
412
+ setText('qs-px', fmt(px, 2));
413
+ setText('qs-rem', fmt(rem, 4));
414
+
415
+ const className = `.p_q${stepNum}`;
416
+ const classEl = document.getElementById('qs-class');
417
+ if (classEl) classEl.textContent = className;
418
+
419
+ // Visual: box size responds to the step
420
+ const size = Math.max(8, px);
421
+ const pad = Math.max(2, px / 4);
422
+ const gap = Math.max(2, px / 6);
423
+
424
+ const box = document.getElementById('qs-box');
425
+ if (box) {
426
+ box.style.width = `${size}px`;
427
+ box.style.height = `${size}px`;
428
+ box.style.padding = `${pad}px`;
429
+ box.style.gap = `${gap}px`;
430
+ }
431
+
432
+ setText('qs-w', `${fmt(size, 0)}px`);
433
+ setText('qs-h', `${fmt(size, 0)}px`);
434
+ setText('qs-pad', `${fmt(pad, 0)}px`);
435
+ setText('qs-gap', `${fmt(gap, 0)}px`);
436
+
437
+ setText('qs-u-step', stepNum);
438
+ setText('qs-u-step2', stepNum);
439
+ setText('qs-u-step3', stepNum);
440
+ setText('qs-u-step4', stepNum);
441
+ setText('qs-u-step5', stepNum);
442
+
443
+ // Update slider position
444
+ if (slider) {
445
+ const sliderIndex = qSteps.indexOf(stepNum);
446
+ if (sliderIndex >= 0) {
447
+ slider.value = String(sliderIndex);
448
+ }
449
+ }
450
+
451
+ // UI state - update chip active states
452
+ document.querySelectorAll<HTMLElement>('.qscale-chip').forEach(btn => {
453
+ btn.classList.toggle('active', Number(btn.dataset.step) === stepNum);
454
+ });
455
+ }
456
+
457
+ // Slider event listener
458
+ if (slider) {
459
+ slider.addEventListener('input', (e) => {
460
+ const index = parseInt((e.target as HTMLInputElement).value);
461
+ if (index >= 0 && index < qSteps.length) {
462
+ setStep(qSteps[index]);
463
+ }
464
+ });
465
+ }
466
+
467
+ // Chip click handlers (for chips defined in template)
468
+ document.querySelectorAll<HTMLElement>('.qscale-chip').forEach(chip => {
469
+ chip.addEventListener('click', () => {
470
+ const step = parseInt(chip.dataset.step || '16');
471
+ setStep(step);
472
+ });
473
+ });
474
+
475
+ // Initialize chips dynamically if container exists
476
+ const chipRow = document.getElementById('qscale-chip-row');
477
+ if (chipRow) {
478
+ qSteps.forEach(step => {
479
+ const btn = document.createElement('button');
480
+ btn.className = 'qscale-chip' + (step === 16 ? ' active' : '');
481
+ btn.textContent = String(step);
482
+ btn.dataset.step = String(step);
483
+ btn.addEventListener('click', () => setStep(step));
484
+ chipRow.appendChild(btn);
485
+ });
486
+ }
487
+
488
+ // Grid visualization for qscale page
489
+ function createQScaleGrid(canvasId: string, scale: number, className: string): void {
490
+ const canvas = document.getElementById(canvasId);
491
+ if (!canvas) return;
492
+
493
+ canvas.innerHTML = '';
494
+ const width = canvas.offsetWidth;
495
+
496
+ for (let x = scale; x < width; x += scale) {
497
+ const line = document.createElement('div');
498
+ line.className = 'grid-line ' + className;
499
+
500
+ // Mark LCM alignment points
501
+ if (x % 20 === 0) {
502
+ line.classList.add('lcm');
503
+ }
504
+
505
+ line.style.left = x + 'px';
506
+ canvas.appendChild(line);
507
+ }
508
+ }
509
+
510
+ // Initialize grids if they exist on this page
511
+ createQScaleGrid('qs-type-grid', 4, 'type');
512
+ createQScaleGrid('qs-line-grid', 5, 'line-scale');
513
+
514
+ // Combined grid showing only LCM points
515
+ const combinedCanvas = document.getElementById('qs-combined-grid');
516
+ if (combinedCanvas) {
517
+ combinedCanvas.innerHTML = '';
518
+ const width = combinedCanvas.offsetWidth;
519
+ for (let x = 20; x < width; x += 20) {
520
+ const line = document.createElement('div');
521
+ line.className = 'grid-line lcm';
522
+ line.style.left = x + 'px';
523
+ combinedCanvas.appendChild(line);
524
+ }
525
+ }
526
+
527
+ // Initial render
528
+ setStep(16);
529
+ }
530
+
531
+ // ============================================================================
532
+ // Density Demo (density.html.jinja)
533
+ // ============================================================================
534
+
535
+ function initDensityDemo(): void {
536
+ if (!document.getElementById('current-dpr')) return;
537
+
538
+ function updateDeviceInfo(): void {
539
+ const dpr = window.devicePixelRatio || 1;
540
+ const dpi = Math.round(dpr * 96);
541
+
542
+ setText('current-dpr', dpr.toFixed(2) + '×');
543
+ setText('current-dpi', dpi + 'dpi');
544
+
545
+ // Determine bucket
546
+ let bucket = 'mdpi';
547
+ if (dpr >= 4) bucket = 'xxxhdpi';
548
+ else if (dpr >= 3) bucket = 'xxhdpi';
549
+ else if (dpr >= 2) bucket = 'xhdpi';
550
+ else if (dpr >= 1.5) bucket = 'hdpi';
551
+ else if (dpr < 1) bucket = 'ldpi';
552
+
553
+ setText('current-bucket', bucket);
554
+ }
555
+
556
+ function updateCalculator(): void {
557
+ const input = document.getElementById('calc-q') as HTMLInputElement | null;
558
+ const q = parseFloat(input?.value || '0') || 0;
559
+ const mm = q * 0.25;
560
+ const inches = mm / 25.4;
561
+ const pt = mm / 0.3528;
562
+ const rem = q * 0.0625;
563
+
564
+ setText('calc-mm', mm.toFixed(2) + 'mm');
565
+ setText('calc-in', inches.toFixed(3) + 'in');
566
+ setText('calc-pt', pt.toFixed(2) + 'pt');
567
+ setText('calc-rem', rem.toFixed(4) + 'rem');
568
+ setText('calc-px1', q + 'px');
569
+ setText('calc-px2', (q * 2) + 'px');
570
+ setText('calc-px3', (q * 3) + 'px');
571
+ }
572
+
573
+ const calcInput = document.getElementById('calc-q');
574
+ calcInput?.addEventListener('input', updateCalculator);
575
+
576
+ // Initialize
577
+ updateDeviceInfo();
578
+ updateCalculator();
579
+
580
+ // Update on DPR change
581
+ if (window.matchMedia) {
582
+ const checkDPR = (): void => {
583
+ const mqQuery = window.matchMedia(`(resolution: ${window.devicePixelRatio}dppx)`);
584
+ mqQuery.addEventListener('change', () => {
585
+ updateDeviceInfo();
586
+ checkDPR();
587
+ }, { once: true });
588
+ };
589
+ checkDPR();
590
+ }
591
+ }
592
+
593
+ // ============================================================================
594
+ // Breakpoints Demo (test_breakpoints.html.jinja)
595
+ // ============================================================================
596
+
597
+ function initBreakpointsDemo(): void {
598
+ if (!document.getElementById('bp-width')) return;
599
+
600
+ const breakpoints = [
601
+ { key: 'ul', min: 4320 },
602
+ { key: 'xl', min: 2880 },
603
+ { key: 'lg', min: 2160 },
604
+ { key: 'md', min: 1440 },
605
+ { key: 'sm', min: 720 },
606
+ { key: 'xs', min: 540 },
607
+ { key: 'ss', min: 360 },
608
+ { key: 'us', min: 240 },
609
+ ];
610
+
611
+ function updateBreakpointUI(): void {
612
+ const width = Math.max(document.documentElement.clientWidth || 0, window.innerWidth || 0);
613
+ const active = breakpoints.find(bp => width >= bp.min) || breakpoints[breakpoints.length - 1];
614
+
615
+ setText('bp-width', String(width));
616
+ setText('bp-active', active.key);
617
+ setText('bp-rule', `(min-width: ${active.min}px)`);
618
+
619
+ document.querySelectorAll<HTMLElement>('.bp-row').forEach(row => {
620
+ row.classList.toggle('active', row.dataset.bp === active.key);
621
+ });
622
+ }
623
+
624
+ window.addEventListener('resize', updateBreakpointUI);
625
+ updateBreakpointUI();
626
+ }
627
+
628
+ // ============================================================================
629
+ // Paper Demo (test_paper.html.jinja)
630
+ // ============================================================================
631
+
632
+ function initPaperDemo(): void {
633
+ const select = document.getElementById('paper-format') as HTMLSelectElement | null;
634
+ const preview = document.getElementById('paper-preview') as HTMLElement | null;
635
+ const container = document.getElementById('paper-preview-container') as HTMLElement | null;
636
+ const title = document.getElementById('paper-title');
637
+ const outW = document.getElementById('paper-w');
638
+ const outH = document.getElementById('paper-h');
639
+ const code = document.getElementById('paper-code');
640
+ const codeOr = document.getElementById('paper-code-or');
641
+ const scaleDisplay = document.getElementById('scale-display');
642
+
643
+ if (!select || !preview) return;
644
+
645
+ let orientation = 'portrait';
646
+
647
+ // Reference size for scaling (A0 is the largest common format)
648
+ const maxRefSize = 1189; // A0 height in mm
649
+ const containerMaxPx = 400; // Max pixel size for preview
650
+
651
+ function currentDims(): { key: string; w: number; h: number } {
652
+ const opt = select?.selectedOptions?.[0];
653
+ if (!opt) return { key: 'q04', w: 180, h: 270 };
654
+
655
+ const key = opt.value;
656
+ const w = Number((opt as HTMLOptionElement).dataset.w);
657
+ const h = Number((opt as HTMLOptionElement).dataset.h);
658
+ return { key, w, h };
659
+ }
660
+
661
+ function apply(): void {
662
+ if (!preview || !select || !container) return;
663
+
664
+ const { key, w, h } = currentDims();
665
+ const pw = orientation === 'landscape' ? h : w;
666
+ const ph = orientation === 'landscape' ? w : h;
667
+
668
+ // Calculate scale factor based on container size
669
+ const maxDim = Math.max(pw, ph);
670
+ const scale = containerMaxPx / maxRefSize;
671
+ const scaledW = pw * scale;
672
+ const scaledH = ph * scale;
673
+
674
+ // Apply actual pixel dimensions
675
+ preview.style.width = `${scaledW}px`;
676
+ preview.style.height = `${scaledH}px`;
677
+ preview.style.aspectRatio = 'auto';
678
+
679
+ // Update scale indicator
680
+ const displayScale = (maxRefSize / maxDim).toFixed(1);
681
+ if (scaleDisplay) scaleDisplay.textContent = `Scale: 1:${displayScale}`;
682
+
683
+ if (title) title.textContent = key;
684
+ if (outW) outW.textContent = String(pw);
685
+ if (outH) outH.textContent = String(ph);
686
+ if (code) code.textContent = key;
687
+ if (codeOr) codeOr.textContent = orientation;
688
+ }
689
+
690
+ document.querySelectorAll<HTMLElement>('.paper-orient__btn').forEach(btn => {
691
+ btn.addEventListener('click', () => {
692
+ orientation = btn.dataset.orient || 'portrait';
693
+ document.querySelectorAll('.paper-orient__btn').forEach(b => b.classList.toggle('active', b === btn));
694
+ apply();
695
+ });
696
+ });
697
+
698
+ select.addEventListener('change', apply);
699
+ apply();
700
+
701
+ // Comparison stacks - render sheets at relative scale
702
+ function renderComparisonStacks(): void {
703
+ const scaleFactor = 0.5; // pixels per mm
704
+
705
+ document.querySelectorAll<HTMLElement>('.comparison-sheet').forEach(sheet => {
706
+ const w = Number(sheet.dataset.w || 0);
707
+ const h = Number(sheet.dataset.h || 0);
708
+ sheet.style.width = `${w * scaleFactor}px`;
709
+ sheet.style.height = `${h * scaleFactor}px`;
710
+ });
711
+ }
712
+
713
+ renderComparisonStacks();
714
+ }
715
+
716
+ // ============================================================================
717
+ // Baseline Grid Demo (guide_baseline.html.jinja)
718
+ // ============================================================================
719
+
720
+ function initBaselineDemo(): void {
721
+ // Expose toggle function globally
722
+ (window as any).toggleBaseline = function (): void {
723
+ document.querySelectorAll('.guide--baseline, .guide--baseline_custom').forEach(el => {
724
+ el.classList.toggle('guide--baseline--hidden');
725
+ });
726
+ };
727
+ }
728
+
729
+ // ============================================================================
730
+ // Hybrid Scale Demo (test_hybrid_scale.html.jinja)
731
+ // ============================================================================
732
+
733
+ function initHybridScaleDemo(): void {
734
+ const slider = document.getElementById('scaleSlider') as HTMLInputElement | null;
735
+ const sliderValue = document.getElementById('sliderValue');
736
+ const chips = document.querySelectorAll<HTMLElement>('.chip');
737
+ const previewBox = document.getElementById('previewBox');
738
+
739
+ if (!slider) return;
740
+
741
+ // Metric elements
742
+ const typeQ = document.getElementById('typeQ');
743
+ const typePx = document.getElementById('typePx');
744
+ const typeMm = document.getElementById('typeMm');
745
+ const typeRem = document.getElementById('typeRem');
746
+
747
+ const lineQ = document.getElementById('lineQ');
748
+ const linePx = document.getElementById('linePx');
749
+ const lineMm = document.getElementById('lineMm');
750
+ const lineRem = document.getElementById('lineRem');
751
+
752
+ const lcmValue = document.getElementById('lcmValue');
753
+ const lcmMultiple = document.getElementById('lcmMultiple');
754
+ const lcmAligned = document.getElementById('lcmAligned');
755
+
756
+ function updateScale(value: number): void {
757
+ const typeVal = value * 4;
758
+ const lineVal = value * 5;
759
+ const lcmVal = Math.ceil(Math.max(typeVal, lineVal) / 20) * 20;
760
+ const isAligned = typeVal % 20 === 0 && lineVal % 20 === 0;
761
+
762
+ // Update slider
763
+ slider!.value = String(value);
764
+ if (sliderValue) sliderValue.textContent = String(value);
765
+
766
+ // Update chips
767
+ chips.forEach(chip => {
768
+ chip.classList.toggle('active', parseInt(chip.dataset.value || '0') === value);
769
+ });
770
+
771
+ // Update metrics
772
+ if (typeQ) typeQ.textContent = typeVal + 'Q';
773
+ if (typePx) typePx.textContent = typeVal + 'px';
774
+ if (typeMm) typeMm.textContent = (typeVal / 4).toFixed(1) + 'mm';
775
+ if (typeRem) typeRem.textContent = (typeVal / 16).toFixed(3) + 'rem';
776
+
777
+ if (lineQ) lineQ.textContent = lineVal + 'Q';
778
+ if (linePx) linePx.textContent = lineVal + 'px';
779
+ if (lineMm) lineMm.textContent = (lineVal / 4).toFixed(2) + 'mm';
780
+ if (lineRem) lineRem.textContent = (lineVal / 16).toFixed(3) + 'rem';
781
+
782
+ if (lcmValue) lcmValue.textContent = lcmVal + 'Q';
783
+ if (lcmMultiple) lcmMultiple.textContent = (lcmVal / 20) + '× LCM';
784
+
785
+ if (lcmAligned) {
786
+ if (typeVal === lineVal && typeVal % 20 === 0) {
787
+ lcmAligned.textContent = '✓ Perfect Alignment';
788
+ lcmAligned.classList.add('aligned');
789
+ } else {
790
+ lcmAligned.textContent = 'Next: ' + lcmVal + 'Q';
791
+ lcmAligned.classList.remove('aligned');
792
+ }
793
+ }
794
+
795
+ // Update preview box
796
+ if (previewBox) {
797
+ const size = Math.min(Math.max(typeVal * 2, 40), 200);
798
+ previewBox.style.width = size + 'px';
799
+ previewBox.style.height = size + 'px';
800
+ }
801
+ }
802
+
803
+ // Event listeners
804
+ slider.addEventListener('input', (e) => updateScale(parseInt((e.target as HTMLInputElement).value)));
805
+
806
+ chips.forEach(chip => {
807
+ chip.addEventListener('click', () => {
808
+ updateScale(parseInt(chip.dataset.value || '4'));
809
+ });
810
+ });
811
+
812
+ // Initialize
813
+ updateScale(4);
814
+
815
+ // Grid visualization
816
+ function createGrid(canvasId: string, scale: number, className: string, showLCM = false): void {
817
+ const canvas = document.getElementById(canvasId);
818
+ if (!canvas) return;
819
+
820
+ canvas.innerHTML = '';
821
+ const width = canvas.offsetWidth;
822
+
823
+ for (let x = scale; x < width; x += scale) {
824
+ const line = document.createElement('div');
825
+ line.className = 'grid-line ' + className;
826
+
827
+ if (showLCM && x % 20 === 0) {
828
+ line.classList.add('lcm');
829
+ }
830
+
831
+ line.style.left = x + 'px';
832
+ canvas.appendChild(line);
833
+ }
834
+ }
835
+
836
+ function createCombinedGrid(canvasId: string): void {
837
+ const canvas = document.getElementById(canvasId);
838
+ if (!canvas) return;
839
+
840
+ canvas.innerHTML = '';
841
+ const width = canvas.offsetWidth;
842
+
843
+ // Add LCM lines (20Q intervals)
844
+ for (let x = 20; x < width; x += 20) {
845
+ const line = document.createElement('div');
846
+ line.className = 'grid-line lcm';
847
+ line.style.left = x + 'px';
848
+ canvas.appendChild(line);
849
+ }
850
+ }
851
+
852
+ // Initialize grid visualizations if containers exist
853
+ function initGrids(): void {
854
+ createGrid('type-grid-canvas', 4, 'type-line', false);
855
+ createGrid('line-grid-canvas', 5, 'line-line', false);
856
+ createCombinedGrid('combined-grid-canvas');
857
+ }
858
+
859
+ initGrids();
860
+
861
+ // Redraw grids on window resize
862
+ let resizeTimeout: ReturnType<typeof setTimeout>;
863
+ window.addEventListener('resize', () => {
864
+ clearTimeout(resizeTimeout);
865
+ resizeTimeout = setTimeout(initGrids, 150);
866
+ });
867
+ }
868
+
869
+ // ============================================================================
870
+ // Initialize All Demos
871
+ // ============================================================================
872
+
873
+ document.addEventListener('DOMContentLoaded', function () {
874
+ // Core functionality (always runs)
875
+ initThemeToggle();
876
+ initGridToggle();
877
+ initMobileNav();
878
+ initDropdown();
879
+
880
+ // Page-specific demos (only run if relevant elements exist)
881
+ initLayersDemo();
882
+ initDeviceDemo();
883
+ initQScaleDemo();
884
+ initDensityDemo();
885
+ initBreakpointsDemo();
886
+ initPaperDemo();
887
+ initBaselineDemo();
888
+ initHybridScaleDemo();
889
+ });