Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
200 changes: 166 additions & 34 deletions landing/faq.html

Large diffs are not rendered by default.

93 changes: 93 additions & 0 deletions landing/fonts/OFL.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
Copyright 2020 The Space Grotesk Project Authors (https://github.com/floriankarsten/space-grotesk)

This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL


-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------

PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.

The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.

DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.

"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).

"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).

"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.

"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.

PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:

1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.

2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.

3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.

4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.

5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.

TERMINATION
This license becomes null and void if any of the above conditions are
not met.

DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file added landing/fonts/SpaceGrotesk-Bold.woff2
Binary file not shown.
195 changes: 0 additions & 195 deletions landing/hero.svg

This file was deleted.

329 changes: 227 additions & 102 deletions landing/index.html

Large diffs are not rendered by default.

258 changes: 204 additions & 54 deletions landing/landing.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(/<link\b[^>]*\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(/<button\b[^>]*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(`<div\\b[^>]*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 <img>, 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(/<svg\b[^>]*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('</p>', 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(/<script>([\s\S]*?)<\/script>/g)].map((m) => m[1]).find((s) => s.includes('.lp-tour'))
const ids = [...html.matchAll(/<button\b[^>]*role="tab"[^>]*id="([^"]+)"[^>]*aria-controls="([^"]+)"/g)].map((m) => ({ id: m[1], controls: m[2] }))

// Just enough DOM for the script: elements that keep their listeners,
// attributes and classes, and a document that finds them.
function mount({ reducedMotion = false } = {}) {
const el = (extra = {}) => {
const listeners = {}, attrs = {}, classes = new Set()
return {
tabIndex: 0, hidden: false, focusVisible: false, dataset: {},
addEventListener(type, fn) { (listeners[type] ||= []).push(fn) },
fire(type, ev = {}) { for (const fn of listeners[type] || []) fn({ preventDefault() {}, ...ev }) },
setAttribute(k, v) { attrs[k] = v }, getAttribute: (k) => attrs[k],
matches(sel) { return sel === ':focus-visible' && this.focusVisible },
focus() { this.fire('focus') },
classList: { toggle: (c, on) => (on ? classes.add(c) : classes.delete(c)), contains: (c) => classes.has(c) },
...extra,
}
}
const tour = el(), theme = el()
const panels = Object.fromEntries(ids.map((t, i) => [t.controls, el({ hidden: i !== 0 })]))
const tabs = ids.map((t, i) => {
const tab = el()
tab.setAttribute('aria-controls', t.controls)
tab.setAttribute('aria-selected', i === 0 ? 'true' : 'false')
return tab
})
const document = {
documentElement: el(),
getElementById: (id) => (id === 'lp-theme' ? theme : panels[id]),
querySelector: (sel) => (sel === '.lp-tour' ? tour : null),
querySelectorAll: (sel) => (sel === '.lp-tab' ? tabs : []),
}
const window = { matchMedia: () => ({ matches: reducedMotion }) }
new Function('document', 'window', 'localStorage', script)(document, window, { setItem() {} })
const selected = () => tabs.findIndex((t) => t.getAttribute('aria-selected') === 'true')
const shown = () => ids.map((t) => !panels[t.controls].hidden)
return { tour, tabs, selected, shown, playing: () => tour.classList.contains('is-playing') }
}

it('finds the script and the four tabs it drives', () => {
expect(script).toBeTruthy()
expect(ids.length).toBe(4)
})

it('describes itself for a screen reader', () => {
expect(svg).toMatch(/role="img"/)
expect(svg).toMatch(/aria-label="[^"]{40,}"/)
it('moves to the next tab when the line under the active one has run out, and wraps', () => {
const t = mount()
expect(t.playing()).toBe(true)
t.tabs[0].fire('animationend')
expect(t.selected()).toBe(1)
expect(t.shown()).toEqual([false, true, false, false])
t.tabs[3].fire('animationend')
expect(t.selected()).toBe(0)
})

it('stops for good when a tab is clicked, and shows the one that was picked', () => {
// WCAG 2.2.2: a pointer, touch or screen-reader user needs a way to stop
// it too, not only a keyboard user. Picking a tab is that way.
const t = mount()
t.tabs[2].fire('click')
expect(t.selected()).toBe(2)
expect(t.playing()).toBe(false)
t.tabs[2].fire('animationend')
expect(t.selected(), 'the panel was swapped under a reader who had picked it').toBe(2)
})

it('stops when the keyboard reaches a tab, and does not move on', () => {
const t = mount()
t.tabs[0].focusVisible = true
t.tabs[0].fire('focus')
expect(t.playing()).toBe(false)
t.tabs[0].fire('animationend')
expect(t.selected()).toBe(0)
})

it('stops on an arrow key and moves the selection with it', () => {
const t = mount()
t.tabs[0].fire('keydown', { key: 'ArrowLeft' })
expect(t.playing()).toBe(false)
expect(t.selected()).toBe(3)
t.tabs[3].fire('keydown', { key: 'Home' })
expect(t.selected()).toBe(0)
})

it('never starts for a reader who asked for less motion', () => {
const t = mount({ reducedMotion: true })
expect(t.playing()).toBe(false)
t.tabs[0].fire('animationend')
expect(t.selected()).toBe(0)
})
})

Expand Down Expand Up @@ -108,55 +256,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('</section>', start))
const faq = read('faq.html')
const answer = (() => {
const start = faq.indexOf('id="account"')
return start === -1 ? '' : faq.slice(start, faq.indexOf('</details>', 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(/<li>/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)
})
})

Expand Down
Loading
Loading