Add AGENTS.md and move the verification tools into the repo
The checks that were being run by hand lived in /tmp and would have been lost, and the colour-contrast pass was ad-hoc enough that it missed --dim2 entirely. - tools/verify.js: headless smoke test. Boots the page against a stubbed DOM/canvas and checks all 9 tabs render, all 10 maps draw with no non-finite geometry and no squashed labels, dog-tag sub-checkboxes cover every tag in every list, no photo appears in two steps, every theme has a CSS block, and no personal data shipped. - tools/theme-contrast.py: WCAG check across all themes, now also covering --on-acc on --acc. Running the stricter check found real problems: --dim2 (small captions) was 2.3-3.0:1 in four themes and --acc-d was 2.7-2.8:1 in two. Lightened those. Worst theme is now 3.2:1, all on-accent pairs are 4.8:1 or better. - AGENTS.md documents the architecture, build stages, assertions, data provenance, deployment and the traps already hit, so a future session does not rediscover them the hard way.
This commit is contained in:
parent
a63606b5c8
commit
fbb5f02f49
6 changed files with 530 additions and 17 deletions
167
AGENTS.md
Normal file
167
AGENTS.md
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
# AGENTS.md
|
||||
|
||||
Orientation for anyone (human or agent) picking this repo up cold. Read this before changing anything.
|
||||
|
||||
## What this is
|
||||
|
||||
A single-page companion for 100%-ing **Metal Gear Solid 2: Sons of Liberty — Master Collection Version** (Steam appid `2131640`). It contains a full walkthrough, all 51 achievements, all 394 dog tags, rebuilt guard-position maps, location screenshots, and live Steam achievement sync.
|
||||
|
||||
- **Live:** <https://rain.pages.melonbread.xyz/la-li-lu-le-lo/>
|
||||
- **Repo:** <https://git.melonbread.xyz/rain/la-li-lu-le-lo> (branch `pages`, which is the default branch)
|
||||
- **Owner:** `rain` / `rain@melonbread.xyz`
|
||||
|
||||
## Golden rules
|
||||
|
||||
1. **Never edit `index.html`.** It is generated. Your change will be silently overwritten on the next build. Edit `build/` and run `./deploy.sh`.
|
||||
2. **Run `node tools/verify.js` before deploying.** It catches most of the ways this project breaks.
|
||||
3. **Run `python3 tools/theme-contrast.py` after touching any colour or theme.**
|
||||
4. The build has assertions. If one fires, **fix the data, do not delete the assertion.**
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
index.html generated - do not edit
|
||||
steam-sync.py generated copy of build/steam-sync.py
|
||||
steam-proxy-worker.js generated copy of build/steam-proxy-worker.js
|
||||
deploy.sh build + commit + push
|
||||
tools/verify.js headless smoke test (stubbed DOM, no browser)
|
||||
tools/theme-contrast.py WCAG contrast check across all themes
|
||||
build/ ALL the sources
|
||||
build.py the build script
|
||||
template.html the page: CSS + JS + markup, with __PLACEHOLDER__ slots
|
||||
achievements_src.py hand-written how-to text for all 51 achievements
|
||||
roadmap_src.py hand-written walkthrough steps + Big Boss content
|
||||
*.json extracted data (see Provenance)
|
||||
```
|
||||
|
||||
`build.py` writes to `OUT = os.path.dirname(HERE)`, i.e. the repo root, and copies the two helper files up from `build/`. So the helpers are edited in `build/` and published to the root.
|
||||
|
||||
## How the page works
|
||||
|
||||
One self-contained HTML file. No framework, no build step at runtime, no network needed except for hot-linked photos.
|
||||
|
||||
`build.py` injects five JSON blobs into `template.html`:
|
||||
|
||||
| Blob | What |
|
||||
|---|---|
|
||||
| `ACHIEVEMENTS` | 51 entries: name, desc, official Steam icon, category, missable flag, guide HTML, photos |
|
||||
| `ROADMAP` | 7 phases / 48 steps, each with body HTML, `gets`, `areas`, `photos` |
|
||||
| `DOGTAGS` | 10 lists (chapter × difficulty), 394 tags with name, area, conditional note |
|
||||
| `MAPS` | 10 spreadsheet sheets: palette, RLE fill runs, text cells, merges, col widths, row heights |
|
||||
| `BIGBOSS` | criteria, heal points, time checkpoints, boss strategies |
|
||||
|
||||
All interactive state lives in **one `localStorage` key**, `mgs2mc-guide-v1`:
|
||||
|
||||
```js
|
||||
S = { ach:{id:true}, tags:{'listId:index':true}, road:{stepId:true},
|
||||
items:{'stepId:slug':true}, hideDone, tab, roadDiff,
|
||||
steam:{profile,id64,persona,unlocked:{api:true},lastSync} }
|
||||
```
|
||||
|
||||
There is a **separate** key `mgs2-theme` for the colour theme, so it is not affected by Export/Import.
|
||||
|
||||
## Build pipeline
|
||||
|
||||
Order matters — later stages depend on earlier ones:
|
||||
|
||||
1. **achievements** — merge `achievements_src.py` with `steam_official.json` (names/descriptions/icons from Steam's own XML).
|
||||
2. **screenshots** — assemble per-achievement photo lists from the two guides, drop perceptual duplicates, pin multi-location photos to steps, attach `st.photos`.
|
||||
3. **colour coding** — classify every `<b>`/`<i>` in the roadmap as tag / item / achievement / missable / boss / area.
|
||||
4. **step sub-checklists** — extract `gets` from the colourised HTML, map tag areas (`TAGMAP`), attach `st.areas`.
|
||||
5. **dog tags**, then **inject** everything into `template.html`.
|
||||
|
||||
### Assertions (do not remove)
|
||||
|
||||
- every dog-tag area is claimed by exactly one step (`TAGMAP`) — nothing silently unmapped
|
||||
- every `PHOTO_STEP` key matches at least one real photo — catches typos in the 40-char hashes
|
||||
- every photo of a split achievement is pinned to a step
|
||||
- no achievement whose photos span several steps is left unpinned
|
||||
|
||||
## Verifying changes
|
||||
|
||||
**`node tools/verify.js`** loads `index.html`, runs its script against a stubbed DOM/canvas and checks: boot does not throw, all 9 tabs render, all 10 maps draw without non-finite geometry, no label is squashed, dog-tag sub-checkboxes cover every tag in every list, no photo appears twice, every theme has a CSS block, and no personal data shipped.
|
||||
|
||||
**`python3 tools/theme-contrast.py`** checks every foreground against `--panel` plus `--on-acc` against `--acc`, across all themes. Fails under 3.0:1.
|
||||
|
||||
**There is no browser tooling available.** `browser_screenshot` crashes the harness GUI process (SIGSEGV in the Electron/sharp path) — do not call it. The Node stub approach above is the substitute; it verifies logic, not layout, so **layout changes cannot be verified here** and should be stated as unverified.
|
||||
|
||||
Quick manual checks worth knowing:
|
||||
|
||||
```bash
|
||||
grep -c 'class="lb"' index.html # photos present in the build
|
||||
node -e "..." # see tools/verify.js for the stub pattern
|
||||
```
|
||||
|
||||
## Data provenance
|
||||
|
||||
All third-party content is credited in the page's Sources tab and in README.md.
|
||||
|
||||
| Data | Source | Notes |
|
||||
|---|---|---|
|
||||
| Achievement list, icons, apinames | Steam community XML | `steam_official.json` — 51 entries, `ach_002_001`…`ach_002_051` |
|
||||
| Achievement how-to text | Steam guide by Cole ヴ Viper + Dayngl's Guides | hand-written into `achievements_src.py` |
|
||||
| 394 dog tags | Video Chums checklist | `dogtags_vc.json` (names + areas) |
|
||||
| Conditional-spawn notes | u/Spikeyroxas spreadsheet | `dogtag_notes.json` (81 notes) |
|
||||
| Area maps | same spreadsheet | `maps.json` — the sheets are cell-grid art, not images |
|
||||
| Location photos | Steam guide + Dayngl's Guides | hot-linked, never downloaded into the repo |
|
||||
| Achievement removal check | TrueSteamAchievements | confirmed 51, incl. `Hold Up-aholic` which the Steam guide skips |
|
||||
|
||||
**Refreshing a dataset** means re-extracting into `build/*.json` and re-running the build. The extraction was originally done with ad-hoc Python against the source pages; if a source changes shape, re-derive rather than patching the JSON by hand.
|
||||
|
||||
## Traps already hit (don't repeat)
|
||||
|
||||
- **Canvas `fillText` with `maxWidth` condenses text, it does not truncate.** Narrow clip widths turn labels into an unreadable smear. `drawMap` measures each label and steps the font down (to 7px), then lets it overflow rather than squashing. `tools/verify.js` fails if any label comes out condensed.
|
||||
- **Excel does not clip overflowing text at a coloured cell.** Only a cell with actual content stops it. Clipping map labels at fill cells made them tiny; clip at text cells only.
|
||||
- **`data-sub` must hold the handler key, not the CSS class.** It was set to the class, so missable achievements emitted `data-sub="ach miss"` and their tick boxes did nothing — and missables are the whole point.
|
||||
- **Per-achievement photo caps break per-step distribution.** Capping at 4 dropped exactly the photos that needed spreading across steps. Deduplicate globally, distribute per step, cap per step.
|
||||
- **Normalize both sides of a name lookup.** An items list written with spaces (`"stun grenades"`) was compared against space-stripped text, so no multi-word item ever matched — 4 spans instead of 31.
|
||||
- **`pkill -f "steam-sync"` kills the invoking shell**, because that shell's own command line contains the pattern. Use `pkill -f "[s]team-sync"` **in a call that does not also contain the literal string**, or split kill and start into separate calls.
|
||||
- **A bad slice edit duplicated half of `build.py`.** The tell was the build printing every stage twice — check build output length when editing that file.
|
||||
- **Code that uses a computed value must run after it is computed.** The photo-to-step assignment needed `gets`, which is built later in the script.
|
||||
|
||||
## Steam sync
|
||||
|
||||
Steam's community XML sends no CORS headers, so a browser cannot read it directly. Two ways around it:
|
||||
|
||||
**Local helper (what is deployed uses).** `steam-sync.py` serves the folder and proxies `/api/steam?profile=…`. A hosted HTTPS page calls it at `http://127.0.0.1:8765`, so **the helper must be running on the viewer's machine**:
|
||||
|
||||
```bash
|
||||
nohup python3 ~/Desktop/la-li-lu-le-lo/steam-sync.py > /tmp/steam-sync.log 2>&1 &
|
||||
```
|
||||
|
||||
It answers `Access-Control-Allow-Private-Network: true` on the preflight — Chrome blocks a public page from reaching localhost without it.
|
||||
|
||||
**Cloudflare Worker.** `steam-proxy-worker.js` does the same job with no local process. Deploy it and set `STEAM_PROXY` near the top of the page script. `ALLOW_ORIGIN` is pinned to the Pages origin.
|
||||
|
||||
`syncEndpoint()` resolves: localhost → same-origin `/api/steam`; `STEAM_PROXY` set → that; otherwise → the local helper.
|
||||
|
||||
Parsing notes: match achievements by **apiname** (`ach_002_0xx`), never by display name. The XML has **no `<steamID>` persona field** — fall back to `customURL`, then to what the user typed.
|
||||
|
||||
## Themes
|
||||
|
||||
Ten dark themes, switched from the header and applied by a small script in `<head>` before first paint (no flash). Everything is themed through CSS custom properties on `:root` and `html[data-theme="…"]`. **Any new colour must go through a variable**, otherwise it will be wrong in nine themes. Tints use `color-mix()` so they adapt automatically.
|
||||
|
||||
Two deliberate choices: the **area maps keep a white background** (they reproduce a spreadsheet; inverting them would make the colour legend lie), and there are **no light themes** (removed on request).
|
||||
|
||||
## Deployment
|
||||
|
||||
```bash
|
||||
./deploy.sh "what changed"
|
||||
```
|
||||
|
||||
Builds, commits, pushes to `pages`. Pages redeploys in ~15s. Verify with:
|
||||
|
||||
```bash
|
||||
curl -s -L https://rain.pages.melonbread.xyz/la-li-lu-le-lo/ | sha256sum
|
||||
sha256sum index.html
|
||||
```
|
||||
|
||||
The two must match. A stale deploy is the most common confusing failure — check the hash before debugging anything else.
|
||||
|
||||
## Conventions
|
||||
|
||||
- British-ish plain English in user-facing copy; no marketing voice.
|
||||
- Comments explain **why**, not what, and are only added where the reasoning is non-obvious.
|
||||
- Content lives in the `*_src.py` files as HTML strings; structural/page changes live in `template.html`.
|
||||
- Keep the page dependency-free. Every external thing is a hot-linked image; nothing else loads off-origin.
|
||||
- Do not commit personal data. `tools/verify.js` fails if `DEFAULT_PROFILE` is non-empty.
|
||||
|
|
@ -13,6 +13,8 @@ A single-file, offline-friendly companion page for 100%-ing **Metal Gear Solid 2
|
|||
| `steam-proxy-worker.js` | Optional Cloudflare Worker. Same job as the Python helper, but for when the page is **hosted** somewhere instead of run locally. |
|
||||
| `build/` | The sources. `index.html` is generated from these, so edit here rather than in the built file. |
|
||||
| `deploy.sh` | Rebuilds and pushes to `pages` in one step. |
|
||||
| `tools/` | `verify.js` (headless smoke test) and `theme-contrast.py` (WCAG check). Both run before deploying. |
|
||||
| `AGENTS.md` | Orientation for anyone picking this up cold: architecture, conventions, and the traps already hit. |
|
||||
|
||||
## Hosting it (GitHub Pages, Netlify, Cloudflare Pages, Neocities…)
|
||||
|
||||
|
|
@ -21,6 +23,8 @@ A single-file, offline-friendly companion page for 100%-ing **Metal Gear Solid 2
|
|||
### Updating the live site
|
||||
|
||||
```bash
|
||||
node tools/verify.js # smoke test
|
||||
python3 tools/theme-contrast.py # after touching any colour
|
||||
./deploy.sh "what you changed"
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ try{
|
|||
/* ================= THEMES ================= */html[data-theme="gruvbox"]{
|
||||
--bg:#1d2021; --bg2:#282828; --panel:#282828; --panel2:#32302f; --panel3:#3c3836;
|
||||
--line:#3c3836; --line2:#504945;
|
||||
--txt:#ebdbb2; --dim:#a89984; --dim2:#7c6f64;
|
||||
--txt:#ebdbb2; --dim:#a89984; --dim2:#8f8274;
|
||||
--acc:#b8bb26; --acc-d:#79740e; --acc2:#83a598; --warn:#fabd2f; --miss:#fb4934; --ok:#8ec07c;
|
||||
--on-acc:#1d2021; --bright:#fbf1c7; --field:#1d2021;
|
||||
--glow1:#3c3836; --glow2:#2b2b1f; --overlay:rgba(29,32,33,.96); --steam:#83a598;
|
||||
|
|
@ -40,7 +40,7 @@ try{
|
|||
html[data-theme="monokai"]{
|
||||
--bg:#1e1f1c; --bg2:#272822; --panel:#272822; --panel2:#2f3029; --panel3:#3b3c33;
|
||||
--line:#3b3c33; --line2:#4e4f45;
|
||||
--txt:#f8f8f2; --dim:#a6a28c; --dim2:#75715e;
|
||||
--txt:#f8f8f2; --dim:#a6a28c; --dim2:#8d8974;
|
||||
--acc:#a6e22e; --acc-d:#6b8f1a; --acc2:#66d9ef; --warn:#e6db74; --miss:#f92672; --ok:#a6e22e;
|
||||
--on-acc:#1e1f1c; --bright:#ffffff; --field:#1e1f1c;
|
||||
--glow1:#2f3a1e; --glow2:#33202b; --overlay:rgba(30,31,28,.96); --steam:#66d9ef;
|
||||
|
|
@ -50,7 +50,7 @@ html[data-theme="solarized-dark"]{
|
|||
--bg:#00212b; --bg2:#002b36; --panel:#002b36; --panel2:#073642; --panel3:#0b4452;
|
||||
--line:#0f4b57; --line2:#1a6472;
|
||||
--txt:#93a1a1; --dim:#839496; --dim2:#657b83;
|
||||
--acc:#2aa198; --acc-d:#1a6b64; --acc2:#268bd2; --warn:#b58900; --miss:#dc322f; --ok:#859900;
|
||||
--acc:#2aa198; --acc-d:#24857b; --acc2:#268bd2; --warn:#b58900; --miss:#dc322f; --ok:#859900;
|
||||
--on-acc:#002b36; --bright:#eee8d5; --field:#00252f;
|
||||
--glow1:#073642; --glow2:#00283a; --overlay:rgba(0,33,43,.96); --steam:#268bd2;
|
||||
--k-tag:#2aa198; --k-item:#6c71c4; --k-ach:#268bd2; --k-area:#839496; --k-extra:#d33682;
|
||||
|
|
@ -58,7 +58,7 @@ html[data-theme="solarized-dark"]{
|
|||
html[data-theme="dracula"]{
|
||||
--bg:#191a21; --bg2:#21222c; --panel:#282a36; --panel2:#343746; --panel3:#3d4055;
|
||||
--line:#3d4055; --line2:#535680;
|
||||
--txt:#f8f8f2; --dim:#b8bcd0; --dim2:#6272a4;
|
||||
--txt:#f8f8f2; --dim:#b8bcd0; --dim2:#7484b8;
|
||||
--acc:#50fa7b; --acc-d:#2f9e4c; --acc2:#8be9fd; --warn:#f1fa8c; --miss:#ff5555; --ok:#50fa7b;
|
||||
--on-acc:#191a21; --bright:#ffffff; --field:#1e1f29;
|
||||
--glow1:#2b2d5c; --glow2:#3a1f36; --overlay:rgba(25,26,33,.96); --steam:#8be9fd;
|
||||
|
|
@ -67,7 +67,7 @@ html[data-theme="dracula"]{
|
|||
html[data-theme="nord"]{
|
||||
--bg:#242933; --bg2:#2e3440; --panel:#2e3440; --panel2:#3b4252; --panel3:#434c5e;
|
||||
--line:#434c5e; --line2:#4c566a;
|
||||
--txt:#d8dee9; --dim:#9aa5b5; --dim2:#6d7a8c;
|
||||
--txt:#d8dee9; --dim:#9aa5b5; --dim2:#8290a2;
|
||||
--acc:#88c0d0; --acc-d:#4e8a99; --acc2:#81a1c1; --warn:#ebcb8b; --miss:#bf616a; --ok:#a3be8c;
|
||||
--on-acc:#2e3440; --bright:#eceff4; --field:#272c36;
|
||||
--glow1:#3b4252; --glow2:#2b3a4a; --overlay:rgba(36,41,51,.96); --steam:#88c0d0;
|
||||
|
|
@ -76,8 +76,8 @@ html[data-theme="nord"]{
|
|||
html[data-theme="tokyo-night"]{
|
||||
--bg:#16161e; --bg2:#1a1b26; --panel:#1f2335; --panel2:#24283b; --panel3:#2f3549;
|
||||
--line:#2f3549; --line2:#3b4261;
|
||||
--txt:#c0caf5; --dim:#9aa5ce; --dim2:#565f89;
|
||||
--acc:#7aa2f7; --acc-d:#3d5fa8; --acc2:#7dcfff; --warn:#e0af68; --miss:#f7768e; --ok:#9ece6a;
|
||||
--txt:#c0caf5; --dim:#9aa5ce; --dim2:#6f79a6;
|
||||
--acc:#7aa2f7; --acc-d:#4c72c4; --acc2:#7dcfff; --warn:#e0af68; --miss:#f7768e; --ok:#9ece6a;
|
||||
--on-acc:#16161e; --bright:#ffffff; --field:#1a1b26;
|
||||
--glow1:#24283b; --glow2:#2a1f3d; --overlay:rgba(22,22,30,.96); --steam:#7dcfff;
|
||||
--k-tag:#9ece6a; --k-item:#bb9af7; --k-ach:#7dcfff; --k-area:#9aa5ce; --k-extra:#ff9e64;
|
||||
|
|
@ -94,7 +94,7 @@ html[data-theme="catppuccin"]{
|
|||
html[data-theme="one-dark"]{
|
||||
--bg:#21252b; --bg2:#282c34; --panel:#282c34; --panel2:#2c313a; --panel3:#353b45;
|
||||
--line:#353b45; --line2:#3e4451;
|
||||
--txt:#abb2bf; --dim:#8b93a1; --dim2:#5c6370;
|
||||
--txt:#abb2bf; --dim:#8b93a1; --dim2:#79818f;
|
||||
--acc:#61afef; --acc-d:#2f6ea8; --acc2:#56b6c2; --warn:#e5c07b; --miss:#e06c75; --ok:#98c379;
|
||||
--on-acc:#21252b; --bright:#ffffff; --field:#23272e;
|
||||
--glow1:#2c313a; --glow2:#2b2f45; --overlay:rgba(33,37,43,.96); --steam:#56b6c2;
|
||||
|
|
|
|||
18
index.html
18
index.html
File diff suppressed because one or more lines are too long
125
tools/theme-contrast.py
Normal file
125
tools/theme-contrast.py
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
#!/usr/bin/env python3
|
||||
"""theme-contrast.py — check every theme's foreground colours against its panels.
|
||||
|
||||
python3 tools/theme-contrast.py
|
||||
|
||||
Themes are easy to get subtly wrong: Solarized Light shipped with accent colours that
|
||||
scored 2.6:1 on its own panels, which read as broken rather than stylish. This walks
|
||||
every theme block in build/template.html, resolves the variables the page actually
|
||||
uses, and reports WCAG contrast against --panel.
|
||||
|
||||
Fails (exit 1) if anything drops below MIN_CONTRAST.
|
||||
"""
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
MIN_CONTRAST = 3.0
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
SRC = ROOT / "build" / "template.html"
|
||||
|
||||
# every variable used as a foreground somewhere in the page
|
||||
FOREGROUNDS = [
|
||||
"--txt", "--dim", "--dim2", "--bright", "--acc", "--acc-d", "--acc2",
|
||||
"--warn", "--miss", "--ok", "--steam",
|
||||
"--k-tag", "--k-item", "--k-ach", "--k-miss", "--k-boss", "--k-area",
|
||||
"--k-mode", "--k-extra",
|
||||
]
|
||||
|
||||
|
||||
def block(css, selector):
|
||||
m = re.search(re.escape(selector) + r"\{(.*?)\}", css, re.S)
|
||||
return m.group(1) if m else ""
|
||||
|
||||
|
||||
def variables(text):
|
||||
return dict(re.findall(r"(--[a-z0-9-]+)\s*:\s*([^;]+);", text))
|
||||
|
||||
|
||||
def to_rgb(value):
|
||||
v = value.strip()
|
||||
m = re.fullmatch(r"#([0-9a-fA-F]{6})", v)
|
||||
if m:
|
||||
n = int(m.group(1), 16)
|
||||
return ((n >> 16) & 255, (n >> 8) & 255, n & 255)
|
||||
m = re.fullmatch(r"#([0-9a-fA-F]{3})", v)
|
||||
if m:
|
||||
return tuple(int(c * 2, 16) for c in m.group(1))
|
||||
return None
|
||||
|
||||
|
||||
def luminance(rgb):
|
||||
def channel(x):
|
||||
x /= 255
|
||||
return x / 12.92 if x <= 0.03928 else ((x + 0.055) / 1.055) ** 2.4
|
||||
r, g, b = rgb
|
||||
return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b)
|
||||
|
||||
|
||||
def contrast(a, b):
|
||||
la, lb = luminance(a), luminance(b)
|
||||
hi, lo = max(la, lb), min(la, lb)
|
||||
return (hi + 0.05) / (lo + 0.05)
|
||||
|
||||
|
||||
def main():
|
||||
css = SRC.read_text(encoding="utf-8")
|
||||
root = variables(block(css, ":root"))
|
||||
themes = {m.group(1): variables(m.group(2))
|
||||
for m in re.finditer(r'html\[data-theme="([^"]+)"\]\{(.*?)\}', css, re.S)}
|
||||
|
||||
if not themes:
|
||||
print("no theme blocks found in", SRC)
|
||||
return 1
|
||||
|
||||
panel = to_rgb(root["--panel"])
|
||||
failures = []
|
||||
print(f"{'theme':<18}{'worst':>7} {'':<3}colours below {MIN_CONTRAST}:1")
|
||||
print("-" * 72)
|
||||
for name, overrides in themes.items():
|
||||
merged = dict(root)
|
||||
merged.update(overrides)
|
||||
rows = []
|
||||
for fg in FOREGROUNDS:
|
||||
value = merged.get(fg, "")
|
||||
if not value.startswith("#"):
|
||||
continue
|
||||
rgb = to_rgb(value)
|
||||
if rgb:
|
||||
rows.append((contrast(rgb, panel), fg))
|
||||
rows.sort()
|
||||
bad = [f"{fg}={r:.1f}" for r, fg in rows if r < MIN_CONTRAST]
|
||||
if bad:
|
||||
failures.append((name, bad))
|
||||
worst, worst_name = rows[0]
|
||||
print(f"{name:<18}{worst:>6.1f} {'' if not bad else '<- ' + ', '.join(bad)}")
|
||||
|
||||
# text drawn on top of an accent fill (buttons, chips, tick boxes)
|
||||
print()
|
||||
print(f"{'theme':<18}{'on-acc/acc':>11} status")
|
||||
print("-" * 72)
|
||||
for name, overrides in themes.items():
|
||||
merged = dict(root)
|
||||
merged.update(overrides)
|
||||
a, b = to_rgb(merged.get("--on-acc", "")), to_rgb(merged.get("--acc", ""))
|
||||
if not a or not b:
|
||||
continue
|
||||
ratio = contrast(a, b)
|
||||
bad = ratio < MIN_CONTRAST
|
||||
if bad:
|
||||
failures.append((name, [f"--on-acc on --acc={ratio:.1f}"]))
|
||||
print(f"{name:<18}{ratio:>10.1f} {'' if not bad else '<- LOW'}")
|
||||
|
||||
print()
|
||||
if failures:
|
||||
print("FAILED — these themes have unreadable foreground/background pairs:")
|
||||
for name, bad in failures:
|
||||
print(f" {name}: {', '.join(bad)}")
|
||||
print("\nDarken the offending colour for that theme, then re-run ./deploy.sh")
|
||||
return 1
|
||||
print(f"all {len(themes)} themes are at or above {MIN_CONTRAST}:1 against --panel")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
217
tools/verify.js
Normal file
217
tools/verify.js
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* verify.js — smoke-test the built page without a browser.
|
||||
*
|
||||
* node tools/verify.js
|
||||
*
|
||||
* Loads index.html, runs its <script> against a stubbed DOM/canvas, and checks that
|
||||
* every tab renders, all the maps draw, the state plumbing works and no personal
|
||||
* data shipped. Exits non-zero on failure, so it is safe to run after a build.
|
||||
*
|
||||
* Why a stub instead of a real browser: browser screenshot tooling in this project's
|
||||
* agent harness crashes the GUI process, and this is faster anyway. It cannot check
|
||||
* layout or styling — only that the logic runs and produces sensible output.
|
||||
*/
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const vm = require('vm');
|
||||
|
||||
const ROOT = path.resolve(__dirname, '..');
|
||||
const FILE = path.join(ROOT, 'index.html');
|
||||
|
||||
let failures = 0;
|
||||
const ok = (label, pass, detail) => {
|
||||
if (!pass) failures++;
|
||||
console.log(` ${pass ? 'ok ' : 'FAIL'} ${label}${detail ? ' ' + detail : ''}`);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------- load the page
|
||||
const html = fs.readFileSync(FILE, 'utf8');
|
||||
const scripts = [...html.matchAll(/<script>([\s\S]*?)<\/script>/g)].map((m) => m[1]);
|
||||
if (!scripts.length) {
|
||||
console.error('no <script> block found in index.html');
|
||||
process.exit(1);
|
||||
}
|
||||
const js = scripts[scripts.length - 1];
|
||||
|
||||
// ---------------------------------------------------------------- DOM stub
|
||||
const renders = {};
|
||||
const draw = { fillRect: 0, fillText: 0, setTransform: 0, badRect: 0, colours: new Set() };
|
||||
const ctx = {
|
||||
_font: '',
|
||||
set fillStyle(v) { draw.colours.add(v); },
|
||||
get fillStyle() { return '#000'; },
|
||||
set font(v) { this._font = v; },
|
||||
get font() { return this._font; },
|
||||
set textBaseline(v) {},
|
||||
setTransform() { draw.setTransform++; },
|
||||
clearRect() {},
|
||||
fillRect(x, y, w, h) {
|
||||
draw.fillRect++;
|
||||
if (![x, y, w, h].every(Number.isFinite)) draw.badRect++;
|
||||
},
|
||||
measureText(t) { return { width: t.length * (parseFloat(this._font) || 11) * 0.55 }; },
|
||||
fillText(t, x, y, maxW) {
|
||||
draw.fillText++;
|
||||
if (maxW !== undefined && t.length * (parseFloat(this._font) || 11) * 0.55 > maxW + 0.5) {
|
||||
draw.condensed = (draw.condensed || 0) + 1; // canvas squashes to honour maxWidth
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const els = {};
|
||||
function makeEl(id) {
|
||||
const classes = new Set();
|
||||
return {
|
||||
id, style: {}, dataset: {}, textContent: '', checked: true, hidden: false,
|
||||
clientWidth: 1200, clientHeight: 800, width: 0, height: 0, offsetWidth: 1920, offsetHeight: 1080,
|
||||
scrollTop: 0, scrollLeft: 0,
|
||||
set innerHTML(v) { renders[id] = v; },
|
||||
get innerHTML() { return renders[id] || ''; },
|
||||
addEventListener() {}, appendChild() {}, replaceWith() {}, remove() {}, focus() {},
|
||||
setSelectionRange() {}, closest() { return null; },
|
||||
getBoundingClientRect() { return { left: 0, top: 0, width: 1920, height: 1080 }; },
|
||||
querySelectorAll() { return []; }, querySelector() { return null; },
|
||||
classList: {
|
||||
add: (c) => classes.add(c), remove: (c) => classes.delete(c),
|
||||
contains: (c) => classes.has(c),
|
||||
toggle: (c, f) => { const on = f === undefined ? !classes.has(c) : f; on ? classes.add(c) : classes.delete(c); return on; },
|
||||
},
|
||||
_classes: classes,
|
||||
getContext() { return ctx; },
|
||||
};
|
||||
}
|
||||
const store = {};
|
||||
const document = {
|
||||
getElementById: (id) => els[id] || (els[id] = makeEl(id)),
|
||||
querySelectorAll: () => [], querySelector: () => null,
|
||||
createElement: () => makeEl('tmp'),
|
||||
addEventListener() {},
|
||||
body: makeEl('body'),
|
||||
documentElement: {
|
||||
_a: {},
|
||||
setAttribute(k, v) { this._a[k] = v; },
|
||||
removeAttribute(k) { delete this._a[k]; },
|
||||
getAttribute(k) { return this._a[k] ?? null; },
|
||||
},
|
||||
};
|
||||
|
||||
function context(hostname, protocol, proxy) {
|
||||
let src = js;
|
||||
if (proxy !== undefined) src = src.replace(/const STEAM_PROXY = '[^']*';/, `const STEAM_PROXY = '${proxy}';`);
|
||||
const sb = {
|
||||
console, document, Date, JSON, Math, Object, Array, String, Number, isFinite, Set, Map,
|
||||
window: { devicePixelRatio: 1, scrollTo() {}, addEventListener() {} },
|
||||
location: { hostname, protocol, hash: '' },
|
||||
localStorage: {
|
||||
getItem: (k) => (k in store ? store[k] : null),
|
||||
setItem: (k, v) => { store[k] = v; },
|
||||
removeItem: (k) => { delete store[k]; },
|
||||
},
|
||||
setTimeout() {}, clearTimeout() {}, requestAnimationFrame: (f) => f(),
|
||||
fetch: () => Promise.reject(new Error('stub: no network in verify')),
|
||||
Blob: function () {}, URL: { createObjectURL: () => '' }, FileReader: function () {},
|
||||
confirm: () => false,
|
||||
};
|
||||
sb.globalThis = sb;
|
||||
vm.createContext(sb);
|
||||
vm.runInContext(src, sb, { filename: 'index.html<script>' });
|
||||
return sb;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- run the checks
|
||||
console.log('verifying', path.relative(process.cwd(), FILE), '\n');
|
||||
|
||||
let sb;
|
||||
try {
|
||||
sb = context('127.0.0.1', 'http:');
|
||||
} catch (e) {
|
||||
console.error('boot() threw:', e.message);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('boot');
|
||||
ok('boot() completed without throwing', true);
|
||||
|
||||
console.log('\nrendering');
|
||||
const TABS = ['roadmap', 'ach', 'tags', 'maps', 'steam', 'boss', 'miss', 'vr', 'src'];
|
||||
for (const t of TABS) {
|
||||
vm.runInContext(`go("${t}")`, sb);
|
||||
const n = (renders['tab-' + t] || '').length;
|
||||
ok(`tab ${t.padEnd(10)}`, n > 200, `${n} bytes`);
|
||||
}
|
||||
|
||||
console.log('\ncontent');
|
||||
vm.runInContext('go("roadmap")', sb);
|
||||
const roadmap = renders['tab-roadmap'] || '';
|
||||
const count = (hay, re) => (hay.match(re) || []).length;
|
||||
const stats = {
|
||||
achievements: vm.runInContext('ACHIEVEMENTS.length', sb),
|
||||
dogtags: vm.runInContext('DOGTAGS.flatMap(l=>l.tags).length', sb),
|
||||
lists: vm.runInContext('DOGTAGS.length', sb),
|
||||
maps: vm.runInContext('MAPS.length', sb),
|
||||
roadmapSteps: vm.runInContext('ROADMAP.flatMap(p=>p.steps).length', sb),
|
||||
themes: vm.runInContext('THEMES.length', sb),
|
||||
photos: count(roadmap, /class="lb"/g),
|
||||
stepPhotos: count(roadmap, /<div class="shots">/g),
|
||||
subBoxes: count(roadmap, /data-sub="/g),
|
||||
subBlocks: count(roadmap, /class="subs"/g),
|
||||
colourSpans: count(roadmap, /class="k-/g),
|
||||
};
|
||||
for (const [k, v] of Object.entries(stats)) ok(`${k.padEnd(14)}`, v > 0, String(v));
|
||||
|
||||
// every data-sub value must be one the change handler understands
|
||||
const kinds = vm.runInContext(
|
||||
`(()=>{const s=new Set();ROADMAP.forEach(p=>p.steps.forEach(st=>{for(const m of subsHTML(st).matchAll(/data-sub="([^"]*)"/g))s.add(m[1]);}));return [...s];})()`, sb);
|
||||
ok('data-sub values are handler keys', kinds.every((k) => ['ach', 'tag', 'item'].includes(k)), JSON.stringify(kinds));
|
||||
|
||||
// dog-tag sub-checkboxes must cover every tag in every list, exactly once
|
||||
console.log('\ndog-tag coverage (sub-checkboxes vs the lists)');
|
||||
const expect = { 'Very Easy': [24, 43], Easy: [26, 44], Normal: [33, 49], Hard: [35, 52], Extreme: [34, 54] };
|
||||
for (const [diff, [tank, plant]] of Object.entries(expect)) {
|
||||
const n = vm.runInContext(
|
||||
`(()=>{S.roadDiff=${JSON.stringify(diff)};let n=0;ROADMAP.forEach(p=>p.steps.forEach(s=>{n+=(subsHTML(s).match(/data-tagbox=/g)||[]).length;}));return n;})()`, sb);
|
||||
ok(`${diff.padEnd(10)} tags`, n === tank + plant, `${n}/${tank + plant}`);
|
||||
}
|
||||
|
||||
// no photo may appear in two steps
|
||||
vm.runInContext('S.roadDiff="Very Easy"', sb);
|
||||
const dupPhotos = vm.runInContext(
|
||||
`(()=>{const seen=new Set(),dupes=[];ROADMAP.forEach(p=>p.steps.forEach(s=>(s.photos||[]).forEach(x=>{if(seen.has(x.u))dupes.push(x.u);else seen.add(x.u);})));return dupes;})()`, sb);
|
||||
ok('no photo appears twice', dupPhotos.length === 0, dupPhotos.length ? dupPhotos.slice(0, 2).join(', ') : '');
|
||||
|
||||
console.log('\nmaps');
|
||||
let drawn = 0;
|
||||
const N = vm.runInContext('MAPS.length', sb);
|
||||
for (let i = 0; i < N; i++) {
|
||||
try { vm.runInContext(`mapIdx=${i}; mapZoom=1.5; drawMap();`, sb); drawn++; }
|
||||
catch (e) { ok(`map ${i}`, false, e.message); }
|
||||
}
|
||||
ok(`all ${N} maps draw`, drawn === N, `${draw.fillRect} fillRect, ${draw.fillText} fillText`);
|
||||
ok('no non-finite geometry', draw.badRect === 0, String(draw.badRect));
|
||||
ok('no squashed labels', !draw.condensed, draw.condensed ? `${draw.condensed} condensed` : '');
|
||||
|
||||
console.log('\nthemes');
|
||||
const themeNames = vm.runInContext('THEMES.map(t=>t[0])', sb);
|
||||
ok('themes defined', themeNames.length > 0, themeNames.join(', '));
|
||||
const css = (html.match(/<style>([\s\S]*?)<\/style>/) || [, ''])[1];
|
||||
const themeBlocks = [...css.matchAll(/html\[data-theme="([^"]+)"\]/g)].map((m) => m[1]);
|
||||
ok('every listed theme has a CSS block', themeNames.filter((t) => t !== 'codec').every((t) => themeBlocks.includes(t)),
|
||||
`${themeBlocks.length} blocks`);
|
||||
ok('no light themes', !themeNames.some((t) => /light/i.test(t)));
|
||||
|
||||
console.log('\nprivacy / sync');
|
||||
ok('no Steam handle shipped', vm.runInContext('DEFAULT_PROFILE', sb) === '', JSON.stringify(vm.runInContext('DEFAULT_PROFILE', sb)));
|
||||
const endpoints = {
|
||||
local: vm.runInContext('syncEndpoint()', context('127.0.0.1', 'http:')),
|
||||
pages: vm.runInContext('syncEndpoint()', context('rain.pages.melonbread.xyz', 'https:')),
|
||||
disk: vm.runInContext('syncEndpoint()', context('', 'file:')),
|
||||
};
|
||||
ok('local uses same-origin api', endpoints.local === '/api/steam', endpoints.local);
|
||||
ok('hosted falls back to helper', String(endpoints.pages).startsWith('http://127.0.0.1:8765'), endpoints.pages);
|
||||
ok('file:// uses helper', String(endpoints.disk).startsWith('http://127.0.0.1:8765'), endpoints.disk);
|
||||
const worker = fs.readFileSync(path.join(ROOT, 'steam-proxy-worker.js'), 'utf8');
|
||||
ok('worker origin is pinned', !/ALLOW_ORIGIN\s*=\s*'\*'/.test(worker),
|
||||
(worker.match(/const ALLOW_ORIGIN = '([^']*)'/) || [, '?'])[1]);
|
||||
|
||||
console.log('\n' + (failures ? `${failures} CHECK(S) FAILED` : 'all checks passed'));
|
||||
process.exit(failures ? 1 : 0);
|
||||
Loading…
Add table
Add a link
Reference in a new issue