-
Notifications
You must be signed in to change notification settings - Fork 21
Expand file tree
/
Copy pathpages.js
More file actions
578 lines (551 loc) · 21.7 KB
/
Copy pathpages.js
File metadata and controls
578 lines (551 loc) · 21.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
import {
CENTER,
MAX_RADIUS,
SIZE,
arcPath,
layoutRadar,
polar,
sectorPath,
} from './geometry.js'
const escape = (s = '') =>
String(s)
.replaceAll('&', '&')
.replaceAll('<', '<')
.replaceAll('>', '>')
.replaceAll('"', '"')
.replaceAll("'", ''')
/** Inline-runs before the stylesheet — applies saved theme/chroma to <html>
* so we don't flash the default palette on a fresh load. */
const THEME_BOOT = `<script>try{var p=JSON.parse(localStorage.getItem('aurora-prefs')||'{}');var h=document.documentElement;h.dataset.theme=p.theme||'dark';h.dataset.chroma=p.chroma||'color';}catch(e){}</script>`
/** Bare background + foreground colours per theme, applied inline before
* aurora.css loads so the page doesn't flash with the browser default
* white. Duplicated in every page template's <head>. */
const FOUC_STYLE = `<style>html,body{background:#07080d;color:#e6e9f0;margin:0}html[data-theme="light"],html[data-theme="light"] body{background:#f6f7fa;color:#11151c}</style>`
/** Path-safe entry slug: keep the original name (matches zalando backend),
* only strip path separators that would break the directory layout.
* Exported so the renderer entry can share the same definition. */
export const entrySlug = (name) => String(name).replaceAll(/[/\\]/g, '-').trim()
const entryHref = (name) => encodeURIComponent(entrySlug(name))
/** Stable id under which an entry lives on disk + in URLs. Legacy 4×4
* radars carry `quadrant: 'q1..q4'` for backward compat with existing
* deployments; Flex radars use the canonical `sector: 's1..sN'`. */
const sectorUrlId = (entry) => entry.quadrant ?? entry.sector
/**
* Per-radar palette block — emitted once per page, parameterised by the
* radar's sector count and ring count. Two roles per sector:
* • `--s{N}-accent` — strokes, labels, corner titles (legible on the
* current theme: light on dark, dark on light)
* • `--s{N}-fill` — blip body. Mirrors accent EXCEPT on light+color
* where it leans vivid so coloured dots pop off the white canvas.
* Plus per-sector gradient stops + per-ring opacity/fade ramps. Scoped
* to nothing in particular — these are CSS custom properties that
* cascade through the page. Mono mode collapses everything to fg-soft.
*/
const renderPalette = (sectors, rings) => {
const lines = (fn) => sectors.map(fn).join('\n')
const accentDark = lines((s) => ` --${s.id}-accent: hsl(${s.accent} 65% 60%);`)
const accentLight = lines((s) => ` --${s.id}-accent: hsl(${s.accent} 60% 36%);`)
// Fill mirrors accent on dark and light+mono. Light+color overrides
// below with brighter, more saturated values.
const fillDark = lines((s) => ` --${s.id}-fill: hsl(${s.accent} 65% 60%);`)
const fillLightDefault = lines((s) => ` --${s.id}-fill: hsl(${s.accent} 60% 36%);`)
const fillLightColor = lines((s) => ` --${s.id}-fill: hsl(${s.accent} 70% 48%);`)
const gradDark = lines(
(s) => ` --${s.id}-grad-0: hsla(${s.accent}, 65%, 60%, 0.32);
--${s.id}-grad-1: hsla(${s.accent}, 65%, 60%, 0.02);`,
)
const gradLight = lines(
(s) => ` --${s.id}-grad-0: hsla(${s.accent}, 75%, 78%, 0.40);
--${s.id}-grad-1: hsla(${s.accent}, 75%, 78%, 0.02);`,
)
const mono = lines(
(s) => ` --${s.id}-accent: var(--fg-soft);
--${s.id}-fill: var(--fg-soft);
--${s.id}-grad-0: hsla(220, 10%, 50%, 0.10);
--${s.id}-grad-1: hsla(220, 10%, 50%, 0.02);`,
)
// Ring fade — inner ring at full intensity, outermost at ~45%. Step
// adapts to ring count M so every ring stays visible: even a 7-ring
// radar gets its outer ring around 0.45, not invisible. Two ramps:
// • blip opacity: 1.00 → 0.55 across M rings
// • sector fill-opacity: 0.95 → 0.45
const lerp = (lo, hi, t) => lo + (hi - lo) * t
const t = (i) => (rings.length <= 1 ? 0 : i / (rings.length - 1))
const ringOpacity = rings
.map((r, i) => {
const opacity = lerp(1.0, 0.55, t(i))
return `.blip[data-r="${r.id}"] { opacity: ${opacity.toFixed(2)}; }`
})
.join('\n')
const sectorFade = rings
.map((r, i) => {
const fill = lerp(0.95, 0.45, t(i))
return `.sector[data-r="${r.id}"] { fill-opacity: ${fill.toFixed(2)}; }`
})
.join('\n')
return `<style id="radar-palette">
[data-theme="dark"] {
${accentDark}
${fillDark}
${gradDark}
}
[data-theme="light"] {
${accentLight}
${fillLightDefault}
${gradLight}
}
[data-theme="light"][data-chroma="color"] {
${fillLightColor}
}
[data-chroma="mono"] {
${mono}
}
${ringOpacity}
${sectorFade}
</style>`
}
/** Render a single radar SVG (full viewBox). Sectors & rings come from
* the layout result so the SVG matches whatever count was declared. */
const renderSvg = (radar, entries, sectors, rings) => {
// Per-sector radial gradients — each sector glows from centre outward.
// SVG `<stop>` resolves CSS variables more reliably when stop-color is
// set via the `style` attribute (CSS property) rather than the
// `stop-color` presentation attribute — the latter sometimes paints
// black on first frame before the cascade settles.
const defs = sectors
.map(
(s) => `
<radialGradient id="grad-${s.id}" cx="50%" cy="50%" r="50%">
<stop offset="0%" style="stop-color: var(--${s.id}-grad-0)"/>
<stop offset="100%" style="stop-color: var(--${s.id}-grad-1)"/>
</radialGradient>`,
)
.join('')
// Sector annulus fills (one path per (sector × ring) cell). Per-ring
// fill-opacity is driven by CSS (.sector[data-r=...]) — emitted in the
// palette block — so the SVG itself stays count-agnostic.
const cells = sectors
.flatMap((s, sIdx) =>
rings.map(
(r, rIdx) =>
`<path d="${sectorPath(sIdx, rIdx, { sectors, rings })}" fill="url(#grad-${s.id})"
class="sector" data-s="${s.id}" data-r="${r.id}"/>`,
),
)
.join('')
// Ring boundary circles + axes between sectors. Axes are emitted per
// sector boundary (N lines for N sectors) so the cross stays correct
// for non-4 layouts.
const ringCircles = rings
.map(
(r) =>
`<circle cx="${CENTER}" cy="${CENTER}" r="${r.outer}" class="ring-line"/>`,
)
.join('')
const axes = sectors
.map((s) => {
const p = polar(s.start, MAX_RADIUS)
return `<line x1="${CENTER}" y1="${CENTER}" x2="${p.x.toFixed(1)}" y2="${p.y.toFixed(1)}" class="axis"/>`
})
.join('')
// Ring labels — text along an arc near the OUTER edge of each ring,
// centred on top (-π/2). Placing the label closer to the outer edge
// (≈70% of the way out from the previous ring) means even narrow
// inner rings have enough arc length for the textPath to lay out
// without truncating ("DOP" bug). Font size scales with the available
// arc length so a 7-ring radar's innermost "STANDARD" still fits.
const ringLabels = rings
.map((r, rIdx) => {
const arcId = `arc-ring-${r.id}`
const innerEdge = rings[rIdx - 1]?.outer ?? 0
const labelRadius = innerEdge + (r.outer - innerEdge) * 0.7
// Half-angle clamped so very small rings don't try to subtend more
// than ~80° of the wheel. Arc length ≈ 2 · r · half.
const half = Math.max(0.18, Math.min(0.9, 70 / labelRadius))
const arcLen = 2 * labelRadius * half
// Per-character width of an uppercase sans-serif label is roughly
// font * (0.6 char + 0.25em letter-spacing) ≈ font * 0.85
// For long labels (RECOMMENDED) we have to shrink the font AND the
// letter-spacing to fit the arc without textPath truncating from
// both ends. CHAR_FACTOR is the tightest fit the eye still parses;
// smaller → bigger fonts pulled in.
const CHAR_FACTOR = 0.95
const fontSize = Math.max(
10,
Math.min(
18,
Math.floor(arcLen / (Math.max(r.label.length, 4) * CHAR_FACTOR)),
),
)
// Tighten letter-spacing for shrunken labels so the chars stay
// visually grouped instead of looking like they were stretched.
const tracking = fontSize < 14 ? '0.08em' : '0.25em'
return `
<defs><path id="${arcId}" d="${arcPath(-Math.PI / 2 - half, -Math.PI / 2 + half, labelRadius)}"/></defs>
<text class="ring-label" data-r="${r.id}" style="font-size: ${fontSize}px; letter-spacing: ${tracking}">
<textPath href="#${arcId}" startOffset="50%" text-anchor="middle">${r.label}</textPath>
</text>`
})
.join('')
// Sector titles — placed at each sector's mid-angle just outside the
// outer ring. For N=4 this lands roughly in the corners (same as the
// legacy layout); for higher N the labels distribute around the rim.
const sectorLabels = sectors
.map((s) => {
const mid = (s.start + s.end) / 2
const p = polar(mid, MAX_RADIUS + 28)
// Anchor/baseline lean the text BACK TOWARDS the centre so it stays
// inside the viewBox. On the right edge (dx > 0) the END of the
// text sits at x; on the bottom (dy > 0) the BASELINE sits at y so
// characters grow upward. Reversed for the opposite sides.
const dx = p.x - CENTER
const dy = p.y - CENTER
const anchor = Math.abs(dx) < 6 ? 'middle' : dx > 0 ? 'end' : 'start'
const baseline = Math.abs(dy) < 6 ? 'middle' : dy > 0 ? 'auto' : 'hanging'
return `<text class="quad-label" x="${p.x.toFixed(1)}" y="${p.y.toFixed(1)}"
text-anchor="${anchor}" dominant-baseline="${baseline}"
fill="var(--${s.id}-accent)">${escape(s.title)}</text>`
})
.join('')
// Entries — circle + number, wrapped in <a> linking to detail page.
// Blip shape encodes movement: triangle up (moved=1), down (moved=-1),
// circle (no move).
const blipShape = (moved, r, cls) => {
if (moved > 0) {
return `<path class="${cls}" d="M 0 -${r} L ${(r * 0.95).toFixed(1)} ${(r * 0.62).toFixed(1)} L -${(r * 0.95).toFixed(1)} ${(r * 0.62).toFixed(1)} Z"/>`
}
if (moved < 0) {
return `<path class="${cls}" d="M 0 ${r} L ${(r * 0.95).toFixed(1)} -${(r * 0.62).toFixed(1)} L -${(r * 0.95).toFixed(1)} -${(r * 0.62).toFixed(1)} Z"/>`
}
return `<circle class="${cls}" r="${r}"/>`
}
const blips = entries
.map((e) => {
const numDy = e.moved > 0 ? 6 : e.moved < 0 ? 2 : 4
const sid = e.sector // canonical sN id (set by layoutRadar)
const urlSid = sectorUrlId(e) // legacy qN preferred for path stability
const href = `entries/${urlSid}/${entryHref(e.name)}/`
return `
<a href="${href}" class="blip-link" tabindex="0">
<g class="blip" data-s="${sid}" data-r="${e.ring}" data-num="${e.num}"
transform="translate(${e.x.toFixed(1)} ${e.y.toFixed(1)})"
style="color: var(--${sid}-fill)"
data-name="${escape(e.name)}"
data-desc="${escape(e.description || '')}"
data-ring="${escape(String(e.ring).toUpperCase())}"
data-moved="${e.moved}">
${blipShape(e.moved, 14, 'blip-fg')}
<text class="blip-num" text-anchor="middle" dy="${numDy}">${e.num}</text>
</g>
</a>`
})
.join('')
return `
<svg viewBox="0 0 ${SIZE} ${SIZE}" xmlns="http://www.w3.org/2000/svg" class="radar-svg" role="img" aria-label="Tech radar">
<defs>${defs}</defs>
<g class="radar-sectors">${cells}</g>
<g class="radar-rings">${ringCircles}${axes}</g>
<g class="radar-labels">${ringLabels}${sectorLabels}</g>
<g class="radar-blips">${blips}</g>
</svg>`
}
/** Timeline strip — range covers full calendar years that contain data
* (Jan 1 minYear → Jan 1 maxYear+1). Snapshot dots sit at their real
* day-of-year offset; year labels are separate ticks on the line.
* Always renders the empty container so radar-page height stays stable
* across scopes with different snapshot counts. */
const renderTimeline = (timeline, currentDate) => {
if (timeline.length <= 1) {
return '<nav class="timeline" aria-label="Snapshot timeline"><div class="tl-items"></div></nav>'
}
const sorted = timeline.toSorted(
(a, b) => Date.parse(a.date) - Date.parse(b.date),
)
const minYear = Number(String(sorted[0].date).slice(0, 4))
const maxYear = Number(String(sorted[sorted.length - 1].date).slice(0, 4))
const minTs = Date.UTC(minYear, 0, 1)
const maxTs = Date.UTC(maxYear + 1, 0, 1)
const span = maxTs - minTs
const yearTicks = []
for (let y = minYear; y <= maxYear; y++) {
const p = (Date.UTC(y, 0, 1) - minTs) / span
yearTicks.push(
`<span class="tl-year-tick" style="--p:${p.toFixed(4)}">${y}</span>`,
)
}
const dots = sorted
.map((t) => {
const p = (Date.parse(t.date) - minTs) / span
const cls = t.date === currentDate ? 'tl-dot tl-dot--current' : 'tl-dot'
const href = `../${encodeURIComponent(t.date)}/`
return `
<a class="${cls}" href="${href}" style="--p:${p.toFixed(4)}"
data-date="${escape(t.date)}" aria-label="${escape(t.date)}">
<span class="tl-marker"></span>
</a>`
})
.join('')
return `
<nav class="timeline" aria-label="Snapshot timeline">
<div class="tl-items">${dots}${yearTicks.join('')}</div>
</nav>`
}
/** Sidebar with all entries grouped by sector + ring. `credits` toggles
* the generator-credit footer; `aboutHref` (when set) renders a small
* About link with a `?` icon, independent of `credits`. */
const renderLegend = (
radar,
entries,
sectors,
rings,
{ credits = true, aboutHref = null } = {},
) => {
const groups = sectors
.map((s) => {
const inSector = entries.filter((e) => e.sector === s.id)
const ringGroups = rings
.map((r) => {
const inRing = inSector.filter((e) => e.ring === r.id)
if (!inRing.length) return ''
const lis = inRing
.map(
(e) =>
`<li><span class="li-num">${e.num}</span><span class="li-name">${escape(e.name)}</span>${
e.moved > 0
? '<span class="li-move li-move--up">▲</span>'
: e.moved < 0
? '<span class="li-move li-move--down">▼</span>'
: ''
}</li>`,
)
.join('')
return `
<div class="legend-ring legend-ring--${r.id}">
<h4>${escape(r.label)}</h4>
<ul>${lis}</ul>
</div>`
})
.join('')
return `
<section class="legend-quad" data-s="${s.id}" style="--sector-accent: var(--${s.id}-accent)">
<h3>${escape(s.title)}</h3>
${ringGroups}
</section>`
})
.join('')
const aboutLink = aboutHref
? `<a class="legend-about" href="${aboutHref}" aria-label="About this radar">?</a>`
: ''
const credit = credits
? `<span class="legend-credit">QIWI <span class="heart">❤</span> <a href="https://github.com/qiwi/tech-radar" target="_blank" rel="noopener">Open Source</a></span>`
: ''
const footer = (aboutLink || credit)
? `<div class="legend-footer">${aboutLink}${credit}</div>`
: ''
return `
<div class="legend-col">
<aside class="legend">${groups}</aside>${footer}
</div>`
}
/** Scope-switcher tabs in the topbar — link directly to each scope's latest
* snapshot, skipping the intermediate redirect page so SPA navigation lands
* on a real radar page in one hop. */
const renderScopeTabs = (scopes, scopeLatest, currentScope) => {
if (scopes.length <= 1) return ''
const tabs = scopes
.map((s) => {
const cls = s === currentScope ? 'tab tab--current' : 'tab'
const latest = scopeLatest[s]
const href = s === currentScope || !latest
? '#'
: `../../${encodeURIComponent(s)}/${encodeURIComponent(latest)}/`
return `<a class="${cls}" href="${href}">${escape(s)}</a>`
})
.join('')
return `<nav class="scope-tabs" aria-label="Scope">${tabs}</nav>`
}
/** Full HTML page for a single radar (scope + date). */
export const radarPage = ({
radar,
scope,
scopes,
scopeLatest,
date,
timeline,
basePath,
navTitle,
navFooter,
credits = true,
aboutHref = null,
autoFitRings = false,
}) => {
const { entries, sectors, rings } = layoutRadar(radar, { autoFitRings })
const title = `${escape(radar.title || scope)} — ${escape(date)}`
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark light">
${FOUC_STYLE}
${THEME_BOOT}
<title>${title}</title>
<link rel="stylesheet" href="${basePath}aurora.css">
${renderPalette(sectors, rings)}
<link rel="icon" href="${basePath}favicon.ico">
</head>
<body class="page-radar">
<header class="topbar">
<a class="brand" href="${basePath}">
<span class="brand-mark">📡</span>
<span class="brand-text">${escape(navTitle || 'Tech radar')}</span>
</a>
${renderScopeTabs(scopes, scopeLatest, scope)}
<div class="topbar-meta">
<span class="meta-date" id="metaDate" data-default="${escape(date)}">${escape(date)}</span>
<button class="toggle toggle--mode" data-toggle="mode" type="button" aria-label="Cycle theme & colour mode"></button>
</div>
</header>
${renderTimeline(timeline, date)}
<main class="radar-shell">
<div class="radar-stage">${renderSvg(radar, entries, sectors, rings)}</div>
${renderLegend(radar, entries, sectors, rings, { credits, aboutHref })}
</main>
<div class="hover-card" id="hoverCard" role="status" aria-live="polite">
<div class="hc-head">
<span class="hc-num"></span>
<h4 class="hc-name"></h4>
</div>
<div class="hc-meta">
<span class="hc-ring"></span>
<span class="hc-move"></span>
</div>
<p class="hc-desc"></p>
</div>
${navFooter ? `<footer class="page-footer">${escape(navFooter)}</footer>` : ''}
<script src="${basePath}aurora.js" defer></script>
</body>
</html>
`
}
/** Entry detail page. */
export const entryPage = ({
entry,
radar,
scope,
date,
basePath,
navTitle,
radarPath,
}) => {
// Resolve the human title of the sector this entry belongs to. Works for
// both legacy (quadrantTitles[q1]) and Flex (sectors[].title) radars.
const sectorTitle = (() => {
const sid = entry.sector
const fromArr = (radar.document.sectors || []).find((s) => s.id === sid)
if (fromArr) return fromArr.title
if (entry.quadrant && radar.document.quadrantTitles) {
return radar.document.quadrantTitles[entry.quadrant]
}
return sid?.toUpperCase() || ''
})()
// Ring title comes from the rings array.
const ringTitle = (() => {
const r = (radar.document.rings || []).find((r) => r.id === entry.ring)
return r ? String(r.title).toUpperCase() : String(entry.ring).toUpperCase()
})()
const moveBadge =
entry.moved > 0
? '<span class="badge badge--up">▲ moved up</span>'
: entry.moved < 0
? '<span class="badge badge--down">▼ moved down</span>'
: ''
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark light">
${FOUC_STYLE}
${THEME_BOOT}
<title>${escape(entry.name)} — ${escape(scope)} ${escape(date)}</title>
<link rel="stylesheet" href="${basePath}aurora.css">
<link rel="icon" href="${basePath}favicon.ico">
</head>
<body class="page-entry">
<header class="topbar">
<a class="brand" href="${radarPath}">
<span class="brand-mark">📡</span>
<span class="brand-text">${escape(navTitle || 'Tech radar')}</span>
</a>
<div class="topbar-meta">
<button class="toggle toggle--mode" data-toggle="mode" type="button" aria-label="Cycle theme & colour mode"></button>
</div>
</header>
<main class="entry-shell">
<h1 class="entry-title">
<a class="back" href="${radarPath}" aria-label="Back to radar">←</a>
${escape(entry.name)}
</h1>
<div class="entry-badges">
<span class="badge badge--quad">${escape(sectorTitle)}</span>
<span class="badge badge--ring badge--ring-${entry.ring}">${escape(ringTitle)}</span>
${moveBadge}
</div>
<div class="entry-desc">${escape(entry.description || '')}</div>
</main>
<script src="${basePath}aurora.js" defer></script>
</body>
</html>
`
}
/** Global About page — radar overview content rendered as prose. The
* back arrow points to dist root (and the SPA picks it up like any
* other internal link). */
export const aboutPage = ({ contentHtml, basePath, navTitle }) => `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark light">
${FOUC_STYLE}
${THEME_BOOT}
<title>${escape(navTitle || 'Tech radar')} — About</title>
<link rel="stylesheet" href="${basePath}aurora.css">
<link rel="icon" href="${basePath}favicon.ico">
</head>
<body class="page-about">
<header class="topbar">
<a class="brand" href="${basePath}">
<span class="brand-mark">📡</span>
<span class="brand-text">${escape(navTitle || 'Tech radar')}</span>
</a>
<div class="topbar-meta">
<button class="toggle toggle--mode" data-toggle="mode" type="button" aria-label="Cycle theme & colour mode"></button>
</div>
</header>
<main class="about-shell">
<h1 class="entry-title">
<a class="back" href="${basePath}" aria-label="Back to radar">←</a>
About
</h1>
<article class="about-content">${contentHtml}</article>
</main>
<script src="${basePath}aurora.js" defer></script>
</body>
</html>
`
/** scope/index.html — meta-redirect to latest snapshot. */
export const redirectPage = ({ date }) => `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="refresh" content="0; url=${escape(date)}/">
<link rel="canonical" href="${escape(date)}/">
<title>Redirecting…</title>
</head>
<body>
<a href="${escape(date)}/">Continue to latest snapshot</a>
</body>
</html>
`