@qiwi/tech-radar
Version:
Fully automated tech-radar generator
615 lines (589 loc) • 22.9 kB
JavaScript
// Aurora stylesheet served as a static asset — single dark theme,
// system font stack, no external resources.
export const css = `/* Smooth crossfade between same-origin radar pages.
Modern browsers (Chrome 126+, Edge, Safari TP) — older fall back to native nav. */
@view-transition { navigation: auto; }
::view-transition-old(root),
::view-transition-new(root) { animation-duration: 220ms; }
/* ── Theme tokens ────────────────────────────────────────────────── */
/* Two independent axes:
[data-theme="dark"|"light"] — surface tones
[data-chroma="color"|"mono"] — accent hues (or grayscale)
Defaults: dark + color. */
:root, [data-theme="dark"] {
color-scheme: dark;
--bg: #07080d;
--bg-elev: #0c0f1a;
/* Slightly brighter than --bg-elev so panels (legend) read as lifted. */
--bg-panel: #161b2acc;
--bg-card: #11162478;
--bg-glow-1: hsl(220 50% 12%);
--bg-glow-2: hsl(280 40% 10%);
--fg: #e6e9f0;
--fg-soft: #aab1c4;
--fg-mute: #7a8093;
--line: #1f2436;
/* UI-state accent (current snapshot, active toggle dot, focus ring).
Detached from ring/quadrant semantics so "selected" never collides
with "ADOPT". */
--accent: hsl(200 85% 65%);
--accent-glow: rgba(120, 200, 255, 0.45);
--r-adopt: hsl(150 60% 55%);
--r-trial: hsl(195 65% 60%);
--r-assess: hsl(45 80% 60%);
--r-hold: hsl(2 75% 65%);
--shadow-lg: 0 24px 60px -16px rgba(0, 0, 0, 0.6);
--radius: 12px;
/* Per-sector accents (\`--s{N}-accent\`, \`--s{N}-grad-0/1\`, \`--s{N}-blip\`)
are emitted per radar in renderPalette() — depend on sector count and
hue distribution, can't live as a fixed set of named tokens here. */
}
[data-theme="light"] {
color-scheme: light;
--bg: #f6f7fa;
--bg-elev: #ffffff;
--bg-panel: rgba(255, 255, 255, 0.92);
--bg-card: rgba(255, 255, 255, 0.72);
/* Subtle pastel glow corners — won't muddy the radar but breaks the flat-white. */
--bg-glow-1: hsl(200 75% 95%);
--bg-glow-2: hsl(300 55% 96%);
--fg: #11151c;
--fg-soft: #475064;
--fg-mute: #6c7484;
--line: #dfe3ea;
--accent: hsl(220 85% 52%);
--accent-glow: rgba(60, 110, 200, 0.35);
--r-adopt: hsl(150 55% 38%);
--r-trial: hsl(195 55% 42%);
--r-assess: hsl(38 70% 45%);
--r-hold: hsl(2 70% 48%);
--shadow-lg: 0 12px 40px -10px rgba(20, 25, 40, 0.18);
/* Per-sector accents/gradients/blips for the light theme are emitted in
renderPalette() per radar (sector-count-dependent). */
}
/* Monochrome — collapse all semantic accents to neutral. Per-sector
token overrides live in renderPalette() so they cover whatever count
the current radar declares. */
[data-chroma="mono"] {
--accent: var(--fg-soft);
--r-adopt: var(--fg-soft);
--r-trial: var(--fg-soft);
--r-assess: var(--fg-soft);
--r-hold: var(--fg-soft);
}
* { box-sizing: border-box; }
html, body { height: 100%; overflow: hidden; }
body {
margin: 0;
background:
radial-gradient(60% 80% at 50% -10%, hsl(220 50% 12%) 0%, transparent 60%),
radial-gradient(40% 60% at 100% 100%, hsl(280 40% 10%) 0%, transparent 70%),
var(--bg);
color: var(--fg);
font: 14px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", "Inter", system-ui, sans-serif;
min-height: 100vh;
}
a { color: inherit; text-decoration: none; }
h1, h2, h3, h4 { margin: 0; font-weight: 600; letter-spacing: -0.01em; }
/* ── Topbar ──────────────────────────────────────────────────────── */
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 28px;
background: linear-gradient(to bottom, var(--bg-elev), transparent);
position: sticky; top: 0; z-index: 5;
backdrop-filter: blur(6px);
}
.brand {
display: inline-flex; align-items: center; gap: 10px;
font-weight: 600; font-size: 14px;
opacity: .55;
transition: opacity .15s ease;
}
.brand:hover { opacity: 1; }
.brand-mark { font-size: 16px; filter: drop-shadow(0 0 6px var(--accent-glow)); }
[data-theme="light"] .brand-mark { filter: none; }
.brand-text { color: var(--fg); }
.topbar-meta { display: flex; align-items: center; gap: 12px; font-size: 13px; color: var(--fg-soft); }
.meta-scope { color: var(--fg); font-weight: 500; }
.meta-date { font-variant-numeric: tabular-nums; color: var(--fg-mute); margin-right: 4px; }
/* ── Mode toggle (theme + chroma in one) ─────────────────────────── */
/* One round button cycles through 4 states: dark+color → dark+mono →
light+mono → light+color → … The glyph encodes BOTH axes:
• dot size → theme (dark = filled, light = small inset dot)
• dot colour → chroma (color = accent, mono = grey) */
.toggle {
appearance: none;
width: 26px; height: 26px;
padding: 0;
border-radius: 999px;
border: 1.5px solid var(--fg-mute);
background: transparent;
cursor: pointer;
position: relative;
transition: border-color .15s ease, transform .15s ease;
}
.toggle:hover { border-color: var(--fg); transform: scale(1.06); }
.toggle:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.toggle--mode::before {
content: '';
position: absolute;
inset: 3px;
border-radius: 999px;
background: var(--accent);
transform: scale(1);
transition: background .2s ease, transform .2s ease;
}
[data-chroma="mono"] .toggle--mode::before { background: var(--fg-mute); }
/* Light theme → smaller inset dot (visually reads as "less filled"). */
[data-theme="light"] .toggle--mode::before { transform: scale(0.5); }
/* ── Scope tabs ──────────────────────────────────────────────────── */
.scope-tabs {
display: flex; align-items: center; gap: 4px;
flex: 1; justify-content: center;
flex-wrap: wrap;
padding: 0 16px;
}
.scope-tabs .tab {
display: inline-flex; align-items: center;
padding: 6px 14px;
border-radius: 999px;
font-size: 13px; font-weight: 500;
color: var(--fg-mute);
transition: color .15s ease, background .15s ease;
}
/* Theme-agnostic hover/active fills — derived from --fg so they read in both
light and dark surfaces. Hardcoded white-alpha was invisible on the white. */
.scope-tabs .tab:hover {
color: var(--fg);
background: color-mix(in srgb, var(--fg) 8%, transparent);
}
.scope-tabs .tab--current {
color: var(--fg);
background: color-mix(in srgb, var(--fg) 12%, transparent);
pointer-events: none;
}
/* ── Timeline ────────────────────────────────────────────────────── */
/* Fixed total height — keeps page layout stable when switching between
scopes whose timelines have different dot counts (or none). */
.timeline {
position: relative;
padding: 8px 32px 6px;
min-height: 44px;
}
.tl-items {
position: relative;
height: 30px;
margin: 0 auto;
max-width: 1200px;
}
.tl-items::before {
content: '';
position: absolute;
left: 16px; right: 16px;
top: 5px;
height: 1px;
background: var(--line);
}
.tl-dot {
position: absolute;
top: 0;
/* Inset by 16px so the first/last dots sit on the line ends, not off-screen. */
left: calc(16px + var(--p, 0) * (100% - 32px));
transform: translateX(-50%);
text-decoration: none;
}
.tl-year-tick {
position: absolute;
bottom: 0;
left: calc(16px + var(--p, 0) * (100% - 32px));
transform: translateX(-50%);
font-size: 11px;
color: var(--fg-mute);
font-variant-numeric: tabular-nums;
letter-spacing: .04em;
pointer-events: none;
}
/* Tiny tick mark above year label, sitting on the line. */
.tl-year-tick::before {
content: '';
position: absolute;
left: 50%;
top: -12px;
width: 1px;
height: 4px;
background: var(--line);
transform: translateX(-50%);
}
.tl-marker {
width: 11px; height: 11px; border-radius: 999px;
background: var(--bg-elev); border: 1.5px solid var(--fg-mute);
transition: transform .15s ease, background .15s ease, border-color .15s ease, box-shadow .15s ease;
z-index: 1;
}
.tl-dot:hover .tl-marker {
border-color: var(--accent);
background: var(--accent);
transform: scale(1.2);
}
.tl-dot--current .tl-marker {
background: var(--accent);
border-color: var(--accent);
box-shadow:
0 0 0 3px color-mix(in srgb, var(--accent) 18%, transparent),
0 0 18px color-mix(in srgb, var(--accent) 45%, transparent);
}
/* ── Radar shell ─────────────────────────────────────────────────── */
.radar-shell {
display: grid;
grid-template-columns: minmax(0, 1fr) 280px;
gap: 20px;
padding: 8px 24px 24px;
max-width: 1700px;
margin: 0 auto;
align-items: start;
}
@media (max-width: 960px) {
.radar-shell { grid-template-columns: 1fr; }
}
/* Stage is a square that fills the remaining viewport height (after topbar +
timeline + paddings) and the column width — whichever is smaller wins. */
.radar-stage {
position: relative;
width: min(100%, calc(100vh - 140px));
aspect-ratio: 1;
margin: 0 auto;
}
.radar-svg { width: 100%; height: 100%; display: block; }
/* ── SVG ─────────────────────────────────────────────────────────── */
/* Sector backgrounds — per-quadrant radial gradient defined in SVG defs
(visual atmosphere, NOT semantic). Semantic colour encoding lives on
the blips and ring labels (by ring). */
.sector { transition: fill-opacity .2s ease, opacity .2s ease; stroke: none; }
.sector:hover { filter: brightness(1.25); }
/* Monochrome — no sector fills; only the ring outlines define structure. */
[data-chroma="mono"] .sector { fill: transparent; }
/* Ring boundary circles + cross axes. In colour mode they sit underneath
the per-quadrant gradient, so they should whisper — a faint tint of fg.
In mono they're the only structure the eye has to land on → bumped up. */
.ring-line {
fill: none;
stroke: color-mix(in srgb, var(--fg) 8%, transparent);
stroke-width: 1;
}
.axis {
stroke: color-mix(in srgb, var(--fg) 5%, transparent);
stroke-width: 1;
}
[data-chroma="mono"] .ring-line {
stroke: color-mix(in srgb, var(--fg-mute) 50%, transparent);
stroke-width: 1.25;
}
[data-chroma="mono"] .axis {
stroke: color-mix(in srgb, var(--fg-mute) 30%, transparent);
}
/* Ring labels — neutral. Ring is encoded by position + blip brightness,
not by colour, so no per-ring tint here. */
.ring-label {
font: 600 18px/1 sans-serif; letter-spacing: .25em;
fill: var(--fg-mute); fill-opacity: .55;
text-transform: uppercase;
}
.quad-label {
font: 700 18px/1 sans-serif; letter-spacing: .15em;
text-transform: uppercase;
fill-opacity: .85;
}
[data-chroma="mono"] .quad-label { fill-opacity: .55; }
.blip-link { cursor: pointer; outline: none; }
/* Sector hue arrives via an inline style="color: var(--s{N}-fill)" on
each blip — pages.js emits it because the sector ids are radar-specific.
Ring brightness ramp (.blip[data-r=...] opacity) is emitted alongside,
in renderPalette(). Hover/active restores full intensity here. */
.blip.is-active,
.blip-link:hover .blip,
.blip-link:focus-visible .blip {
opacity: 1;
filter: saturate(1.15);
}
/* Outlined blips: fill matches the page background so the SVG sector
gradient doesn't leak through; stroke + number both pick up the accent.
IMPORTANT: don't transform-scale .blip on hover — bbox flicker in dense
clusters strobes the hover-card. Glow filter only. */
.blip-fg {
fill: var(--bg);
stroke: currentColor;
stroke-width: 2;
transition: filter .15s ease, stroke-width .15s ease, fill .15s ease;
}
.blip-num {
fill: currentColor;
font: 700 12px/1 sans-serif;
pointer-events: none;
font-variant-numeric: tabular-nums;
}
.blip.is-active .blip-fg,
.blip-link:hover .blip-fg,
.blip-link:focus-visible .blip-fg {
filter: drop-shadow(0 0 10px currentColor);
stroke-width: 2.5;
fill: color-mix(in srgb, currentColor 14%, var(--bg));
}
/* ── Light + colour: solid-filled blips, no sector wash ─────────────
On the dark canvas a coloured wash + outlined blip read well together
(signal-on-fog). On white we drop the wash entirely and let the blip
colour carry. Per-sector --sN-fill tokens are emitted in
renderPalette(); rules below are sector-count-agnostic. */
[data-theme="light"][data-chroma="color"] .sector { fill: transparent; }
[data-theme="light"][data-chroma="color"] .ring-line {
stroke: color-mix(in srgb, var(--fg) 14%, transparent);
}
[data-theme="light"][data-chroma="color"] .axis {
stroke: color-mix(in srgb, var(--fg) 10%, transparent);
}
[data-theme="light"][data-chroma="color"] .blip { filter: none; }
[data-theme="light"][data-chroma="color"] .blip.is-active,
[data-theme="light"][data-chroma="color"] .blip-link:hover .blip,
[data-theme="light"][data-chroma="color"] .blip-link:focus-visible .blip {
opacity: 1;
filter: none;
}
[data-theme="light"][data-chroma="color"] .blip-fg {
fill: currentColor;
stroke: none;
}
[data-theme="light"][data-chroma="color"] .blip-num {
fill: #fff;
}
[data-theme="light"][data-chroma="color"] .blip.is-active .blip-fg,
[data-theme="light"][data-chroma="color"] .blip-link:hover .blip-fg,
[data-theme="light"][data-chroma="color"] .blip-link:focus-visible .blip-fg {
fill: currentColor;
stroke: none;
filter: drop-shadow(0 3px 10px color-mix(in srgb, currentColor 55%, transparent));
}
/* ── Legend (sidebar) ────────────────────────────────────────────── */
/* The whole column (legend panel + footer credit) sticks together; only the
list inside the panel scrolls when entries overflow.
Max-height matches the radar-stage (calc(100vh - 140px)) so the column
bottom never falls below the radar — leaves room for topbar + timeline
+ shell padding (~120 px) plus a small viewport margin. */
.legend-col {
position: sticky; top: 80px;
display: flex; flex-direction: column;
gap: 8px;
max-height: calc(100vh - 140px);
min-height: 0;
}
.legend {
background: var(--bg-panel);
border-radius: var(--radius);
padding: 14px 16px;
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
font-size: 13px;
}
.legend-quad + .legend-quad { margin-top: 22px; padding-top: 18px; border-top: 1px dashed var(--line); }
.legend-quad h3 {
font-size: 13px; letter-spacing: .04em; text-transform: uppercase;
color: var(--fg);
margin-bottom: 10px;
}
/* Sector accent comes from the inline \`--sector-accent\` custom property
pages.js sets on each <section data-s="sN"> — works for any sector count. */
.legend-quad h3 { color: var(--sector-accent, var(--fg)); }
.legend-ring h4 {
font-size: 11px; letter-spacing: .15em; text-transform: uppercase;
color: var(--fg-mute); margin: 12px 0 6px;
}
.legend-ring ul { list-style: none; margin: 0; padding: 0; }
.legend-ring li {
display: flex; align-items: baseline; gap: 8px;
padding: 3px 0; cursor: pointer;
border-radius: 6px; padding-left: 4px;
transition: background .15s ease;
}
/* Theme-agnostic — derived from --fg so it shows on both light + dark. */
.legend-ring li:hover { background: color-mix(in srgb, var(--fg) 6%, transparent); }
.legend-ring li.is-active {
background: color-mix(in srgb, var(--fg) 12%, transparent);
}
/* About link — round "?" pill sitting INSIDE .legend-footer to the left
of the credit text. No label, no own line. Independent of credits flag,
so it can stand alone if --credits false. */
.legend-about {
display: inline-flex; align-items: center; justify-content: center;
width: 20px; height: 20px;
border-radius: 999px;
border: 1px solid var(--fg-mute);
color: var(--fg-mute);
font: 600 11px/1 sans-serif;
text-decoration: none;
transition: color .15s ease, border-color .15s ease, transform .15s ease;
flex: 0 0 auto;
}
.legend-about:hover {
color: var(--fg);
border-color: var(--fg);
transform: scale(1.06);
}
.legend-about:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.legend-credit { display: inline-flex; gap: 6px; align-items: baseline; }
/* Generator credit — sits OUTSIDE .legend so the panel's bg/radius don't
wrap it. Just plain text on the page background, no chrome.
Single row; flex: 0 0 auto so the scrollable panel above can't squeeze
it out. Some breathing room above the line so it doesn't crowd the
legend's bottom items. */
.legend-footer {
flex: 0 0 auto;
padding: 8px 6px 0;
font-size: 11px;
color: var(--fg-mute);
line-height: 1.4;
display: flex; flex-flow: row wrap; align-items: center;
gap: 8px;
}
.legend-footer .sep { opacity: .55; }
/* Link inherits the same muted grey as the slogan — it's a credit, not a CTA.
Underline stays as the only affordance; brightens on hover. */
/* Only the credit link gets the underline treatment — the "?" pill is
styled separately and would look weird with an underline through it. */
.legend-credit a {
color: inherit;
text-decoration: underline;
text-decoration-color: color-mix(in srgb, var(--fg-mute) 35%, transparent);
text-underline-offset: 2px;
}
.legend-credit a:hover {
color: var(--fg-soft);
text-decoration-color: var(--fg-mute);
}
.legend-footer .heart { color: hsl(2 75% 60%); }
[data-chroma="mono"] .legend-footer .heart { color: var(--fg-mute); }
.li-num {
display: inline-block; min-width: 24px;
font-variant-numeric: tabular-nums;
color: var(--fg-mute); font-size: 11px;
text-align: right;
}
.li-name { flex: 1; color: var(--fg); }
.li-move { font-size: 10px; color: var(--fg-mute); }
/* ── Hover card ──────────────────────────────────────────────────── */
.hover-card {
position: fixed;
background: var(--bg-elev);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 14px 16px;
width: 320px;
box-shadow: var(--shadow-lg);
z-index: 50;
font-size: 13px;
opacity: 0;
transition: opacity .12s ease;
top: 0; left: 0;
}
/* pointer-events: none on the card AND every descendant — otherwise text
children intercept hover and force pointerleave on the blip below,
producing a strobe loop. */
.hover-card, .hover-card * { pointer-events: none; }
.hover-card.is-shown { opacity: 1; }
.hc-head { display: flex; align-items: baseline; gap: 10px; margin-bottom: 6px; }
.hc-num { font-variant-numeric: tabular-nums; color: var(--fg-mute); font-size: 11px; }
.hc-name { font-size: 15px; font-weight: 600; }
.hc-meta { display: flex; gap: 10px; margin-bottom: 8px; font-size: 11px; }
.hc-ring {
color: var(--fg-soft);
letter-spacing: .14em; text-transform: uppercase; font-weight: 600;
font-size: 10.5px;
}
.hc-move { color: var(--fg-mute); }
.hc-desc { margin: 0; color: var(--fg-soft); line-height: 1.5; }
/* ── Entry detail ────────────────────────────────────────────────── */
.entry-shell {
max-width: 720px; margin: 0 auto; padding: 32px 32px 64px;
}
.entry-title {
font-size: 36px; line-height: 1.15; margin-bottom: 14px;
display: flex; align-items: center; gap: 16px;
}
.entry-title .back {
display: inline-flex; align-items: center; justify-content: center;
width: 36px; height: 36px;
border-radius: 999px;
color: var(--fg-mute);
font-size: 24px; font-weight: 400; line-height: 1;
border: 1px solid var(--line);
transition: color .15s ease, border-color .15s ease, background .15s ease;
}
.entry-title .back:hover {
color: var(--fg);
border-color: var(--fg-mute);
background: rgba(255,255,255,.04);
}
.entry-badges { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 24px; }
.badge {
display: inline-flex; align-items: center; gap: 6px;
padding: 4px 10px; border-radius: 999px; font-size: 11px;
letter-spacing: .08em; text-transform: uppercase; font-weight: 600;
background: var(--bg-card); border: 1px solid var(--line); color: var(--fg-soft);
}
.badge--ring-adopt { color: var(--r-adopt); border-color: color-mix(in srgb, var(--r-adopt) 35%, transparent); }
.badge--ring-trial { color: var(--r-trial); border-color: color-mix(in srgb, var(--r-trial) 35%, transparent); }
.badge--ring-assess { color: var(--r-assess); border-color: color-mix(in srgb, var(--r-assess) 35%, transparent); }
.badge--ring-hold { color: var(--r-hold); border-color: color-mix(in srgb, var(--r-hold) 35%, transparent); }
.badge--up { color: var(--r-adopt); border-color: color-mix(in srgb, var(--r-adopt) 35%, transparent); }
.badge--down { color: var(--r-hold); border-color: color-mix(in srgb, var(--r-hold) 35%, transparent); }
.entry-desc {
font-size: 16px; line-height: 1.7; color: var(--fg-soft);
white-space: pre-wrap;
}
/* ── About page ──────────────────────────────────────────────────── */
/* Prose layout — wider than entry-shell because content has two-column
reading flow with headings + bullets. Container caps line length so
long paragraphs stay readable. */
.about-shell {
max-width: 920px;
margin: 0 auto;
padding: 24px 32px 64px;
}
.about-content {
columns: 2;
column-gap: 48px;
font-size: 14.5px;
line-height: 1.65;
color: var(--fg-soft);
}
@media (max-width: 720px) {
.about-content { columns: 1; }
}
.about-content h1,
.about-content h2,
.about-content h3 {
break-after: avoid;
margin: 0 0 12px;
color: var(--fg);
font-weight: 700;
}
.about-content h1 { font-size: 20px; }
.about-content h2 { font-size: 17px; margin-top: 22px; }
.about-content h3 { font-size: 15px; margin-top: 18px; }
.about-content p { margin: 0 0 14px; }
.about-content ul { margin: 0 0 16px; padding-left: 22px; }
.about-content li { margin: 0 0 10px; }
.about-content li::marker { color: var(--fg-mute); }
.about-content strong { color: var(--fg); font-weight: 700; }
.about-content a {
color: var(--accent);
text-decoration: underline;
text-decoration-color: color-mix(in srgb, var(--accent) 50%, transparent);
}
.about-content a:hover { text-decoration-color: var(--accent); }
.page-footer {
text-align: center; padding: 24px;
color: var(--fg-mute); font-size: 12px;
border-top: 1px solid var(--line);
}
`