From 70654d9b1a3c21ccd265fb65030de04cef9b4cdc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kasper=20H=C3=A4gele?= Date: Sun, 27 Sep 2026 20:22:08 +0200 Subject: [PATCH 1/2] feat(landing): show the four surfaces on the front page, and answer the first-drive questions in the FAQ The front page named the RX webapp and the map in text over an animated hero, so a visitor saw neither until they paired a radio or met the map's login. It now shows the RX webapp, the map, a hunt and a repeater's reach as mockups with real values, in a tab slideshow, and the Hunt & Locate and Reach captions carry the position disclaimer. The account steps move to the FAQ answer #account, which the front page links. The FAQ grows from 9 to 16 answers (browsers, install, readout, SF7/SF8, own broker, how the map relates to the RX webapp, contributing). The theme follows the system, with a toggle that remembers the choice. Space Grotesk Bold is self-hosted in landing/fonts/ with its OFL licence; hero.svg is removed. landing: 41 tests green, each new test mutation-checked. Closes #730 Co-Authored-By: Claude Opus 5.5 --- landing/faq.html | 198 ++++++++++-- landing/fonts/OFL.txt | 93 ++++++ landing/fonts/SpaceGrotesk-Bold.woff2 | Bin 0 -> 41780 bytes landing/hero.svg | 195 ------------ landing/index.html | 327 +++++++++++++------ landing/landing.test.js | 142 +++++---- landing/style.css | 440 +++++++++++++++++--------- 7 files changed, 854 insertions(+), 541 deletions(-) create mode 100644 landing/fonts/OFL.txt create mode 100644 landing/fonts/SpaceGrotesk-Bold.woff2 delete mode 100644 landing/hero.svg diff --git a/landing/faq.html b/landing/faq.html index 7bc78873..26b61762 100644 --- a/landing/faq.html +++ b/landing/faq.html @@ -1,11 +1,21 @@ - + FAQ ยท Mesh-Hunter + + + diff --git a/landing/landing.test.js b/landing/landing.test.js index d56a2397..da427a9f 100644 --- a/landing/landing.test.js +++ b/landing/landing.test.js @@ -48,39 +48,71 @@ describe('landing pages', () => { }) }) -describe('the hero shot', () => { - const svg = read('hero.svg') +// The site fetches nothing from another host (the v7 redesign). The display +// face is served from here for that reason, so a stylesheet or font that +// quietly points at a CDN would break the rule the fonts/ folder exists for. +describe('self-contained', () => { + const css = readFileSync(new URL('./style.css', import.meta.url), 'utf8').replace(/\/\*[\s\S]*?\*\//g, '') - it('is self-contained: no network references', () => { - // It is loaded through , which blocks external subresources anyway -- - // so a remote reference would silently render nothing. The xmlns namespace - // URI is not one: it is an identifier, never fetched, and matching it was - // this test's own first bug. - const fetched = [...svg.matchAll(/(?:href|src)\s*=\s*"([^"]+)"/g)].map((m) => m[1]) - .concat([...svg.matchAll(/url\(\s*['"]?([^'")]+)/g)].map((m) => m[1])) - expect(fetched.filter((u) => /^https?:/i.test(u))).toEqual([]) + it.each(pages)('%s loads its stylesheet and icon from this site', (f) => { + const links = [...read(f).matchAll(/]*\bhref="([^"]+)"/g)].map((m) => m[1]) + expect(links.length).toBeGreaterThan(0) + expect(links.filter((u) => /^(https?:)?\/\//i.test(u))).toEqual([]) }) - it('carries its own animation and honours reduced motion', () => { - // One @keyframes per pillar: the phase is baked into the percentages rather - // than set with animation-delay, so every pillar shares one timeline and - // the scene can clear before the next drive starts. - expect(svg.match(/@keyframes /g)?.length ?? 0).toBeGreaterThan(20) - expect(svg).toMatch(/@keyframes drive/) - expect(svg).toMatch(/prefers-reduced-motion/) + it('points every url() in the stylesheet at a file that exists here', () => { + const urls = [...css.matchAll(/url\(\s*['"]?([^'")]+)/g)].map((m) => m[1]) + expect(urls.length, 'no url() to check').toBeGreaterThan(0) + for (const u of urls) { + expect(u, `${u} is not local`).not.toMatch(/^(https?:)?\/\//i) + expect(() => readFileSync(new URL(`./${u.replace(/^\//, '')}`, import.meta.url)), `${u} does not exist`).not.toThrow() + } + expect(css).not.toMatch(/@import/) }) +}) + +// The four surfaces as tabs above one shot. The markup is what a reader +// without the script gets, so it has to be right on its own: one tab +// selected, its panel shown, the others hidden, and each tab naming a panel +// that exists. +describe('the tour', () => { + const html = read('index.html') + const tabs = [...html.matchAll(/]*role="tab"[^>]*>/g)].map((m) => ({ + controls: m[0].match(/aria-controls="([^"]+)"/)?.[1], + selected: m[0].match(/aria-selected="([^"]+)"/)?.[1], + })) + const panel = (id) => html.match(new RegExp(`]*role="tabpanel"[^>]*id="${id}"[^>]*>`))?.[0] - it('clears the trail before the drive restarts', () => { - // The failure this guards: pillars still on screen when the hunter comes - // round again, so each lap draws over the last one's coverage. - const ends = [...svg.matchAll(/([\d.]+)%,100%\{transform:scaleY\(1\);opacity:0\}/g)] - expect(ends.length, 'no pillar keyframe ends at opacity 0').toBeGreaterThan(20) - for (const m of ends) expect(Number(m[1])).toBeLessThan(100) + it('has a tab per surface, each with its own panel', () => { + expect(tabs.length).toBe(4) + for (const t of tabs) expect(panel(t.controls), `no panel ${t.controls}`).toBeTruthy() }) - it('describes itself for a screen reader', () => { - expect(svg).toMatch(/role="img"/) - expect(svg).toMatch(/aria-label="[^"]{40,}"/) + it('selects one tab and shows only its panel', () => { + expect(tabs.filter((t) => t.selected === 'true').length).toBe(1) + for (const t of tabs) { + expect(/\bhidden=/.test(panel(t.controls)), `${t.controls}`).toBe(t.selected !== 'true') + } + }) + + it('describes each shot for a screen reader', () => { + const shots = [...html.matchAll(/]*viewBox="0 0 640 470"[^>]*>/g)].map((m) => m[0]) + expect(shots.length).toBe(4) + for (const s of shots) { + expect(s).toMatch(/role="img"/) + expect(s).toMatch(/aria-label="[^"]{40,}"/) + } + }) + + // AGENTS.md ยง7: output that implies where a node is carries the + // disclaimer, visible while it is (the review of #92). The Hunt & Locate + // and Reach shots draw an estimated position, so their captions say what + // that is. + it.each(['panel-hunt', 'panel-reach'])('says under %s that a position is inferred, not tracked', (id) => { + const at = html.indexOf(`id="${id}"`) + expect(at).toBeGreaterThan(-1) + const panel = html.slice(at, html.indexOf('

', at)) + expect(panel).toMatch(/inferred from RSSI and SNR, not from GPS tracking/) }) }) @@ -108,55 +140,57 @@ describe('link colours', () => { }) }) -// #490: how you get an account was one sentence in the two-column block at the -// foot of the page, after the blog card. A visitor who clicks "Open the map" -// meets a login form with nothing to register against, so the answer belongs -// with the product sections it follows from, not in the small print. +// #490: a visitor who opens the map meets a login form with nothing to +// register against, so how to get an account has to be one click from the +// front page. Since the v7 redesign the steps live in the FAQ answer, the +// place the question is asked, and the front page links that answer. describe('getting an account', () => { - const html = read('index.html') - const section = (() => { - const start = html.indexOf('id="get-an-account"') - return start === -1 ? '' : html.slice(start, html.indexOf('', start)) + const faq = read('faq.html') + const answer = (() => { + const start = faq.indexOf('id="account"') + return start === -1 ? '' : faq.slice(start, faq.indexOf('', start)) })() - it('has a section of its own', () => { - expect(section).not.toBe('') + it('has its own FAQ answer, with the steps', () => { + expect(answer).not.toBe('') + expect(answer.match(/
  • /g)?.length ?? 0).toBeGreaterThan(3) }) it('names the RX webapp as the only place that can register you', () => { - expect(section).toContain('https://rx.mesh-hunter.eu') - expect(section).toMatch(/companion/i) + expect(answer).toContain('https://rx.mesh-hunter.eu') + expect(answer).toMatch(/companion/i) }) it('names the admin step, which no amount of self-service replaces', () => { - expect(section).toMatch(/member/i) - expect(section).toMatch(/admin/i) + expect(answer).toMatch(/member/i) + expect(answer).toMatch(/admin/i) }) - it('sits with the product sections, not in the small print at the bottom', () => { - // Where it used to live: one sentence inside "About & access", in the - // two-column footer block after the blog card. This is the placement the - // issue is about, so it is worth pinning. - const at = html.indexOf('id="get-an-account"') - expect(at).toBeGreaterThan(-1) - expect(at).toBeLessThan(html.indexOf('lp-sub')) - expect(at).toBeLessThan(html.indexOf('Read the FAQ')) + it('is linked from the front page', () => { + expect(read('index.html')).toContain('href="/faq.html#account"') }) }) -describe('in-page anchors', () => { +describe('fragment targets', () => { const css = readFileSync(new URL('./style.css', import.meta.url), 'utf8').replace(/\/\*[\s\S]*?\*\//g, '') - const html = read('index.html') + const faq = read('faq.html') + const links = pages.flatMap((f) => [...read(f).matchAll(/href="\/faq\.html#([\w-]+)"/g)].map((m) => m[1])) + + it('links to FAQ answers that exist', () => { + expect(links.length).toBeGreaterThan(0) + for (const id of links) expect(faq, `#${id}`).toContain(`id="${id}"`) + }) // .lp-top is sticky, so a fragment jump parks the target heading underneath - // it: the reader lands on a section whose title is hidden. Measured at 59px - // for the header, so the reserve has to clear that. + // it: the reader lands on a section whose title is hidden. The header is + // 64px tall since the v7 redesign, so the reserve has to clear that. it('reserves more than the sticky header above a fragment target', () => { - expect([...html.matchAll(/href="#([\w-]+)"/g)].length).toBeGreaterThan(0) + const header = Number(css.match(/\.lp-top \.lp-wrap\s*\{[^}]*height:\s*(\d+)px/)?.[1]) + expect(header).toBeGreaterThan(0) const rule = css.match(/:target\s*\{([^}]*)\}/) expect(rule, 'no :target rule').not.toBeNull() const px = Number(rule[1].match(/scroll-margin-top:\s*(\d+)px/)?.[1]) - expect(px).toBeGreaterThan(59) + expect(px).toBeGreaterThan(header) }) }) diff --git a/landing/style.css b/landing/style.css index bee5e0c1..f62bbc6c 100644 --- a/landing/style.css +++ b/landing/style.css @@ -1,174 +1,302 @@ -:root, [data-theme="dark"] { - --ch-bg:#0b0e14; --ch-text:#e6edf3; --ch-muted:#9aa; - --ch-surface:rgba(18,23,33,0.92); --ch-input-bg:#11161f; --ch-border:#2a3340; - --ch-sig-hot:#ff453a; --ch-sig-warm:#ff9f0a; --ch-sig-mid:#ffd60a; --ch-sig-cool:#40c8ff; --ch-sig-cold:#5e7bff; --ch-sig-none:#5b6675; - /* Same accents as the app's tokens.css (#539): one brand, one link colour. */ +/* Tokens: the values of app/src/styles/tokens.css (docs/design-system.md), so + the three surfaces are one system. The site follows the system's theme, and + the button in the header overrides it (data-theme on , remembered as + ch-theme). The product shots and the footer are dark in both themes: + data-theme="dark" on their element picks the dark values, the way the float + readout does in the app. */ +:root { + --ch-bg:#f4f1ea; --ch-surface:rgba(255,252,245,0.86); --ch-surface-thin:rgba(255,252,245,0.72); + --ch-text:#1b2430; --ch-muted:#5a6675; --ch-border:rgba(0,0,0,0.10); --ch-input-bg:#fffcf5; + --ch-accent:#0b7a60; --ch-accent-2:#b4530a; + --ch-sig-hot:#d11f1f; --ch-sig-warm:#e07000; --ch-sig-mid:#c79100; --ch-sig-cool:#1577c0; + --ch-sig-cold:#3a44b8; --ch-sig-faint:#6a2fb0; --ch-sig-none:#9aa3ad; + --ch-bar-track:#d8d2c4; +} +@media (prefers-color-scheme: dark) { + :root:not([data-theme="light"]) { + --ch-bg:#0b0e14; --ch-surface:rgba(18,23,33,0.82); --ch-surface-thin:rgba(18,23,33,0.72); + --ch-text:#e6edf3; --ch-muted:#8b98a9; --ch-border:rgba(255,255,255,0.08); --ch-input-bg:#11161f; + --ch-accent:#38e0a6; --ch-accent-2:#ffb020; + --ch-sig-hot:#ff453a; --ch-sig-warm:#ff9f0a; --ch-sig-mid:#ffd60a; --ch-sig-cool:#40c8ff; + --ch-sig-cold:#5e7bff; --ch-sig-faint:#9b6bff; --ch-sig-none:#5b6675; + --ch-bar-track:#1b2230; + } +} +[data-theme="dark"] { + --ch-bg:#0b0e14; --ch-surface:rgba(18,23,33,0.82); --ch-surface-thin:rgba(18,23,33,0.72); + --ch-text:#e6edf3; --ch-muted:#8b98a9; --ch-border:rgba(255,255,255,0.08); --ch-input-bg:#11161f; --ch-accent:#38e0a6; --ch-accent-2:#ffb020; + --ch-sig-hot:#ff453a; --ch-sig-warm:#ff9f0a; --ch-sig-mid:#ffd60a; --ch-sig-cool:#40c8ff; + --ch-sig-cold:#5e7bff; --ch-sig-faint:#9b6bff; --ch-sig-none:#5b6675; + --ch-bar-track:#1b2230; + color: var(--ch-text); } -[data-theme="light"] { - --ch-bg:#f4f1ea; --ch-text:#1b2430; --ch-muted:#5a6675; - --ch-surface:rgba(255,252,245,0.92); --ch-input-bg:#fffcf5; --ch-border:#cfc8b8; - --ch-sig-hot:#d11f1f; --ch-sig-warm:#e07000; --ch-sig-mid:#c79100; --ch-sig-cool:#1577c0; --ch-sig-cold:#3a44b8; --ch-sig-none:#9aa3ad; - --ch-accent:#0b7a60; --ch-accent-2:#b4530a; + +/* The display face for headings and the wordmark (31 Aug 2026), served from + here: the site fetches nothing from another host. Body copy stays the system + stack, as in the app. */ +@font-face { + font-family: "Space Grotesk"; + src: url("/fonts/SpaceGrotesk-Bold.woff2") format("woff2"); + font-weight: 700; font-style: normal; font-display: swap; } * { box-sizing: border-box; } html, body { margin: 0; } body { - background: var(--ch-bg); - color: var(--ch-text); - font: 16px/1.6 system-ui, -apple-system, Segoe UI, Roboto, sans-serif; - min-height: 100vh; + background: var(--ch-bg); color: var(--ch-text); + font: 17px/1.55 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; } +/* The signal ramp, cold to hot, as the thin rule the brand uses. */ +body::before { content: ""; display: block; height: 4px; + background: linear-gradient(90deg, var(--ch-sig-cold), var(--ch-sig-cool), var(--ch-sig-mid), var(--ch-sig-warm), var(--ch-sig-hot)); } +h1, h2, h3 { font-family: "Space Grotesk", system-ui, sans-serif; font-weight: 700; letter-spacing: -0.015em; margin: 0; } +.lp-wrap { max-width: 1240px; margin: 0 auto; padding: 0 32px; } +.mono { font-family: ui-monospace, "SF Mono", SFMono-Regular, Menlo, Consolas, monospace; } -.lp-top { - display: flex; align-items: center; justify-content: space-between; - padding: 14px 20px; border-bottom: 1px solid var(--ch-border); - background: var(--ch-surface); backdrop-filter: blur(8px); - position: sticky; top: 0; -} -/* The hex mark beside the wordmark: the PWA icon is the one brand mark - (#539). flex:none + a real gap so the brand never touches the first nav - link โ€” measured at 0px before. */ -.lp-brand { font-weight: 700; letter-spacing: 0.02em; - display: inline-flex; align-items: center; gap: 9px; flex: none; margin-right: 16px; } -.lp-nav { display: flex; align-items: center; gap: 16px; } -.lp-nav a, .lp-nav a:visited { color: var(--ch-text); text-decoration: none; } -.lp-nav a:hover { color: var(--ch-accent-2); } -[data-theme="light"] .lp-th-moon { display: none; } -:root:not([data-theme="light"]) .lp-th-sun { display: none; } -.lp-theme { - background: var(--ch-input-bg); color: var(--ch-text); - border: 1px solid var(--ch-border); border-radius: 8px; - padding: 4px 8px; cursor: pointer; font-size: 15px; -} +/* ---- header ---- */ +.lp-top { position: sticky; top: 0; z-index: 10; background: var(--ch-surface); + backdrop-filter: blur(10px); border-bottom: 1px solid var(--ch-border); } +.lp-top .lp-wrap { display: flex; align-items: center; gap: 26px; height: 64px; } +/* flex:none and a real gap: the brand never touches the first nav link (#539). */ +.lp-brand { display: inline-flex; align-items: center; gap: 10px; flex: none; margin-right: auto; + font-family: "Space Grotesk", system-ui, sans-serif; font-weight: 700; font-size: 20px; letter-spacing: -0.015em; + color: var(--ch-text); text-decoration: none; } +.lp-nav { display: flex; align-items: center; gap: 24px; font-size: 15px; } +.lp-nav a, .lp-nav a:visited { color: var(--ch-text); text-decoration: none; white-space: nowrap; } +.lp-nav a:hover { color: var(--ch-accent); } +.lp-nav a.active, .lp-nav a.active:visited { color: var(--ch-accent); } +/* One button that swaps the theme (docs/design-system.md: one control that + swaps state); it shows the theme it switches to. */ +.lp-theme { flex: none; display: inline-flex; align-items: center; justify-content: center; width: 36px; height: 36px; + border-radius: 10px; border: 1px solid var(--ch-border); background: var(--ch-input-bg); color: var(--ch-text); cursor: pointer; } +.lp-theme:hover { border-color: var(--ch-accent); } +.lp-theme:focus-visible { outline: 2px solid var(--ch-accent); outline-offset: 2px; } +:root[data-theme="dark"] .lp-th-moon, :root:not([data-theme="dark"]) .lp-th-sun { display: none; } -/* 940, not 760 (#441): the hero shot needs the width, and the two feature - sections read as the page rather than as cards sitting on it. */ -.lp-main { max-width: 940px; margin: 0 auto; padding: 32px 20px 48px; } -/* The hero is the animation, with the copy over it (#441). The scrim is not - decoration: white text over a scene carrying #ff453a and #ffd60a pillars - needs a floor under it, and it is heaviest top and bottom where the h1 and - the buttons sit. */ -.lp-hero { - position: relative; margin: 8px 0 0; - border: 1px solid var(--ch-border); border-radius: 14px; - overflow: hidden; background: #0e131c; -} -.lp-hero-bg { - position: absolute; inset: 0; width: 100%; height: 100%; - object-fit: cover; object-position: center bottom; -} -/* The copy sits in the TOP of the frame and the scrim thins out below it, so - the lower half -- where the drive runs -- is close to unobscured. The heavy - band is only as tall as the text that needs it. */ -.lp-hero-inner { - position: relative; text-align: center; padding: 60px 24px 132px; - background: linear-gradient(180deg, - rgba(11,14,20,0.92) 0%, rgba(11,14,20,0.86) 30%, - rgba(11,14,20,0.55) 52%, rgba(11,14,20,0.18) 72%, - rgba(11,14,20,0.10) 100%); -} -/* Literal colours, not tokens: the shot behind this is dark in BOTH themes, so - the copy over it has to be dark-theme copy in both too. Following the theme - put #1b2430 text on a near-black scrim in light mode. */ -.lp-hero-inner { color: #e6edf3; } -.lp-hero .lp-lead { color: #b9c4d0; } -.lp-hero .lp-btn, .lp-hero .lp-btn:visited { - color: #e6edf3; background: rgba(17,22,31,0.82); border-color: #2a3340; -} -.lp-hero .lp-btn-primary { border-color: #40c8ff; } -.lp-hero .lp-btn:hover { border-color: #40c8ff; } -.lp-hero h1 { font-size: 2.4rem; margin: 0 0 12px; } -.lp-lead { color: var(--ch-muted); font-size: 1.15rem; max-width: 620px; margin: 0 auto 24px; } -.lp-cta { display: flex; gap: 12px; justify-content: center; flex-wrap: wrap; } - -.lp-btn { - display: inline-block; padding: 10px 18px; border-radius: 10px; - border: 1px solid var(--ch-border); background: var(--ch-input-bg); - color: var(--ch-text); text-decoration: none; font-weight: 600; -} -.lp-btn:visited { color: var(--ch-text); } -.lp-btn:hover { border-color: var(--ch-sig-cool); } -.lp-btn-primary { border-color: var(--ch-sig-cool); } - -/* The two products lead (#441). Rules rather than cards: a card makes them - items on the page, a rule makes them the page. */ -.lp-feature { border-top: 1px solid var(--ch-border); padding: 34px 0 8px; } -.lp-feature:first-of-type { margin-top: 34px; } -.lp-feature h2 { margin: 0 0 14px; font-size: 2rem; letter-spacing: -0.015em; } -.lp-feature p { font-size: 1.08rem; max-width: 700px; margin: 0 0 16px; } -.lp-feature p:last-child { margin-bottom: 0; } - -/* Everything that follows them is support, and reads as support. */ -.lp-sub { - display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 32px; - margin: 40px 0 0; padding-top: 28px; border-top: 1px solid var(--ch-border); -} -.lp-sub h3 { - margin: 0 0 10px; font-size: 0.95rem; letter-spacing: 0.06em; - text-transform: uppercase; color: var(--ch-muted); -} -.lp-sub p, .lp-sub li { color: var(--ch-muted); font-size: 0.95rem; } -.lp-sub p { margin: 0 0 10px; } -.lp-sub p:last-child { margin-bottom: 0; } +/* ---- buttons: the app's primary CTA (app.css .splash-cta), 13px radius ---- */ +a.lp-btn, a.lp-btn:visited { display: inline-flex; align-items: center; justify-content: center; height: 48px; + padding: 0 22px; border-radius: 13px; font-size: 16px; font-weight: 600; white-space: nowrap; + text-decoration: none; border: 1px solid var(--ch-border); background: var(--ch-input-bg); color: var(--ch-text); } +a.lp-btn-primary, a.lp-btn-primary:visited { background: var(--ch-accent); color: var(--ch-bg); border-color: transparent; } +a.lp-btn:hover { border-color: var(--ch-accent); } +.lp-top a.lp-btn { height: 40px; padding: 0 16px; font-size: 15px; } -@media (max-width: 640px) { - .lp-sub { grid-template-columns: 1fr; gap: 24px; } - .lp-hero-inner { padding: 40px 18px 88px; } - .lp-hero h1 { font-size: 2rem; } - .lp-lead { font-size: 1.05rem; } - .lp-feature { padding: 26px 0 8px; } - .lp-feature h2 { font-size: 1.5rem; } - .lp-feature p { font-size: 1rem; } - .lp-steps { font-size: 1rem; } -} +/* ---- hero ---- */ +.lp-hero { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.08fr); gap: 56px; + align-items: center; padding: 72px 0 96px; } +.lp-eyebrow { display: inline-flex; align-items: center; gap: 10px; font-size: 14px; font-weight: 600; color: var(--ch-accent); } +.lp-eyebrow::before { content: ""; width: 28px; height: 3px; border-radius: 2px; + background: linear-gradient(90deg, var(--ch-sig-cold), var(--ch-sig-cool), var(--ch-sig-mid), var(--ch-sig-warm), var(--ch-sig-hot)); } +.lp-hero h1 { font-size: clamp(40px, 3.9vw, 56px); line-height: 1.04; margin: 18px 0 24px; } +.lp-lead { font-size: 20px; line-height: 1.5; color: var(--ch-muted); max-width: 540px; margin: 0 0 34px; } +.lp-cta { display: flex; gap: 12px; flex-wrap: wrap; } +/* Two even columns, so the chips line up however long their text is. */ +.lp-proof { display: grid; grid-template-columns: repeat(2, max-content); gap: 10px; list-style: none; padding: 0; margin: 30px 0 0; } +.lp-proof li { display: inline-flex; align-items: center; gap: 8px; height: 34px; padding: 0 12px; + border-radius: 10px; background: var(--ch-surface); border: 1px solid var(--ch-border); font-size: 14px; color: var(--ch-muted); } +.lp-proof svg { flex: none; } -.lp-card { - background: var(--ch-surface); border: 1px solid var(--ch-border); - border-radius: 14px; padding: 20px 22px; margin: 22px 0; -} -.lp-card h2 { margin: 0 0 10px; font-size: 1.3rem; } -/* Content links, in every container that holds copy. Scoped to the containers - rather than `.lp-main a`, which would also catch .lp-btn. - :visited is stated explicitly everywhere below: an author rule that names - only the base state leaves :visited to the UA, which is browser blue and - then purple. That is exactly what happened when the product copy moved out - of .lp-card into .lp-feature (#441) -- the links went #0000ee on a #0b0e14 - background. */ -.lp-card a, .lp-card a:visited, -.lp-feature a, .lp-feature a:visited, -.lp-sub a, .lp-sub a:visited { color: var(--ch-sig-cool); } -/* A fragment jump lands the target under .lp-top, which is sticky and 59px - tall, so the heading you followed the link for is the one thing you cannot - see (#490). 80px is that plus breathing room, and it is the header's tallest - state -- the mobile bar is shorter. */ -:target { scroll-margin-top: 80px; } +/* The four surfaces as a slideshow: tabs as wide as the shot under them + (WAI-ARIA tabs, script below). While it plays, the line under the active + tab runs for one slide and the next tab takes over when it ends; a pointer + over it pauses the line, and choosing or focusing a tab stops it. */ +.lp-tabs { display: flex; gap: 4px; padding: 4px; border-radius: 13px; margin-bottom: 14px; + background: color-mix(in srgb, var(--ch-text) 8%, transparent); } +.lp-tab { position: relative; flex: 1 1 0; min-width: 0; height: 38px; padding: 0 10px; border: 0; border-radius: 10px; + background: none; cursor: pointer; font: 600 15px system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; + color: var(--ch-muted); white-space: nowrap; } +.lp-tab::after { content: ""; position: absolute; left: 12px; right: 12px; bottom: 4px; height: 2px; border-radius: 1px; + background: var(--ch-accent); transform: scaleX(0); transform-origin: left; } +.lp-tour.is-playing .lp-tab[aria-selected="true"]::after { animation: lp-slide 6s linear forwards; } +/* More specific than the rule that starts the line, whose shorthand would + otherwise set it running again. */ +.lp-tour.is-playing:hover .lp-tab[aria-selected="true"]::after { animation-play-state: paused; } +@keyframes lp-slide { to { transform: scaleX(1); } } +.lp-panel { animation: lp-fade .35s ease; } +@keyframes lp-fade { from { opacity: 0; } } +@media (prefers-reduced-motion: reduce) { .lp-panel { animation: none; } } +.lp-tab[aria-selected="true"] { background: var(--ch-input-bg); color: var(--ch-text); box-shadow: 0 1px 3px rgba(0,0,0,.12); } +.lp-tab:focus-visible { outline: 2px solid var(--ch-accent); outline-offset: 2px; } +.lp-tabcap { margin: 14px 2px 0; font-size: 15px; color: var(--ch-muted); } +.lp-tabcap strong { color: var(--ch-text); font-weight: 600; } +.lp-tabcap a, .lp-tabcap a:visited { color: var(--ch-accent); font-weight: 600; text-decoration: none; } -/* The numbered path to an account (#490). Same measure and size as - .lp-feature p, since it is the body copy of that section rather than a list - of links. */ -.lp-steps { margin: 0 0 16px; padding-left: 22px; max-width: 700px; font-size: 1.08rem; } -.lp-steps li { margin: 8px 0; } -.lp-links { margin: 0; padding-left: 20px; } -.lp-links li { margin: 6px 0; } - -.lp-foot { - text-align: center; color: var(--ch-muted); - padding: 24px 20px 40px; border-top: 1px solid var(--ch-border); -} +/* ---- product shots: always dark, drawn from real receptions ---- */ +.lp-shot { position: relative; aspect-ratio: 640 / 470; border-radius: 16px; overflow: hidden; + background: var(--ch-bg); border: 1px solid var(--ch-border); box-shadow: 0 30px 80px rgba(11,14,20,.25); } +.lp-shot > svg { position: absolute; inset: 0; width: 100%; height: 100%; } +.shot-grid { fill: none; stroke: var(--ch-border); } +.shot-route { fill: none; stroke: var(--ch-accent); stroke-width: 2.5; stroke-dasharray: 7 9; } +.shot-wave { fill: none; stroke: var(--ch-accent); stroke-opacity: .2; stroke-width: 1.5; } +.shot-target { fill: none; stroke: var(--ch-sig-hot); stroke-width: 2; } +.shot-area { fill: var(--ch-sig-hot); fill-opacity: .08; stroke: var(--ch-sig-hot); stroke-width: 1.5; stroke-dasharray: 5 5; } +.dotc { width: 7px; height: 7px; border-radius: 50%; background: var(--ch-bg); } +.shot-dots circle { stroke: var(--ch-bg); stroke-width: 1.5; } +.shot-hex polygon { stroke-width: 0; } +.shot-hex polygon { fill-opacity: .8; } +.shot-rays line { stroke-width: 1.6; opacity: .75; } +.shot-offset { stroke: var(--ch-text); stroke-width: 1.5; stroke-dasharray: 4 4; } +.shot-adv, .shot-est { fill: var(--ch-text); stroke: var(--ch-bg); stroke-width: 2; } +.shot-lab { fill: var(--ch-text); font: 13px system-ui, sans-serif; paint-order: stroke; stroke: var(--ch-bg); stroke-width: 4px; stroke-linejoin: round; } +.read-arrow { fill: var(--ch-sig-cool); margin-right: 8px; vertical-align: -2px; } +.t-hot { fill: var(--ch-sig-hot); stroke: var(--ch-sig-hot); } +.t-warm { fill: var(--ch-sig-warm); stroke: var(--ch-sig-warm); } +.t-mid { fill: var(--ch-sig-mid); stroke: var(--ch-sig-mid); } +.t-cool { fill: var(--ch-sig-cool); stroke: var(--ch-sig-cool); } +.t-cold { fill: var(--ch-sig-cold); stroke: var(--ch-sig-cold); } +.t-faint { fill: var(--ch-sig-faint); stroke: var(--ch-sig-faint); } +.shot-dots circle.t-hot, .shot-dots circle.t-warm, .shot-dots circle.t-mid, .shot-dots circle.t-cool, +.shot-dots circle.t-cold, .shot-dots circle.t-faint { stroke: var(--ch-bg); } + +/* Glass chrome over a shot: the app's panel material. */ +.shot-bar { position: absolute; left: 14px; right: 14px; top: 14px; display: flex; gap: 6px; padding: 6px; overflow: hidden; + background: var(--ch-surface); backdrop-filter: blur(12px); border: 1px solid var(--ch-border); border-radius: 12px; } +.shot-chip { height: 30px; padding: 0 11px; display: inline-flex; align-items: center; gap: 7px; border-radius: 8px; + font-size: 13px; color: var(--ch-text); background: color-mix(in srgb, var(--ch-text) 8%, transparent); white-space: nowrap; } +.shot-chip.on { background: var(--ch-accent); color: var(--ch-bg); font-weight: 600; } +.shot-chip.end { margin-left: auto; } + +/* The floating window (the app's float readout), over another app: solid, + not glass, since it is a video window. */ +.shot-pip { position: absolute; left: 16px; top: 66px; width: 38%; border-radius: 10px; overflow: hidden; + background: var(--ch-bg); box-shadow: 0 14px 40px rgba(0,0,0,.55); border: 1px solid var(--ch-border); + display: grid; grid-template-columns: 6px 1fr; } +.pip-tier { background: var(--ch-sig-cold); } +.pip-body { padding: 10px 12px 9px; display: flex; flex-direction: column; font-size: 11px; color: var(--ch-muted); } +.pip-rssi { font-size: 30px; font-weight: 700; line-height: 1; color: var(--ch-sig-cold); } +.pip-rssi small { font-size: 11px; font-weight: 500; color: var(--ch-muted); margin-left: 3px; } +.pip-name { font-size: 13px; font-weight: 700; color: var(--ch-text); margin-top: 7px; } +.pip-foot { display: flex; justify-content: space-between; margin-top: 8px; font-weight: 700; } +.pip-foot .on { color: var(--ch-accent); } +.pip-dot::before { content: ""; display: inline-block; width: 6px; height: 6px; border-radius: 50%; + background: var(--ch-accent); margin: 0 4px 1px 8px; } + +/* The readout card, the HUD in glass (the artboard's). */ +.shot-read { position: absolute; left: 16px; bottom: 16px; width: 360px; max-width: calc(100% - 32px); padding: 14px 16px; + background: var(--ch-surface); backdrop-filter: blur(14px); border: 1px solid var(--ch-border); border-radius: 10px; } +.read-k { font-size: 11px; letter-spacing: .04em; color: var(--ch-muted); } +.read-n { font-family: "Space Grotesk", system-ui, sans-serif; font-weight: 700; font-size: 42px; line-height: 1; margin: 8px 0 10px; color: var(--ch-text); } +.read-n small { font-size: 15px; font-weight: 500; margin-left: 4px; font-family: system-ui, sans-serif; } +.read-tier { display: flex; gap: 4px; margin-bottom: 8px; } +.read-tier i { flex: 1; height: 6px; border-radius: 2px; background: var(--ch-bar-track); } +.read-tier i.on-cold { background: var(--ch-sig-cold); } +.read-tier i.on-cool { background: var(--ch-sig-cool); } +.read-tier i.on-mid { background: var(--ch-sig-mid); } +.read-tier i.on-warm { background: var(--ch-sig-warm); } +.read-tier i.on-hot { background: var(--ch-sig-hot); } +.read-arrow-hot { fill: var(--ch-sig-hot); } +.shot-ping { fill: none; stroke: var(--ch-sig-hot); stroke-width: 1.5; opacity: .35; } +/* The receptions as they come in, the app's ticker in glass. */ +.shot-ticker { position: absolute; left: 16px; top: 66px; width: 44%; padding: 9px 12px; font-size: 11px; + background: var(--ch-surface); backdrop-filter: blur(12px); border: 1px solid var(--ch-border); border-radius: 10px; } +.tk-head { color: var(--ch-muted); letter-spacing: .04em; margin-bottom: 6px; } +.tk-row { display: grid; grid-template-columns: auto 1fr auto; gap: 10px; color: var(--ch-text); line-height: 1.7; } +.t-hot-text { color: var(--ch-sig-hot); font-weight: 700; } +.read-line { display: flex; justify-content: space-between; } +.read-scale { height: 8px; border-radius: 3px; margin: 10px 0 6px; + background: linear-gradient(90deg, var(--ch-sig-hot), var(--ch-sig-warm), var(--ch-sig-mid), var(--ch-sig-cool), var(--ch-sig-cold), var(--ch-sig-faint)); } +.shot-bytes { position: absolute; right: 18px; bottom: 18px; font-size: 11px; line-height: 1.7; text-align: right; + color: var(--ch-muted); opacity: .6; } + +/* ---- sections ---- */ +.lp-section { padding: 0 0 110px; } +.lp-head { display: grid; grid-template-columns: 1fr 1fr; gap: 60px; align-items: end; margin-bottom: 36px; } +.lp-head h2 { font-size: clamp(34px, 4vw, 48px); line-height: 1.08; margin-top: 14px; } +.lp-head p { color: var(--ch-muted); margin: 0; } + +.lp-tiles { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 18px; list-style: none; padding: 0; margin: 0; } +.lp-tile { background: var(--ch-surface); border: 1px solid var(--ch-border); border-radius: 16px; padding: 28px 28px 24px; } +.lp-tile .n { font-family: "Space Grotesk", system-ui, sans-serif; font-weight: 700; font-size: 56px; line-height: 1; letter-spacing: -0.03em; } +.lp-tile .n small { font-size: 22px; letter-spacing: 0; margin-left: 4px; color: var(--ch-muted); } +.lp-tile h3 { font-size: 18px; margin: 22px 0 4px; } +.lp-tile p { color: var(--ch-muted); font-size: 15px; margin: 0; } +.lp-tile::after { content: ""; display: block; height: 3px; border-radius: 2px; margin-top: 18px; + background: linear-gradient(90deg, var(--ch-sig-cold), var(--ch-sig-cool), var(--ch-sig-mid), var(--ch-sig-warm), var(--ch-sig-hot)); } +.lp-asof { margin-top: 14px; font-size: 14px; color: var(--ch-muted); } -/* --- secondary pages (faq) --- */ -.lp-nav a.active, .lp-nav a.active:visited { color: var(--ch-accent-2); } +.lp-card { display: flex; align-items: center; justify-content: space-between; gap: 40px; + background: var(--ch-surface); border: 1px solid var(--ch-border); border-radius: 16px; padding: 40px 48px; } +.lp-card h2 { font-size: 32px; margin-bottom: 8px; } +.lp-card h2 a, .lp-card h2 a:visited { color: var(--ch-text); text-decoration: none; } +.lp-card h2 a:hover { color: var(--ch-accent); } +.lp-card p { color: var(--ch-muted); margin: 0; } +.lp-qs { display: flex; flex-wrap: wrap; gap: 8px; list-style: none; padding: 0; margin: 18px 0 0; } +.lp-qs a, .lp-qs a:visited { display: inline-flex; align-items: center; height: 34px; padding: 0 12px; border-radius: 10px; + border: 1px solid var(--ch-border); background: var(--ch-input-bg); color: var(--ch-text); font-size: 14px; text-decoration: none; } +.lp-qs a:hover { border-color: var(--ch-accent); } -.faq-head { padding: 8px 0 4px; } -.faq-head h1 { font-size: 2rem; margin: 8px 0; } -.faq-head p { color: var(--ch-muted); font-size: 1.1rem; max-width: 700px; margin: 0; } +.lp-grid6 { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); border-top: 1px solid var(--ch-border); + list-style: none; padding: 0; margin: 0; } +.lp-grid6 li { padding: 30px 32px 34px 0; border-bottom: 1px solid var(--ch-border); } +.lp-grid6 li:not(:nth-child(3n+1)) { border-left: 1px solid var(--ch-border); padding-left: 32px; } +.lp-grid6 svg { display: block; margin-bottom: 18px; } +.lp-grid6 h3 { font-size: 24px; margin-bottom: 8px; } +.lp-grid6 p { color: var(--ch-muted); font-size: 15px; margin: 0 0 18px; max-width: 360px; } +.lp-grid6 a, .lp-grid6 a:visited { color: var(--ch-accent); font-weight: 600; text-decoration: none; } -.faq-list { margin: 24px 0 0; } +/* ---- footer: dark in both themes ---- */ +.lp-foot { background: var(--ch-bg); padding: 80px 0 30px; overflow: hidden; } +.lp-foot-top { display: grid; grid-template-columns: 1.4fr 1fr 1fr 1fr; gap: 40px; } +.lp-foot-top p { color: var(--ch-muted); max-width: 400px; font-size: 15px; margin: 18px 0 22px; } +.lp-foot h2 { font-size: 14px; font-family: system-ui, sans-serif; letter-spacing: 0; color: var(--ch-muted); font-weight: 600; margin: 0 0 18px; } +.lp-foot ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 12px; font-size: 15px; } +.lp-foot a, .lp-foot a:visited { color: var(--ch-text); text-decoration: none; } +.lp-foot a:hover { color: var(--ch-accent); } +.lp-foot a.lp-btn-primary, .lp-foot a.lp-btn-primary:visited { color: var(--ch-bg); } +.lp-wordmark { font-family: "Space Grotesk", system-ui, sans-serif; font-weight: 700; font-size: clamp(64px, 14.5vw, 212px); + line-height: .9; letter-spacing: -0.04em; margin: 90px 0 30px; white-space: nowrap; color: var(--ch-text); } +.lp-foot-bottom { display: flex; justify-content: space-between; align-items: center; gap: 16px; border-top: 1px solid var(--ch-border); + padding-top: 22px; font-size: 14px; color: var(--ch-muted); } +.lp-foot-bottom .rb { width: 120px; height: 3px; border-radius: 2px; + background: linear-gradient(90deg, var(--ch-sig-cold), var(--ch-sig-cool), var(--ch-sig-mid), var(--ch-sig-warm), var(--ch-sig-hot)); } + +/* A fragment jump lands under the sticky header, 64px tall (#490): 80px is + that plus breathing room. */ +:target { scroll-margin-top: 80px; } + +/* ---- FAQ page ---- */ +.faq-head { padding: 56px 0 8px; } +.faq-head h1 { font-size: clamp(40px, 5vw, 64px); line-height: 1.05; margin: 14px 0 12px; } +.faq-head p { color: var(--ch-muted); font-size: 19px; max-width: 700px; margin: 0; } +.faq-list { margin: 28px 0 96px; max-width: 860px; } .faq-list details { border-top: 1px solid var(--ch-border); } .faq-list details:last-child { border-bottom: 1px solid var(--ch-border); } -.faq-list summary { cursor: pointer; font-weight: 600; font-size: 1.08rem; padding: 14px 0; } -.faq-list summary:hover { color: var(--ch-accent-2); } -.faq-list details p { color: var(--ch-muted); margin: 0 0 16px; max-width: 700px; } -.faq-list details a, .faq-list details a:visited { color: var(--ch-accent-2); } +.faq-list summary { cursor: pointer; font-weight: 600; font-size: 18px; padding: 16px 0; } +.faq-list summary:hover { color: var(--ch-accent); } +.faq-list details p, .faq-list details ol, .faq-list details ul { color: var(--ch-muted); margin: 0 0 16px; max-width: 720px; } +.faq-list details li { margin: 6px 0; } +.faq-list details a, .faq-list details a:visited { color: var(--ch-accent); } + +/* ---- narrow screens ---- */ +@media (max-width: 960px) { + .lp-hero { grid-template-columns: 1fr; gap: 40px; padding: 48px 0 72px; } + .lp-head, .lp-foot-top { grid-template-columns: 1fr 1fr; gap: 28px; } + .lp-tiles { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .lp-card { flex-direction: column; align-items: flex-start; padding: 32px; } +} +@media (max-width: 640px) { + .lp-wrap { padding: 0 16px; } + .lp-top .lp-wrap { gap: 14px; } + .lp-brand { font-size: 18px; gap: 8px; } + .lp-nav { gap: 14px; font-size: 14px; } +} +@media (max-width: 420px) { + /* The hero's second button is the way to the map on a narrow phone. */ + .lp-nav .lp-map { display: none; } + .lp-nav .lp-wide, .lp-top a.lp-btn { display: none; } + .lp-lead { font-size: 18px; } + .lp-hero h1 br { display: none; } + .lp-proof { grid-template-columns: max-content; } + .lp-head, .lp-foot-top { grid-template-columns: 1fr; } + .lp-tiles { grid-template-columns: 1fr; } + .lp-grid6 { grid-template-columns: 1fr; } + .lp-grid6 li, .lp-grid6 li:not(:nth-child(3n+1)) { border-left: 0; padding: 24px 0; } + .lp-tabs { display: flex; } + .lp-tab { padding: 0 4px; font-size: 13px; } + /* Taller on a phone, so the floating window and the readout leave the + drawing some room. */ + .lp-shot { aspect-ratio: 3 / 4; } + .shot-pip { width: 58%; top: 60px; } + .shot-bytes { display: none; } +} From 8a96cd26996e14f443b834d7ffe55de7f129071b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kasper=20H=C3=A4gele?= Date: Wed, 30 Sep 2026 19:03:12 +0200 Subject: [PATCH 2/2] fix(landing): stop the tour when a tab is picked, and correct the FAQ on telemetry From the review of #731. - A click or tap on a tab stops the slideshow for good, as keyboard focus and the arrow keys already did. Since 70654d9 a click restarted it, so a pointer, touch or screen-reader user had no way to stop a panel being swapped under them every 6 s (WCAG 2.2.2). The hover pause now only applies to a pointer that can hover, since :hover sticks after a tap. - The slideshow script is tested: the page's own inline script runs against a stand-in for the tabs and panels. - FAQ #auto-discover: only a repeater's question has the one-minute gap (ANON_MIN_GAP_MS). A companion's telemetry is asked per cycle. - FAQ #install: the app runs standalone, not full screen. - A tab is 44px high. At 420px and under the tabs are sized by their labels: "Hunt & Locate" did not fit a quarter of a 360px screen. - The three box-shadows mix from a --ch-shadow token, and a test keeps colour values inside the token block. Co-Authored-By: Claude Fable 5.1 --- landing/faq.html | 8 +-- landing/index.html | 18 ++++--- landing/landing.test.js | 116 ++++++++++++++++++++++++++++++++++++++++ landing/style.css | 29 ++++++---- 4 files changed, 150 insertions(+), 21 deletions(-) diff --git a/landing/faq.html b/landing/faq.html index 26b61762..e3cc38b2 100644 --- a/landing/faq.html +++ b/landing/faq.html @@ -113,7 +113,8 @@

    FAQ

    How do I install the RX webapp on Android?

    Open rx.mesh-hunter.eu in Chrome, open Chrome's menu and choose Install app or Add to - Home screen. It then starts from your home screen like an app, full screen and upright.

    + Home screen. It then starts from your home screen like an app, in its own window without + the browser's address bar, and upright.

    Keep the screen on and the webapp in front while you map: with the screen off or the webapp in the background, the phone stops capturing.

    @@ -202,8 +203,9 @@

    FAQ

    of the repeaters it has heard, in turn;
  • with Introduce my node to targets on and a companion as target, your own advert, so that node can hear you and answer.
  • -
  • with a target selected, one direct question: a companion is asked for its telemetry, a - repeater for its regions and clock or for its owner, each at most once a minute.
  • +
  • with a target selected, one direct question: a companion is asked for its telemetry, + or a repeater for its regions and clock or for its owner. A repeater gets such a + question at most once a minute.
  • A cycle runs every 10 seconds, or sooner once you have moved 50 metres, but never faster than the airtime budget of the band allows. That budget depends on the spreading factor, diff --git a/landing/index.html b/landing/index.html index 3030ece9..19eaa5be 100644 --- a/landing/index.html +++ b/landing/index.html @@ -244,11 +244,12 @@

    Questions before you start?

    })(); // The four surfaces as a slideshow (WAI-ARIA tabs). The progress line under // the active tab times each slide in CSS; when its animation ends, the next - // tab is selected. A pointer over it pauses it. A click picks a tab and the - // show goes on from there (Kasper, 28 September): the line starts over on - // the picked tab. Keyboard focus and the arrow keys stop it, so it never - // moves away from under a keyboard user (the carousel pattern), and a - // reader who asked the system for less motion never gets it started. + // tab is selected. A pointer that can hover pauses it while it is over it. + // Picking a tab stops it for good, by click, tap, keyboard focus or arrow + // key (the carousel pattern): that is the stop every reader has, so a + // panel is never swapped under someone reading its caption (WCAG 2.2.2, + // review of #731). A reader who asked the system for less motion never + // gets it started. (function () { var tour = document.querySelector('.lp-tour'); var tabs = [].slice.call(document.querySelectorAll('.lp-tab')); @@ -266,9 +267,10 @@

    Questions before you start?

    t.addEventListener('animationend', function () { if (tour.classList.contains('is-playing')) select(tabs[(i + 1) % tabs.length], false); }); - t.addEventListener('click', function () { select(t, false); }); - // Keyboard focus stops the show; a click focuses the tab too, so only - // a focus the browser would ring (:focus-visible) counts. + t.addEventListener('click', function () { playing(false); select(t, false); }); + // Keyboard focus stops the show before a key is pressed. Only a focus + // the browser would ring (:focus-visible) counts: a click focuses the + // tab too, and has stopped it already. t.addEventListener('focus', function () { if (t.matches(':focus-visible')) playing(false); }); t.addEventListener('keydown', function (e) { var keys = { ArrowRight: i + 1, ArrowLeft: i - 1, Home: 0, End: tabs.length - 1 }; diff --git a/landing/landing.test.js b/landing/landing.test.js index da427a9f..d1e2c1ca 100644 --- a/landing/landing.test.js +++ b/landing/landing.test.js @@ -71,6 +71,23 @@ describe('self-contained', () => { }) }) +// AGENTS.md ยง7: colours come from the --ch-* tokens. Three box-shadows carried +// a raw rgba() (review of #731), and a shadow is where the next one would go: +// it reads as an effect, not as a colour. +describe('colours', () => { + const css = readFileSync(new URL('./style.css', import.meta.url), 'utf8').replace(/\/\*[\s\S]*?\*\//g, '') + const tokens = [...css.matchAll(/--ch-[a-z0-9-]+\s*:\s*[^;]+;/g)] + const rules = css.replace(/--ch-[a-z0-9-]+\s*:\s*[^;]+;/g, '') + + it('finds the token block, so stripping it strips something', () => { + expect(tokens.length).toBeGreaterThan(20) + }) + + it('writes no colour value outside the token block', () => { + expect(rules.match(/#[0-9a-f]{3,8}\b|rgba?\([^)]*\)|hsla?\([^)]*\)/gi) || []).toEqual([]) + }) +}) + // The four surfaces as tabs above one shot. The markup is what a reader // without the script gets, so it has to be right on its own: one tab // selected, its panel shown, the others hidden, and each tab naming a panel @@ -116,6 +133,105 @@ describe('the tour', () => { }) }) +// The tour's behaviour, which the markup checks above cannot see: the page's +// own inline script, run against a stand-in for the four tabs and their +// panels. It is what decides when a panel is swapped under a reader, and it +// changed once (a click stopped the show, then did not) with no test noticing +// (review of #731). +describe('the tour as a slideshow', () => { + const html = read('index.html') + const script = [...html.matchAll(/