|
<!doctype html> |
|
<html lang="en" class="dark"> |
|
<head> |
|
<meta charset="utf-8"> |
|
<meta name="viewport" content="width=device-width, initial-scale=1"> |
|
<title>{{TITLE}}</title> |
|
<!-- Inline favicon. Without one the browser requests /favicon.ico, which a file:// |
|
document cannot load — it logs "Unsafe attempt to load URL … unique security |
|
origins" on every open. An amber tile also makes the tab findable. --> |
|
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='8' fill='%23fbbf24'/%3E%3C/svg%3E"> |
|
<link rel="preconnect" href="https://fonts.googleapis.com"> |
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> |
|
<link href="https://fonts.googleapis.com/css2?family=Hanken+Grotesk:wght@400;500;600;700;800&display=swap" rel="stylesheet"> |
|
<style> |
|
/* ============================================================ |
|
Uptip PR explainer — dark shell |
|
Mirrors admin-app/design-system.md ("Warm Dashboard"), .dark column. |
|
Token values are copied from admin-app/src/index.css — if that file |
|
changes, change these. |
|
============================================================ */ |
|
:root { |
|
--c-canvas: #0d0a08; |
|
--c-surface: #1a1613; |
|
--c-surface2: #221d18; |
|
--c-fg: #f7f4f1; |
|
--c-muted: #b3a79d; |
|
--c-dim: #988b7f; |
|
--c-accent: #fbbf24; |
|
--c-accent-ink: #1c1917; |
|
--bg-grad: linear-gradient(180deg, #1a1613, #0d0a08); |
|
|
|
/* opacity-modifier idiom from the design system */ |
|
--hair: rgba(247,244,241,0.10); |
|
--hair-soft: rgba(247,244,241,0.08); |
|
--hover: rgba(247,244,241,0.05); |
|
--accent-soft: rgba(251,191,36,0.15); |
|
--shadow: 0 22px 48px -26px rgba(0,0,0,0.45); |
|
|
|
/* Status colors. Dark-side steps only — per design-system.md these are |
|
the steps that clear WCAG AA against #1a1613 (green-500 8.11:1, |
|
red-500 4.72:1). Never fade them with opacity; use --c-muted instead. */ |
|
--ok: #22c55e; |
|
--warn: #f59e0b; |
|
--danger:#ef4444; |
|
--info: #3b82f6; |
|
|
|
--ease: cubic-bezier(0.16, 1, 0.3, 1); |
|
} |
|
|
|
* { box-sizing: border-box; scrollbar-width: thin; |
|
scrollbar-color: color-mix(in srgb, var(--c-fg) 22%, transparent) transparent; } |
|
*::-webkit-scrollbar { width: 10px; height: 10px; } |
|
*::-webkit-scrollbar-track { background: transparent; } |
|
*::-webkit-scrollbar-thumb { |
|
background: color-mix(in srgb, var(--c-fg) 18%, transparent); |
|
border-radius: 9999px; border: 3px solid transparent; background-clip: padding-box; |
|
} |
|
*::-webkit-scrollbar-thumb:hover { background: color-mix(in srgb, var(--c-fg) 32%, transparent); } |
|
|
|
html { scroll-behavior: smooth; } |
|
body { |
|
margin: 0; |
|
background: var(--c-canvas) var(--bg-grad) no-repeat fixed; |
|
color: var(--c-fg); |
|
font-family: 'Hanken Grotesk', ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif; |
|
font-size: 15px; line-height: 1.65; |
|
-webkit-font-smoothing: antialiased; |
|
} |
|
::selection { background: var(--accent-soft); } |
|
|
|
/* ---------- layout ---------- */ |
|
.wrap { max-width: 1180px; margin: 0 auto; padding: 0 20px 96px; } |
|
.layout { display: block; } |
|
@media (min-width: 1080px) { |
|
.layout { display: grid; grid-template-columns: 232px minmax(0, 1fr); gap: 44px; align-items: start; } |
|
} |
|
main { min-width: 0; } |
|
|
|
/* ---------- hero ---------- */ |
|
.hero { padding: 56px 0 30px; } |
|
.hero__eyebrow { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 18px; } |
|
.hero h1 { |
|
font-size: clamp(28px, 4.4vw, 42px); font-weight: 800; letter-spacing: -0.02em; |
|
line-height: 1.12; margin: 0 0 14px; |
|
} |
|
.hero__sub { color: var(--c-muted); font-size: 16px; max-width: 68ch; margin: 0 0 22px; } |
|
.hero__meta { display: flex; flex-wrap: wrap; gap: 10px 26px; font-size: 13px; color: var(--c-dim); } |
|
.hero__meta b { color: var(--c-fg); font-weight: 700; } |
|
|
|
/* ---------- table of contents ---------- */ |
|
.toc { display: none; } |
|
@media (min-width: 1080px) { |
|
.toc { display: block; position: sticky; top: 28px; padding-top: 8px; } |
|
} |
|
.toc__title { |
|
font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; |
|
color: var(--c-dim); margin: 0 0 12px; padding-left: 12px; |
|
} |
|
.toc a { |
|
display: block; padding: 6px 12px; margin-bottom: 2px; border-radius: 12px; |
|
color: var(--c-muted); text-decoration: none; font-size: 13.5px; line-height: 1.4; |
|
border-left: 2px solid transparent; transition: color .18s, background .18s; |
|
} |
|
.toc a:hover { color: var(--c-fg); background: var(--hover); } |
|
.toc a.is-active { color: var(--c-accent); background: var(--accent-soft); font-weight: 600; } |
|
.toc__layer { |
|
font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; |
|
color: var(--c-dim); padding: 14px 12px 6px; opacity: .75; |
|
} |
|
|
|
/* ---------- sections ---------- */ |
|
.sec { padding: 40px 0 8px; scroll-margin-top: 24px; } |
|
.sec + .sec { border-top: 1px solid var(--hair-soft); } |
|
.sec__head { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin-bottom: 6px; } |
|
.sec h2 { font-size: 26px; font-weight: 800; letter-spacing: -0.015em; margin: 0; } |
|
.sec h3 { font-size: 18px; font-weight: 700; margin: 34px 0 10px; } |
|
.sec h4 { font-size: 14px; font-weight: 700; margin: 22px 0 8px; color: var(--c-muted); } |
|
.sec__lede { color: var(--c-muted); font-size: 16px; max-width: 72ch; margin: 0 0 18px; } |
|
p { max-width: 72ch; } |
|
a { color: var(--c-accent); text-decoration-color: rgba(251,191,36,.4); text-underline-offset: 2px; } |
|
|
|
/* ---------- pills ---------- */ |
|
.pill { |
|
display: inline-flex; align-items: center; gap: 6px; padding: 4px 10px; border-radius: 9999px; |
|
font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; |
|
background: var(--hover); color: var(--c-muted); white-space: nowrap; |
|
} |
|
.pill--accent { background: var(--accent-soft); color: var(--c-accent); } |
|
.pill--ok { background: rgba(34,197,94,.15); color: var(--ok); } |
|
.pill--warn { background: rgba(245,158,11,.15); color: var(--warn); } |
|
.pill--danger { background: rgba(239,68,68,.15); color: var(--danger); } |
|
.pill--info { background: rgba(59,130,246,.15); color: var(--info); } |
|
.pill--mono { |
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace; |
|
text-transform: none; letter-spacing: 0; font-weight: 600; font-size: 11.5px; |
|
} |
|
|
|
/* ---------- cards ---------- */ |
|
.card { |
|
background: var(--c-surface2); border-radius: 22px; padding: 24px 26px; |
|
box-shadow: inset 0 0 0 1px var(--hair), var(--shadow); |
|
margin: 18px 0; |
|
} |
|
.card__title { font-size: 15px; font-weight: 700; margin: 0 0 4px; } |
|
.card__note { font-size: 13px; color: var(--c-dim); margin: 0; } |
|
.card > :first-child { margin-top: 0; } |
|
.card > :last-child { margin-bottom: 0; } |
|
.grid2 { display: grid; gap: 16px; } |
|
.grid3 { display: grid; gap: 16px; } |
|
@media (min-width: 760px) { |
|
.grid2 { grid-template-columns: repeat(2, minmax(0,1fr)); } |
|
.grid3 { grid-template-columns: repeat(3, minmax(0,1fr)); } |
|
} |
|
|
|
/* stat tile */ |
|
.stat { background: var(--c-surface2); border-radius: 22px; padding: 20px 22px; |
|
box-shadow: inset 0 0 0 1px var(--hair), var(--shadow); } |
|
.stat__num { font-size: 30px; font-weight: 800; letter-spacing: -0.02em; line-height: 1.1; } |
|
.stat__lbl { font-size: 12px; color: var(--c-dim); margin-top: 4px; } |
|
.stat--ok .stat__num { color: var(--ok); } |
|
.stat--warn .stat__num { color: var(--warn); } |
|
.stat--danger .stat__num { color: var(--danger); } |
|
.stat--accent .stat__num { color: var(--c-accent); } |
|
|
|
/* ---------- callouts ---------- */ |
|
.callout { |
|
display: flex; gap: 14px; padding: 16px 20px; border-radius: 18px; margin: 18px 0; |
|
background: var(--c-surface); box-shadow: inset 0 0 0 1px var(--hair); |
|
} |
|
.callout__icon { |
|
flex: none; width: 30px; height: 30px; border-radius: 14px; display: grid; place-items: center; |
|
background: var(--accent-soft); color: var(--c-accent); font-size: 15px; font-weight: 800; |
|
} |
|
.callout__body { min-width: 0; } |
|
.callout__body > :first-child { margin-top: 0; } |
|
.callout__body > :last-child { margin-bottom: 0; } |
|
.callout__title { display: block; font-weight: 700; margin-bottom: 2px; } |
|
.callout p { font-size: 14.5px; color: var(--c-muted); } |
|
.callout--warn .callout__icon { background: rgba(245,158,11,.15); color: var(--warn); } |
|
.callout--danger .callout__icon { background: rgba(239,68,68,.15); color: var(--danger); } |
|
.callout--info .callout__icon { background: rgba(59,130,246,.15); color: var(--info); } |
|
.callout--ok .callout__icon { background: rgba(34,197,94,.15); color: var(--ok); } |
|
|
|
/* ---------- code ---------- */ |
|
.code__cap { |
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; |
|
color: var(--c-dim); margin: 18px 0 -8px; display: block; |
|
} |
|
pre.code { |
|
/* white-space MUST stay pre/pre-wrap or the browser eats the newlines */ |
|
white-space: pre; overflow-x: auto; -webkit-overflow-scrolling: touch; |
|
background: var(--c-surface); border-radius: 18px; padding: 18px 20px; margin: 18px 0; |
|
box-shadow: inset 0 0 0 1px var(--hair); |
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace; |
|
font-size: 12.8px; line-height: 1.65; color: var(--c-fg); tab-size: 2; |
|
} |
|
/* One line per block span. Mixing block spans with raw newlines inside a `pre` |
|
yields a blank line between every pair, so wrap EVERY line — context included — |
|
in .ln/.add/.del/.hl and leave NO newline between them. */ |
|
pre.code .ln { display: block; } |
|
/* A deliberately blank line — <span class="ln"></span> — would otherwise collapse |
|
to zero height and lose the separation it was put there for. */ |
|
pre.code .ln:empty::after { content: '\200b'; } |
|
/* Added/removed lines carry their tint on the BACKGROUND only, with the +/- marker |
|
coloured, so syntax highlighting shows through in true colours the way a diff |
|
viewer does. Do not put a `color` back on these. */ |
|
pre.code .add { display: block; background: rgba(34,197,94,.10); margin: 0 -20px; padding: 0 20px; } |
|
pre.code .del { display: block; background: rgba(239,68,68,.10); margin: 0 -20px; padding: 0 20px; } |
|
pre.code .hl { display: block; background: var(--accent-soft); margin: 0 -20px; padding: 0 20px; } |
|
pre.code .cmt { color: var(--c-dim); } /* inline — nest inside .ln */ |
|
|
|
/* Syntax tokens, applied by the highlighter in the script below. The palette is |
|
the design system's own — accent amber for keywords, the sanctioned green and |
|
blue status steps for literals, --c-dim for comments — so a code block reads as |
|
part of this app rather than as a vendor theme dropped into it. Measured as bare |
|
text on the code surface #1a1613: accent 10.9:1, green-500 8.1:1, blue-500 |
|
4.9:1, dim 5.6:1 — all clear AA for normal text. Never soften one of these with |
|
opacity; that is what drops a semantic colour under AA. */ |
|
pre.code .t-k { color: var(--c-accent); } /* keyword */ |
|
pre.code .t-s { color: var(--ok); } /* string */ |
|
pre.code .t-n { color: var(--info); } /* number, literal */ |
|
pre.code .t-c { color: var(--c-dim); } /* comment */ |
|
pre.code .t-p { color: var(--c-muted); } /* punctuation */ |
|
pre.code .t-f { color: var(--c-fg); font-weight: 600; } /* callee / type name */ |
|
pre.code .t-d { font-weight: 700; } /* the +/- marker */ |
|
pre.code .add .t-d { color: var(--ok); } |
|
pre.code .del .t-d { color: var(--danger); } |
|
code:not(pre code) { |
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.88em; |
|
background: var(--hover); padding: 1.5px 6px; border-radius: 7px; color: var(--c-fg); |
|
} |
|
|
|
/* ---------- tables ---------- */ |
|
.table-scroll { overflow-x: auto; margin: 18px 0; } |
|
table.data { width: 100%; border-collapse: collapse; font-size: 14px; min-width: 520px; } |
|
table.data th { |
|
text-align: left; font-size: 11px; font-weight: 700; text-transform: uppercase; |
|
letter-spacing: 0.06em; color: var(--c-dim); padding: 0 14px 10px; white-space: nowrap; |
|
} |
|
table.data td { padding: 11px 14px; border-top: 1px solid var(--hair-soft); vertical-align: top; } |
|
table.data tr:hover td { background: var(--hover); } |
|
table.data td.mono, table.data td .mono { |
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12.5px; |
|
} |
|
/* For a short status/verdict cell that should never wrap mid-label. */ |
|
table.data td.nowrap { white-space: nowrap; } |
|
.dot { display: inline-block; width: 8px; height: 8px; border-radius: 9999px; margin-right: 8px; vertical-align: 1px; } |
|
.dot--ok { background: var(--ok); } .dot--warn { background: var(--warn); } |
|
.dot--none { background: var(--danger); } .dot--dim { background: var(--c-dim); } |
|
|
|
/* ---------- key/value list ---------- */ |
|
.kv { display: grid; grid-template-columns: max-content 1fr; gap: 8px 20px; margin: 16px 0; font-size: 14px; } |
|
.kv dt { color: var(--c-dim); } |
|
.kv dd { margin: 0; } |
|
|
|
/* ---------- diagrams ---------- */ |
|
.diagram { |
|
background: var(--c-surface); border-radius: 22px; padding: 26px 24px; margin: 22px 0; |
|
box-shadow: inset 0 0 0 1px var(--hair); |
|
} |
|
.diagram__cap { font-size: 12.5px; color: var(--c-dim); margin-top: 16px; text-align: center; } |
|
.flow { display: flex; align-items: stretch; gap: 12px; flex-wrap: wrap; justify-content: center; } |
|
.flow--v { flex-direction: column; align-items: center; } |
|
.node { |
|
flex: 1 1 170px; min-width: 150px; max-width: 260px; |
|
background: var(--c-surface2); border-radius: 14px; padding: 14px 16px; |
|
box-shadow: inset 0 0 0 1px var(--hair); |
|
} |
|
.node--accent { box-shadow: inset 0 0 0 1px rgba(251,191,36,.4); } |
|
.node__label { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; color: var(--c-dim); } |
|
.node__title { font-weight: 700; font-size: 14.5px; margin-top: 3px; } |
|
.node__data { |
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 11.5px; |
|
color: var(--c-muted); margin-top: 8px; white-space: pre-wrap; word-break: break-word; |
|
} |
|
.arrow { flex: none; align-self: center; color: var(--c-dim); font-size: 18px; padding: 0 2px; } |
|
.arrow__lbl { display: block; font-size: 10.5px; text-transform: uppercase; letter-spacing: .06em; text-align: center; } |
|
|
|
/* simplified UI mock */ |
|
.ui { background: var(--c-surface2); border-radius: 18px; overflow: hidden; |
|
box-shadow: inset 0 0 0 1px var(--hair); max-width: 460px; } |
|
.ui__bar { background: var(--hover); padding: 9px 14px; font-size: 12px; font-weight: 700; color: var(--c-muted); |
|
border-bottom: 1px solid var(--hair-soft); } |
|
.ui__body { padding: 14px; display: grid; gap: 8px; } |
|
.ui__row { display: flex; justify-content: space-between; gap: 12px; font-size: 13px; |
|
padding: 9px 12px; border-radius: 12px; background: var(--hover); } |
|
.ui__row--new { background: var(--accent-soft); color: var(--c-accent); font-weight: 700; } |
|
.ui__ghost { height: 9px; border-radius: 9999px; background: var(--hover); } |
|
|
|
/* ---------- layer badges ---------- */ |
|
.layer { |
|
font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; |
|
padding: 3px 9px; border-radius: 9999px; background: var(--hover); color: var(--c-dim); |
|
} |
|
.layer--product { background: rgba(59,130,246,.15); color: var(--info); } |
|
.layer--reviewer { background: var(--accent-soft); color: var(--c-accent); } |
|
.layer--deep { background: var(--hover); color: var(--c-muted); } |
|
|
|
/* ---------- collapsible ---------- */ |
|
details.more { |
|
background: var(--c-surface); border-radius: 18px; margin: 18px 0; overflow: hidden; |
|
box-shadow: inset 0 0 0 1px var(--hair); |
|
} |
|
details.more > summary { |
|
cursor: pointer; padding: 15px 20px; font-weight: 700; font-size: 14.5px; list-style: none; |
|
display: flex; align-items: center; gap: 10px; |
|
} |
|
details.more > summary::-webkit-details-marker { display: none; } |
|
details.more > summary::before { |
|
content: '▸'; color: var(--c-accent); font-size: 12px; transition: transform .2s var(--ease); |
|
} |
|
details.more[open] > summary::before { transform: rotate(90deg); } |
|
details.more > summary:hover { background: var(--hover); } |
|
details.more .more__body { padding: 0 20px 18px; } |
|
details.more .more__body > :first-child { margin-top: 0; } |
|
|
|
/* ---------- mockup panel: before/after wipe, and the strip fallback ---------- |
|
Both are driven by the JSON inside .mockup; see references/mockup-panel.md. |
|
Everything here is images and one clip-path — no iframe, no mockup JS. */ |
|
.mockup { margin: 18px 0 4px; } |
|
.mockup__bar { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 12px; } |
|
.mockup__src { margin-left: auto; font-family: ui-monospace, Menlo, monospace; |
|
font-size: 12px; color: var(--c-dim); } |
|
.mockup__sides { display: flex; justify-content: space-between; margin: 0 4px 8px; } |
|
.mockup__box { background: var(--c-surface); border-radius: 18px; padding: 10px; |
|
box-shadow: inset 0 0 0 1px var(--hair), var(--shadow); } |
|
.wipe { position: relative; overflow: hidden; border-radius: 11px; |
|
background: var(--c-canvas); touch-action: none; user-select: none; } |
|
.wipe__side { position: absolute; top: 0; left: 0; width: 100%; height: 100%; |
|
transform: translateZ(0); backface-visibility: hidden; } |
|
.wipe__side img { display: block; width: 100%; height: auto; } |
|
.wipe__side--after { will-change: clip-path; } |
|
.wipe__grip { position: absolute; top: 0; bottom: 0; left: 0; width: 2px; z-index: 9; |
|
background: var(--c-accent); cursor: col-resize; will-change: transform; |
|
box-shadow: 0 0 0 1px rgba(0,0,0,0.45); } |
|
.wipe__knob { position: absolute; top: 50%; left: 50%; transform: translate(-50%,-50%); |
|
width: 36px; height: 36px; border-radius: 9999px; display: grid; place-items: center; |
|
background: var(--c-accent); color: var(--c-accent-ink); |
|
font-weight: 800; font-size: 14px; box-shadow: 0 6px 18px rgba(0,0,0,0.45); } |
|
.wipe--sbs { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; height: auto !important; } |
|
.wipe--sbs .wipe__side { position: relative; width: auto; height: auto; clip-path: none !important; } |
|
.wipe--sbs .wipe__grip { display: none; } |
|
|
|
/* A ring marks one changed region. Never faded to "soften" it — see the design |
|
system; a ghost (the container a removal left) is dashed instead. */ |
|
.rings { position: absolute; inset: 0; pointer-events: none; z-index: 6; } |
|
.ring { position: absolute; border-radius: 9px; transition: background .2s var(--ease); } |
|
.ring[data-kind=removed] { box-shadow: 0 0 0 2px var(--danger); } |
|
.ring[data-kind=changed] { box-shadow: 0 0 0 2px var(--c-accent); } |
|
.ring[data-kind=renamed] { box-shadow: 0 0 0 2px var(--info); } |
|
.ring[data-kind=added] { box-shadow: 0 0 0 2px var(--ok); } |
|
.ring--ghost { box-shadow: none !important; outline: 2px dashed var(--danger); border-radius: 11px; } |
|
.ring.is-hot { background: rgba(251,191,36,0.14); } |
|
.ring.is-hot[data-kind=removed] { background: rgba(239,68,68,0.16); } |
|
.ring.is-hot[data-kind=renamed] { background: rgba(59,130,246,0.16); } |
|
.ring.is-hot[data-kind=added] { background: rgba(34,197,94,0.16); } |
|
|
|
.chips { display: flex; flex-wrap: wrap; gap: 7px; margin-top: 14px; } |
|
.chg { display: inline-flex; align-items: center; gap: 8px; padding: 6px 12px 6px 8px; |
|
border-radius: 9999px; background: var(--c-surface); cursor: pointer; font: inherit; |
|
font-size: 12.5px; color: var(--c-muted); border: 0; |
|
box-shadow: inset 0 0 0 1px var(--hair); |
|
transition: background .16s var(--ease), box-shadow .16s var(--ease); } |
|
.chg:hover, .chg.is-on { background: var(--c-surface2); color: var(--c-fg); |
|
box-shadow: inset 0 0 0 1.5px var(--c-accent); } |
|
.chg i { width: 8px; height: 8px; border-radius: 3px; display: inline-block; } |
|
.chg[data-kind=removed] i { background: var(--danger); } |
|
.chg[data-kind=changed] i { background: var(--c-accent); } |
|
.chg[data-kind=renamed] i { background: var(--info); } |
|
.chg[data-kind=added] i { background: var(--ok); } |
|
.chg[data-kind=nonvisual] i { background: transparent; box-shadow: inset 0 0 0 1.5px var(--c-dim); } |
|
.detail { margin-top: 14px; background: var(--c-surface); border-radius: 16px; |
|
padding: 16px 18px; box-shadow: inset 0 0 0 1px var(--hair); min-height: 92px; } |
|
.detail__title { font-weight: 700; font-size: 15px; } |
|
.detail__why { color: var(--c-muted); font-size: 13.6px; margin: 6px 0 0; } |
|
.detail__files { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 10px; } |
|
.filechip { font-family: ui-monospace, Menlo, monospace; font-size: 10.5px; color: var(--c-dim); |
|
background: var(--c-surface2); padding: 2px 7px; border-radius: 6px; |
|
box-shadow: inset 0 0 0 1px var(--hair-soft); } |
|
|
|
/* Strip fallback: one cropped before/after pair per change, out of the same stills. */ |
|
.strip { display: grid; grid-template-columns: repeat(auto-fit, minmax(420px, 1fr)); gap: 16px; } |
|
@media (max-width: 900px) { .strip { grid-template-columns: 1fr; } } |
|
.sc { background: var(--c-surface); border-radius: 18px; overflow: hidden; |
|
box-shadow: inset 0 0 0 1px var(--hair); } |
|
.sc__head { padding: 15px 18px 12px; display: grid; grid-template-columns: 26px 1fr; gap: 11px; } |
|
.sc__n { width: 26px; height: 26px; border-radius: 9999px; display: grid; place-items: center; |
|
font-size: 12px; font-weight: 800; background: var(--c-accent); color: var(--c-accent-ink); } |
|
.sc[data-kind=removed] .sc__n { background: var(--danger); color: #fff; } |
|
.sc[data-kind=renamed] .sc__n { background: var(--info); color: #fff; } |
|
.sc[data-kind=added] .sc__n { background: var(--ok); color: #08290f; } |
|
.sc[data-kind=nonvisual] .sc__n { background: transparent; color: var(--c-dim); |
|
box-shadow: inset 0 0 0 1.5px var(--hair); } |
|
.sc__body { padding: 0 18px 14px 55px; } |
|
.sc__why { color: var(--c-muted); font-size: 13px; margin: 0; } |
|
.sc__pair { display: grid; grid-template-columns: 1fr 1fr; gap: 1px; background: var(--hair-soft); } |
|
.sc__half { position: relative; overflow: hidden; background: var(--c-canvas); } |
|
.sc__lbl { position: absolute; top: 8px; left: 10px; z-index: 4; font-size: 9.5px; font-weight: 800; |
|
letter-spacing: .09em; text-transform: uppercase; color: var(--c-dim); |
|
background: rgba(13,10,8,0.85); padding: 3px 7px; border-radius: 6px; } |
|
.crop { position: relative; overflow: hidden; height: 186px; background: var(--c-canvas); } |
|
.crop img { position: absolute; top: 0; left: 0; max-width: none; } |
|
.sc__none { padding: 18px 18px 20px 55px; color: var(--c-dim); font-size: 13px; } |
|
|
|
/* Ballot: candidate designs from a comparison mockup. Static markup, no script. */ |
|
.ballot { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 14px; } |
|
.vb { display: flex; flex-direction: column; background: var(--c-surface); border-radius: 18px; |
|
overflow: hidden; box-shadow: inset 0 0 0 1px var(--hair); } |
|
.vb--chosen { box-shadow: inset 0 0 0 1.5px var(--c-accent), var(--shadow); } |
|
.vb__head { padding: 14px 16px 10px; display: flex; align-items: center; gap: 9px; |
|
justify-content: space-between; } |
|
.vb__head h3 { margin: 0; font-size: 14.5px; font-weight: 700; } |
|
.vb img { display: block; width: 100%; height: auto; |
|
border-top: 1px solid var(--hair-soft); border-bottom: 1px solid var(--hair-soft); } |
|
.vb__body { padding: 12px 16px 16px; } |
|
.vb ul { margin: 0; padding: 0; list-style: none; font-size: 12.7px; color: var(--c-muted); } |
|
.vb li { display: grid; grid-template-columns: 14px 1fr; gap: 7px; margin-top: 6px; line-height: 1.45; } |
|
.vb li b { font-weight: 800; } |
|
.vb .plus b { color: var(--ok); } |
|
.vb .minus b { color: var(--danger); } |
|
|
|
/* ---------- footer ---------- */ |
|
.foot { margin-top: 60px; padding-top: 24px; border-top: 1px solid var(--hair-soft); |
|
font-size: 12.5px; color: var(--c-dim); display: flex; flex-wrap: wrap; gap: 8px 22px; } |
|
|
|
/* ---------- Soft-Dock entrance ---------- */ |
|
@keyframes rise { from { opacity: 0; transform: translateY(16px); } to { opacity: 1; transform: none; } } |
|
.rise { animation: rise .62s var(--ease) both; animation-delay: calc(var(--i, 0) * 60ms); } |
|
@media (prefers-reduced-motion: reduce) { |
|
html { scroll-behavior: auto; } |
|
@keyframes rise { from { opacity: 0; } to { opacity: 1; } } |
|
.rise { animation-duration: .3s; } |
|
} |
|
/* ---------- print ---------- |
|
Enough that Cmd-P in a browser gives something honest: the page keeps its |
|
own colours, the sidebar goes, and nothing sits in a sideways scroller where |
|
the overflow would simply be absent on paper. |
|
|
|
This is deliberately the short version. `/explain-pr --pdf` does not rely on |
|
it — references/render-pdf.mjs injects references/print.css, which is the |
|
full treatment (cover page, per-section page breaks, mockup panels rebuilt |
|
as static stills with a printed legend) and reaches explainers generated |
|
before any of it existed. Add rules THERE, not here. */ |
|
@media print { |
|
*, *::before, *::after { -webkit-print-color-adjust: exact; print-color-adjust: exact; } |
|
body { background: var(--c-canvas); background-image: none; color: var(--c-fg); } |
|
.toc { display: none; } |
|
.wrap { max-width: none; padding: 0 12mm 8mm; } |
|
.layout { display: block; } |
|
pre.code { white-space: pre-wrap; overflow: visible; overflow-wrap: anywhere; } |
|
.table-scroll { overflow: visible; } |
|
table.data { min-width: 0; } |
|
.card, .callout, .diagram, .mockup, pre.code, table.data tr { break-inside: avoid; } |
|
.rise { animation: none !important; opacity: 1 !important; transform: none !important; } |
|
} |
|
</style> |
|
</head> |
|
<body> |
|
|
|
<!-- |
|
============================================================================ |
|
MARKUP VOCABULARY — everything below is already styled. Compose from these; |
|
do not invent new classes or inline colors, and never use a raw hex. |
|
|
|
SECTION <section class="sec" id="slug" data-layer="product|reviewer|deep"> |
|
<div class="sec__head"><h2>Title</h2></div> <- layer badge auto-injected |
|
<p class="sec__lede">One-line framing.</p> |
|
PILL <span class="pill pill--ok|warn|danger|info|accent|mono">text</span> |
|
CARD <div class="card"><p class="card__title">..</p>..</div> |
|
GRID <div class="grid2|grid3">..cards/stats..</div> |
|
STAT <div class="stat stat--warn"><div class="stat__num">7</div> |
|
<div class="stat__lbl">files with no test</div></div> |
|
CALLOUT <div class="callout callout--warn"><div class="callout__icon">!</div> |
|
<div class="callout__body"><strong class="callout__title">..</strong> |
|
<p>..</p></div></div> |
|
CODE <span class="code__cap">path/to/file.ts</span> |
|
<pre class="code" data-lang="ts"><span class="ln">context line</span><span |
|
class="del">- old line</span><span class="add">+ new line</span></pre> |
|
EVERY line gets .ln / .add / .del, and there is NO newline between the |
|
spans — a newline between two block spans renders as a blank line. |
|
Escape < > & inside code. |
|
data-lang="ts" (default) | "sql" | "graphql" — syntax highlighting is |
|
applied by the script; do NOT hand-colour tokens. A GraphQL SDL block |
|
and a block of SQL lifted out of a .sql.ts file each need their own |
|
data-lang, whatever the enclosing file's extension is. |
|
TABLE <div class="table-scroll"><table class="data"><thead>..<tbody>..</table></div> |
|
status cell: <td class="nowrap"><span class="dot dot--ok"></span>label</td> |
|
(.nowrap keeps a short verdict label off two lines; .mono for paths) |
|
KV <dl class="kv"><dt>Key</dt><dd>Value</dd></dl> |
|
DIAGRAM <div class="diagram"><div class="flow"> |
|
<div class="node"><div class="node__label">Client</div> |
|
<div class="node__title">reports.tsx</div> |
|
<div class="node__data">branchId: 42</div></div> |
|
<div class="arrow">→<span class="arrow__lbl">query</span></div> |
|
..</div><p class="diagram__cap">Fig 1. ..</p></div> |
|
(.flow--v stacks vertically; .node--accent highlights the changed one) |
|
UI MOCK <div class="ui"><div class="ui__bar">Reports</div><div class="ui__body"> |
|
<div class="ui__row">Row <span>$12.00</span></div> |
|
<div class="ui__row ui__row--new">New row <span>$4.00</span></div></div></div> |
|
MOCKUP For a PR that carries a real mockup. Two baked stills of the same |
|
screen, wiped between, with a ring on every region the PR changed. |
|
<div class="mockup" data-shape="wipe"> <- or "strip" |
|
<img data-state="before" alt=".." src="data:image/webp;base64,.."> |
|
<img data-state="after" alt=".." src="data:image/webp;base64,.."> |
|
<script type="application/json">{ |
|
"source": "docs/mockups/x.html", |
|
"beforeLabel": "Today", "afterLabel": "Proposed", |
|
"before": { "w": 1180, "h": 1009, "rects": { "teams": {"x":336,"y":48,"w":260,"h":34} } }, |
|
"after": { "w": 1180, "h": 898, "rects": { "teams": {"x":365,"y":48,"w":286,"h":34} } }, |
|
"changes": [{ "id":"teams", "n":"03", "kind":"changed", |
|
"label":"Teams multi-select", "title":"..", "why":"..", |
|
"files":["report-composer.tsx"] }] |
|
}<\/script> |
|
</div> |
|
kind: removed | changed | renamed | added | nonvisual. |
|
"ghost": true on a removal draws its "after" ring dashed, on the |
|
container the thing left. A change with no rect in a state simply |
|
gets no ring there. HOW TO BAKE THE STILLS AND MEASURE THE RECTS: |
|
references/mockup-panel.md — do not eyeball coordinates. |
|
BALLOT Static markup, for a mockup that compares candidate designs. |
|
<div class="ballot"><div class="vb vb--chosen"> |
|
<div class="vb__head"><h3>A · ..</h3><span class="pill pill--accent">shipped</span></div> |
|
<img alt=".." src="data:image/webp;base64,.."> |
|
<div class="vb__body"><ul> |
|
<li class="plus"><b>+</b><span>..</span></li> |
|
<li class="minus"><b>−</b><span>..</span></li> |
|
</ul></div></div>..</div> |
|
COLLAPSE <details class="more"><summary>Skip unless..</summary> |
|
<div class="more__body">..</div></details> |
|
ANIMATE add class="rise" and style="--i:2" to stagger a group's entrance |
|
============================================================================ |
|
--> |
|
|
|
<div class="wrap"> |
|
|
|
<header class="hero rise"> |
|
<div class="hero__eyebrow"> |
|
<!-- FILL: one pill per PR ref, plus state --> |
|
{{REF_PILLS}} |
|
</div> |
|
<h1>{{TITLE}}</h1> |
|
<p class="hero__sub">{{ONE_LINE_SUMMARY}}</p> |
|
<div class="hero__meta">{{META}}</div> |
|
</header> |
|
|
|
<div class="layout"> |
|
<nav class="toc" aria-label="Contents"> |
|
<p class="toc__title">Contents</p> |
|
<div id="toc-links"><!-- auto-generated from sections --></div> |
|
</nav> |
|
|
|
<main> |
|
<!-- FILL: sections, in this order. |
|
1 what-shipped data-layer="product" |
|
2 contract data-layer="reviewer" |
|
3 blast-radius data-layer="reviewer" |
|
4 test-map data-layer="reviewer" |
|
5 background data-layer="deep" |
|
6 intuition data-layer="deep" |
|
7 code data-layer="deep" |
|
--> |
|
{{SECTIONS}} |
|
|
|
<footer class="foot"> |
|
<span>Generated {{GENERATED_AT}}</span> |
|
<span>{{SOURCE_REFS}}</span> |
|
<span>Uptip PR explainer</span> |
|
</footer> |
|
</main> |
|
</div> |
|
</div> |
|
|
|
<script> |
|
(function () { |
|
'use strict'; |
|
|
|
/* ====================================================================== |
|
Syntax highlighting. |
|
|
|
A small tokenizer rather than a CDN library, for two reasons: these pages |
|
are opened from disk and must not need the network, and the colours have to |
|
be the design system's rather than a vendor theme's. |
|
|
|
Languages are the three that turn up in uptip diffs. `data-lang` on the |
|
<pre> picks one ('ts' | 'sql' | 'graphql'); 'ts' is the default. Rules are |
|
tried in order at the current position, so comments must precede strings and |
|
keywords must precede the catch-all identifier — which exists to consume a |
|
whole word, guaranteeing the scanner only ever sits at a token boundary and |
|
can never match a keyword inside an identifier. |
|
====================================================================== */ |
|
var PLAIN = null; |
|
var IDENT = [/^[A-Za-z_$][\w$]*/, PLAIN]; |
|
var WS = [/^\s+/, PLAIN]; |
|
|
|
var RULES = { |
|
ts: [ |
|
WS, |
|
[/^\/\/.*/, 't-c'], |
|
[/^\/\*.*?\*\//, 't-c'], |
|
[/^\/\*.*/, 't-c'], |
|
[/^`(?:\\.|[^`\\])*`/, 't-s'], |
|
[/^'(?:\\.|[^'\\])*'/, 't-s'], |
|
[/^"(?:\\.|[^"\\])*"/, 't-s'], |
|
[/^\/(?:\\.|\[(?:\\.|[^\]\\])*\]|[^/\\])+\/[gimsuy]*/, 't-s'], |
|
[/^\d[\d_]*(?:\.\d+)?\b/, 't-n'], |
|
[/^(?:abstract|as|async|await|boolean|break|case|catch|class|const|constructor|continue|declare|default|delete|do|else|enum|export|extends|false|finally|for|from|function|get|if|implements|import|in|instanceof|interface|is|keyof|let|new|null|number|of|private|protected|public|readonly|return|set|static|string|super|switch|this|throw|true|try|type|typeof|undefined|var|void|while|yield)\b/, 't-k'], |
|
[/^[A-Za-z_$][\w$]*(?=\s*\()/, 't-f'], |
|
IDENT, |
|
[/^[{}()[\].,;:?!<>=+\-*/%&|^~]+/, 't-p'], |
|
], |
|
sql: [ |
|
WS, |
|
[/^--.*/, 't-c'], |
|
[/^'(?:''|[^'])*'/, 't-s'], |
|
[/^\d+(?:\.\d+)?\b/, 't-n'], |
|
[/^(?:ADD|ALL|ALTER|AND|AS|ASC|BINARY|BY|CASE|CAST|CHAR|COALESCE|CONCAT|COUNT|CREATE|DESC|DISTINCT|ELSE|END|EXISTS|FROM|GROUP|GROUP_CONCAT|HAVING|IF|IN|INDEX|INNER|IS|JOIN|LEADING|LEFT|LIKE|LIMIT|NOT|NULL|ON|OR|ORDER|OUTER|REPLACE|SELECT|SEPARATOR|SET|THEN|TRIM|UNIQUE|UPDATE|WHEN|WHERE|WITH)\b/i, 't-k'], |
|
[/^[A-Za-z_][\w]*(?=\s*\()/, 't-f'], |
|
[/^[A-Za-z_][\w]*/, PLAIN], |
|
[/^[{}()[\].,;:?!<>=+\-*/%&|^~]+/, 't-p'], |
|
], |
|
graphql: [ |
|
WS, |
|
[/^"""[\s\S]*?"""/, 't-s'], |
|
[/^""".*/, 't-s'], |
|
[/^#.*/, 't-c'], |
|
[/^"(?:\\.|[^"\\])*"/, 't-s'], |
|
[/^"[^"]*$/, 't-s'], |
|
[/^@[A-Za-z_]\w*/, 't-k'], |
|
[/^\d+(?:\.\d+)?\b/, 't-n'], |
|
[/^(?:type|input|enum|interface|union|scalar|schema|query|mutation|subscription|fragment|on|implements|extend|directive|String|Int|Float|Boolean|ID|true|false|null)\b/, 't-k'], |
|
IDENT, |
|
[/^[{}()[\].,;:?!<>=+\-*/%&|^~]+/, 't-p'], |
|
], |
|
}; |
|
|
|
function esc(s) { |
|
return s.replace(/[&<>]/g, function (c) { |
|
return c === '&' ? '&' : c === '<' ? '<' : '>'; |
|
}); |
|
} |
|
|
|
function highlightLine(text, rules) { |
|
var out = ''; |
|
var rest = text; |
|
|
|
// The leading +/- of a diff line is a marker, not code — pull it off first so |
|
// the tokenizer never reads it as an operator. |
|
var mark = rest.match(/^([+-] ?)/); |
|
if (mark) { |
|
out += '<span class="t-d">' + esc(mark[1]) + '</span>'; |
|
rest = rest.slice(mark[1].length); |
|
} |
|
|
|
while (rest.length) { |
|
var matched = false; |
|
for (var i = 0; i < rules.length; i++) { |
|
var hit = rules[i][0].exec(rest); |
|
if (!hit || !hit[0].length) continue; |
|
var cls = rules[i][1]; |
|
out += cls ? '<span class="' + cls + '">' + esc(hit[0]) + '</span>' : esc(hit[0]); |
|
rest = rest.slice(hit[0].length); |
|
matched = true; |
|
break; |
|
} |
|
if (!matched) { out += esc(rest[0]); rest = rest.slice(1); } |
|
} |
|
return out; |
|
} |
|
|
|
try { |
|
Array.prototype.forEach.call(document.querySelectorAll('pre.code'), function (pre) { |
|
var rules = RULES[pre.getAttribute('data-lang') || 'ts'] || RULES.ts; |
|
Array.prototype.forEach.call(pre.children, function (line) { |
|
if (!/\b(?:ln|add|del|hl)\b/.test(line.className)) return; |
|
line.innerHTML = highlightLine(line.textContent, rules); |
|
}); |
|
}); |
|
} catch (e) { |
|
/* A highlighting failure must never cost the page its contents list. */ |
|
} |
|
|
|
/* ====================================================================== |
|
Mockup panel. |
|
|
|
Markup contract, per panel (see references/mockup-panel.md for how to |
|
produce the stills and the geometry): |
|
|
|
<div class="mockup" data-shape="wipe|strip"> |
|
<img data-state="before" alt=".." src="data:image/webp;base64,.."> |
|
<img data-state="after" alt=".." src="data:image/webp;base64,.."> |
|
<script type="application/json">{ "before": {..}, "after": {..}, |
|
"changes": [..] }<\/script> |
|
</div> |
|
|
|
`before`/`after` are { w, h, rects: { <changeId>: {x,y,w,h} } } measured |
|
against the stage box at the width the still was taken. Rects are scaled |
|
to whatever width the panel renders at, so the page stays responsive. |
|
|
|
A change with no rect in a state gets no ring in that state. That is the |
|
rule, not a fallback: a ring whose element could not be found would be a |
|
guess, and the caption says "no surface in this mockup" instead. |
|
====================================================================== */ |
|
function buildMockups() { |
|
Array.prototype.forEach.call(document.querySelectorAll('.mockup'), function (root) { |
|
var json = root.querySelector('script[type="application/json"]'); |
|
var imgs = { before: root.querySelector('img[data-state=before]'), |
|
after: root.querySelector('img[data-state=after]') }; |
|
if (!json || !imgs.before || !imgs.after) return; |
|
var data; |
|
try { data = JSON.parse(json.textContent); } catch (e) { return; } |
|
var src = { before: imgs.before.getAttribute('src'), after: imgs.after.getAttribute('src') }; |
|
var alt = { before: imgs.before.getAttribute('alt') || 'before', |
|
after: imgs.after.getAttribute('alt') || 'after' }; |
|
imgs.before.remove(); |
|
imgs.after.remove(); |
|
json.remove(); |
|
if (root.getAttribute('data-shape') === 'strip') buildStrip(root, data, src, alt); |
|
else buildWipe(root, data, src, alt); |
|
}); |
|
} |
|
|
|
function ringEl(change, rect, ghost) { |
|
var d = document.createElement('div'); |
|
d.className = 'ring' + (ghost ? ' ring--ghost' : ''); |
|
d.setAttribute('data-kind', change.kind); |
|
d.setAttribute('data-id', change.id); |
|
d.rect = rect; |
|
return d; |
|
} |
|
|
|
function ringLayer(data, state, changes) { |
|
var host = document.createElement('div'); |
|
host.className = 'rings'; |
|
changes.forEach(function (c) { |
|
var rect = (data[state].rects || {})[c.id]; |
|
if (!rect) return; |
|
host.appendChild(ringEl(c, rect, !!c.ghost && state === 'after')); |
|
}); |
|
return host; |
|
} |
|
|
|
function buildWipe(root, data, src, alt) { |
|
var changes = data.changes || []; |
|
var maxH = Math.max(data.before.h, data.after.h); |
|
var split = 0.5, mode = 'wipe', pinned = null, boxW = 0, scale = 1; |
|
|
|
var bar = document.createElement('div'); |
|
bar.className = 'mockup__bar'; |
|
bar.innerHTML = |
|
'<div class="seg"><button data-mode="wipe" class="is-on">Wipe</button>' + |
|
'<button data-mode="flip">Flip</button>' + |
|
'<button data-mode="sbs">Side by side</button></div>' + |
|
(data.source ? '<span class="mockup__src">' + data.source + '</span>' : ''); |
|
|
|
var sides = document.createElement('div'); |
|
sides.className = 'mockup__sides'; |
|
sides.innerHTML = '<span class="pill">◀ ' + (data.beforeLabel || 'Before') + '</span>' + |
|
'<span class="pill pill--accent">' + (data.afterLabel || 'After') + ' ▶</span>'; |
|
|
|
var box = document.createElement('div'); |
|
box.className = 'mockup__box'; |
|
var wipe = document.createElement('div'); |
|
wipe.className = 'wipe'; |
|
box.appendChild(wipe); |
|
|
|
var layers = {}; |
|
['before', 'after'].forEach(function (state) { |
|
var side = document.createElement('div'); |
|
side.className = 'wipe__side wipe__side--' + state; |
|
var im = document.createElement('img'); |
|
im.src = src[state]; |
|
im.alt = alt[state]; |
|
side.appendChild(im); |
|
side.appendChild(ringLayer(data, state, changes)); |
|
wipe.appendChild(side); |
|
layers[state] = side; |
|
}); |
|
|
|
var grip = document.createElement('div'); |
|
grip.className = 'wipe__grip'; |
|
grip.innerHTML = '<div class="wipe__knob">↔</div>'; |
|
wipe.appendChild(grip); |
|
|
|
var chips = document.createElement('div'); |
|
chips.className = 'chips'; |
|
var detail = document.createElement('div'); |
|
detail.className = 'detail'; |
|
|
|
root.appendChild(bar); |
|
root.appendChild(sides); |
|
root.appendChild(box); |
|
root.appendChild(chips); |
|
root.appendChild(detail); |
|
|
|
function layout() { |
|
boxW = wipe.clientWidth; |
|
if (!boxW) return; |
|
if (mode === 'sbs') { scale = ((boxW - 10) / 2) / data.before.w; wipe.style.height = ''; } |
|
else { scale = boxW / data.before.w; wipe.style.height = Math.round(maxH * scale) + 'px'; } |
|
var pad = 5 * scale; |
|
['before', 'after'].forEach(function (state) { |
|
Array.prototype.forEach.call(layers[state].querySelectorAll('.ring'), function (r) { |
|
r.style.left = (r.rect.x * scale - pad) + 'px'; |
|
r.style.top = (r.rect.y * scale - pad) + 'px'; |
|
r.style.width = (r.rect.w * scale + pad * 2) + 'px'; |
|
r.style.height = (r.rect.h * scale + pad * 2) + 'px'; |
|
}); |
|
}); |
|
applySplit(); |
|
} |
|
function applySplit() { |
|
if (mode !== 'wipe') { layers.after.style.clipPath = 'none'; return; } |
|
var px = Math.round(boxW * split); |
|
layers.after.style.clipPath = 'inset(0 0 0 ' + px + 'px)'; |
|
grip.style.transform = 'translateX(' + px + 'px)'; |
|
} |
|
|
|
/* Dragging is coalesced into one animation frame. Nothing polls: the only |
|
other things that move the rings are a resize and a mode change. */ |
|
var down = false, pendingX = null, raf = null; |
|
function commit() { |
|
raf = null; |
|
if (pendingX == null) return; |
|
var r = wipe.getBoundingClientRect(); |
|
split = Math.max(0.01, Math.min(0.99, (pendingX - r.left) / r.width)); |
|
pendingX = null; |
|
applySplit(); |
|
} |
|
function move(ev) { |
|
if (!down) return; |
|
pendingX = ev.touches ? ev.touches[0].clientX : ev.clientX; |
|
if (!raf) raf = requestAnimationFrame(commit); |
|
if (ev.cancelable) ev.preventDefault(); |
|
} |
|
grip.addEventListener('mousedown', function (e) { if (mode === 'wipe') { down = true; move(e); e.preventDefault(); } }); |
|
grip.addEventListener('touchstart', function (e) { if (mode === 'wipe') { down = true; move(e); } }, { passive: false }); |
|
window.addEventListener('mousemove', move); |
|
window.addEventListener('touchmove', move, { passive: false }); |
|
window.addEventListener('mouseup', function () { down = false; }); |
|
window.addEventListener('touchend', function () { down = false; }); |
|
|
|
var flipTimer = null, flipOn = true; |
|
bar.querySelector('.seg').addEventListener('click', function (ev) { |
|
var b = ev.target.closest('button'); |
|
if (!b) return; |
|
Array.prototype.forEach.call(this.children, function (x) { x.classList.toggle('is-on', x === b); }); |
|
mode = b.getAttribute('data-mode'); |
|
wipe.classList.toggle('wipe--sbs', mode === 'sbs'); |
|
if (flipTimer) { clearInterval(flipTimer); flipTimer = null; } |
|
layers.after.style.display = ''; |
|
if (mode === 'flip') { |
|
flipTimer = setInterval(function () { |
|
flipOn = !flipOn; |
|
layers.after.style.display = flipOn ? '' : 'none'; |
|
}, 1500); |
|
} |
|
layout(); |
|
}); |
|
|
|
function light(id) { |
|
Array.prototype.forEach.call(wipe.querySelectorAll('.ring'), function (r) { |
|
r.classList.toggle('is-hot', r.getAttribute('data-id') === id); |
|
}); |
|
Array.prototype.forEach.call(chips.children, function (b) { |
|
b.classList.toggle('is-on', b.getAttribute('data-id') === id); |
|
}); |
|
var c = null; |
|
changes.forEach(function (x) { if (x.id === id) c = x; }); |
|
detail.innerHTML = c |
|
? '<div class="detail__title">' + (c.n ? c.n + ' · ' : '') + c.title + '</div>' + |
|
'<p class="detail__why">' + c.why + '</p>' + |
|
(c.kind === 'nonvisual' |
|
? '<p class="detail__why" style="color:var(--c-dim)"><b>No surface in this mockup</b> — nothing to ring.</p>' |
|
: '') + |
|
'<div class="detail__files">' + (c.files || []).map(function (f) { |
|
return '<span class="filechip">' + f + '</span>'; |
|
}).join('') + '</div>' |
|
: (data.detailDefault || ''); |
|
} |
|
|
|
changes.forEach(function (c) { |
|
var b = document.createElement('button'); |
|
b.className = 'chg'; |
|
b.setAttribute('data-kind', c.kind); |
|
b.setAttribute('data-id', c.id); |
|
b.innerHTML = '<i></i>' + (c.label || c.title); |
|
b.addEventListener('mouseenter', function () { light(c.id); }); |
|
b.addEventListener('mouseleave', function () { light(pinned); }); |
|
b.addEventListener('click', function () { |
|
pinned = (pinned === c.id) ? null : c.id; |
|
light(pinned || c.id); |
|
}); |
|
chips.appendChild(b); |
|
}); |
|
light(null); |
|
|
|
var rz = null; |
|
window.addEventListener('resize', function () { |
|
if (rz) return; |
|
rz = requestAnimationFrame(function () { rz = null; layout(); }); |
|
}); |
|
layout(); |
|
/* Fonts and image decode can both land after the first layout. */ |
|
window.addEventListener('load', layout); |
|
} |
|
|
|
/* The strip is the same two stills, cropped per change. Both halves share |
|
one scale — set by the smaller anchor, normally the "before" one — or a |
|
removal's container anchor zooms the "after" half out to nothing. */ |
|
function buildStrip(root, data, src, alt) { |
|
var host = document.createElement('div'); |
|
host.className = 'strip'; |
|
root.appendChild(host); |
|
|
|
(data.changes || []).forEach(function (c) { |
|
var card = document.createElement('div'); |
|
card.className = 'sc'; |
|
card.setAttribute('data-kind', c.kind); |
|
card.innerHTML = |
|
'<div class="sc__head"><div class="sc__n">' + (c.kind === 'nonvisual' ? '·' : (c.n || '')) + '</div>' + |
|
'<div><h3>' + c.title + '</h3></div></div>' + |
|
'<div class="sc__body"><p class="sc__why">' + c.why + '</p>' + |
|
'<div class="detail__files">' + (c.files || []).map(function (f) { |
|
return '<span class="filechip">' + f + '</span>'; |
|
}).join('') + '</div></div>'; |
|
host.appendChild(card); |
|
|
|
if (c.kind === 'nonvisual') { |
|
var none = document.createElement('div'); |
|
none.className = 'sc__none'; |
|
none.textContent = c.noSurface || 'No visual surface — this change has nothing to point at.'; |
|
card.appendChild(none); |
|
return; |
|
} |
|
|
|
var pair = document.createElement('div'); |
|
pair.className = 'sc__pair'; |
|
card.appendChild(pair); |
|
|
|
['before', 'after'].forEach(function (state) { |
|
var half = document.createElement('div'); |
|
half.className = 'sc__half'; |
|
half.innerHTML = '<span class="sc__lbl">' + |
|
(state === 'before' ? (data.beforeLabel || 'before') : (data.afterLabel || 'after')) + '</span>'; |
|
var crop = document.createElement('div'); |
|
crop.className = 'crop'; |
|
half.appendChild(crop); |
|
pair.appendChild(half); |
|
|
|
var rect = (data[state].rects || {})[c.id]; |
|
if (!rect) { |
|
crop.innerHTML = '<div class="sc__none" style="padding:16px">not present in this state</div>'; |
|
return; |
|
} |
|
requestAnimationFrame(function () { |
|
var bw = crop.clientWidth, bh = crop.clientHeight; |
|
var ref = (data.before.rects || {})[c.id] || rect; |
|
var pad = c.pad || 26; |
|
var k = Math.max(0.3, Math.min(1.6, bw / (ref.w + pad * 2))); |
|
var tx = bw / 2 - (rect.x + rect.w / 2) * k; |
|
var ty = bh / 2 - (rect.y + rect.h / 2) * k; |
|
tx = Math.min(0, Math.max(tx, bw - data[state].w * k)); |
|
ty = Math.min(0, Math.max(ty, bh - data[state].h * k)); |
|
var im = document.createElement('img'); |
|
im.src = src[state]; |
|
im.alt = c.title + ' — ' + alt[state]; |
|
im.style.width = (data[state].w * k) + 'px'; |
|
im.style.transform = 'translate(' + tx + 'px,' + ty + 'px)'; |
|
crop.appendChild(im); |
|
var r = ringEl(c, rect, !!c.ghost && state === 'after'); |
|
r.style.left = (tx + rect.x * k - 4) + 'px'; |
|
r.style.top = (ty + rect.y * k - 4) + 'px'; |
|
r.style.width = (rect.w * k + 8) + 'px'; |
|
r.style.height = (rect.h * k + 8) + 'px'; |
|
crop.appendChild(r); |
|
}); |
|
}); |
|
}); |
|
} |
|
|
|
try { buildMockups(); } catch (e) { |
|
/* A malformed panel must not cost the page its contents list. */ |
|
} |
|
|
|
var LAYER_NAMES = { product: 'Product', reviewer: 'Reviewer', deep: 'Deep dive' }; |
|
var sections = Array.prototype.slice.call(document.querySelectorAll('section.sec')); |
|
|
|
// Layer badges + table of contents, both derived from the sections themselves. |
|
var tocBox = document.getElementById('toc-links'); |
|
var lastLayer = null; |
|
sections.forEach(function (sec) { |
|
var layer = sec.getAttribute('data-layer'); |
|
var head = sec.querySelector('.sec__head'); |
|
if (layer && head && !head.querySelector('.layer')) { |
|
var badge = document.createElement('span'); |
|
badge.className = 'layer layer--' + layer; |
|
badge.textContent = LAYER_NAMES[layer] || layer; |
|
head.appendChild(badge); |
|
} |
|
if (!tocBox) return; |
|
if (layer && layer !== lastLayer) { |
|
var grp = document.createElement('div'); |
|
grp.className = 'toc__layer'; |
|
grp.textContent = LAYER_NAMES[layer] || layer; |
|
tocBox.appendChild(grp); |
|
lastLayer = layer; |
|
} |
|
var h2 = sec.querySelector('h2'); |
|
if (!h2 || !sec.id) return; |
|
var a = document.createElement('a'); |
|
a.href = '#' + sec.id; |
|
a.textContent = h2.textContent; |
|
a.setAttribute('data-for', sec.id); |
|
tocBox.appendChild(a); |
|
}); |
|
|
|
// Scrollspy. |
|
var links = {}; |
|
Array.prototype.forEach.call(document.querySelectorAll('#toc-links a'), function (a) { |
|
links[a.getAttribute('data-for')] = a; |
|
}); |
|
if ('IntersectionObserver' in window && sections.length) { |
|
var visible = {}; |
|
var obs = new IntersectionObserver(function (entries) { |
|
entries.forEach(function (e) { visible[e.target.id] = e.isIntersecting; }); |
|
var current = null; |
|
sections.forEach(function (s) { if (visible[s.id] && !current) current = s.id; }); |
|
Object.keys(links).forEach(function (id) { |
|
links[id].classList.toggle('is-active', id === current); |
|
}); |
|
}, { rootMargin: '-10% 0px -70% 0px', threshold: 0 }); |
|
sections.forEach(function (s) { obs.observe(s); }); |
|
} |
|
|
|
})(); |
|
</script> |
|
</body> |
|
</html> |