diff --git a/landing/faq.html b/landing/faq.html index 7bc78873..e3cc38b2 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..d1e2c1ca 100644 --- a/landing/landing.test.js +++ b/landing/landing.test.js @@ -48,39 +48,187 @@ 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.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('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/) + }) +}) + +// 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 +// 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('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('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('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('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,}"/) + } }) - 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/) + // 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/) }) +}) - 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) +// 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(/