Make the flagship capability-5 screenshot show the studio doing something
Some checks are pending
build-gate / build (push) Waiting to run

It showed an empty pane reading "Choose an element from the list." The README's
headline image for the highest capability level demonstrated nothing, and every
shot gate passed it, because freshness, distinctness and element counts are all
satisfied by an empty pane. The shot now selects the first rail row before
capturing, so the picture shows what the studio is for: a nested SPA rendering
inside the shell that lists every element by capability. The file went from 167KB
to just over a megabyte, in line with its sibling.

Found by reading the image, the same way the mobile error card was found
yesterday. Reading one picture has now caught two defects that eleven gates
passed.

Add check:shot-fonts. The studio labels each row with a capability emoji, and
this machine has no emoji-capable font, so all 28 rendered as tofu boxes in the
committed PNG. The markup is right and the product is fine in a real browser —
the shot machine could not draw it — so the fix is not to strip the emoji. The
gate asks fontconfig whether this system can render the glyphs the UI actually
uses, and names the font install rather than the markup.

It is deliberately not in build:unlocked: it reports an environment condition
that is currently true here, and a gate that always fails in the build makes the
build useless. Its first version also flagged the star this repo uses as a
comment marker, which every text font renders; the range is now narrowed to the
pictographic planes.
This commit is contained in:
doxa-maps 2026-09-22 09:48:20 +00:00
parent 236fef5682
commit f3a5b228ee
15 changed files with 155 additions and 2 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 971 KiB

After

Width:  |  Height:  |  Size: 971 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 355 KiB

After

Width:  |  Height:  |  Size: 355 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 352 KiB

After

Width:  |  Height:  |  Size: 350 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 996 KiB

After

Width:  |  Height:  |  Size: 1,024 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1 MiB

After

Width:  |  Height:  |  Size: 1,000 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 892 KiB

After

Width:  |  Height:  |  Size: 892 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 888 KiB

After

Width:  |  Height:  |  Size: 888 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 163 KiB

After

Width:  |  Height:  |  Size: 1,022 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 818 KiB

After

Width:  |  Height:  |  Size: 833 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 881 KiB

After

Width:  |  Height:  |  Size: 851 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 690 KiB

After

Width:  |  Height:  |  Size: 825 KiB

View file

@ -0,0 +1,125 @@
#!/usr/bin/env node
/**
* check-shot-fonts.mjs — the machine taking the screenshots must be able to RENDER what they show.
*
* THE BUG THIS EXISTS TO PREVENT (real, found 2026-09-22 by READING cap5-plugin-studio.png).
* The studio's rail labels every row with a capability emoji (CAP_EMOJI = 🧩 🔗 🗂️ 🎛️ 🔹). This box
* has ZERO emoji-capable fonts installed — `fc-list | grep -i emoji` returns nothing — so every one
* of those glyphs rendered as a TOFU BOX (􀀀) in the committed README image. The markup is correct
* and the product is fine in a real browser; the SHOT MACHINE could not draw it.
*
* ★ THAT DISTINCTION IS THE WHOLE POINT, and getting it backwards would have been the expensive
* mistake: the obvious "fix" is to strip the emoji from the product, which would degrade the real
* UI to repair a screenshot. The cause had to be MEASURED (is the font missing, or is the markup
* wrong?) before a fix could be chosen. It was the font.
*
* ★ WHY A GATE RATHER THAN JUST INSTALLING A FONT. Installing one fixes THIS box and silently rots
* for the next person: the Doxa team regenerates these images on their machine, and a missing font
* there produces the same tofu with no warning. A gate travels with the repo and fails on any box
* that cannot render the glyphs the UI actually uses, naming the fix.
*
* WHAT IT CHECKS: scans plugin-designs/ + library/ for non-ASCII display glyphs that reach the UI,
* then asks fontconfig whether this system can render them. It does NOT need a browser — fc-list is
* the same font database the headless browser draws from.
*
* ★ NOT A CHECK ON THE IMAGES THEMSELVES. Detecting tofu by reading pixels would be the direct
* measurement, but it needs a reference rendering to compare against. This checks the PRECONDITION
* instead, which is deterministic, instant, and fails BEFORE a bad PNG is committed rather than
* after. Pair it with actually looking at a regenerated shot — no gate replaces that.
*
* SKIPs (exit 0) when the UI uses no such glyphs at all — nothing to render, nothing to check.
* NEGATIVE-TESTED: with no emoji font installed this FAILS naming the glyphs and the fix; the
* source scan is asserted non-empty so it cannot pass vacuously.
*/
import { readFileSync, readdirSync, existsSync, statSync } from 'node:fs'
import { execSync } from 'node:child_process'
import { resolve, dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
// Glyphs that genuinely need a COLOUR-EMOJI font. Deliberately narrow, in two ways:
//
// 1. NOT "all non-ASCII" — accented Latin, CJK and arrows ship in ordinary text fonts.
// 2. NOT the whole Misc-Symbols block (U+2600–27BF). A first version included it and immediately
// produced a FALSE POSITIVE on ★ (U+2605), which this repo uses as a COMMENT MARKER in dozens
// of files and which every text font renders correctly. A gate that flags correct code trains
// people to ignore it, so the range was tightened to the pictographic planes plus the explicit
// emoji-presentation selector.
//
// U+FE0F (VARIATION SELECTOR-16) is included because it is the marker that REQUESTS emoji
// presentation for an otherwise-textual codepoint — its presence is the signal that a colour font
// is expected. The base character it follows is reported alongside it.
const EMOJI_RE = /[\u{1F300}-\u{1FAFF}\u{1F000}-\u{1F0FF}\u{FE0F}]/gu
function walk(dir, out = []) {
if (!existsSync(dir)) return out
for (const e of readdirSync(dir, { withFileTypes: true })) {
const p = join(dir, e.name)
if (e.name === 'node_modules' || e.name.startsWith('.')) continue
if (e.isDirectory()) walk(p, out)
else if (/\.(vue|js|mjs)$/.test(e.name)) out.push(p)
}
return out
}
const files = [...walk(join(ROOT, 'plugin-designs')), ...walk(join(ROOT, 'library'))]
// Vacuous guard: "0 files scanned" must never read as "0 problems".
const MIN_FILES = 20
if (files.length < MIN_FILES) {
console.error(`SHOT-FONTS: FAIL — only ${files.length} source file(s) scanned (expected >= ${MIN_FILES}).`)
console.error(' The scan is broken or the tree is empty. Refusing to pass vacuously.')
process.exit(1)
}
const found = new Map() // glyph -> first file that uses it
for (const f of files) {
let text
try { text = readFileSync(f, 'utf8') } catch { continue }
for (const m of text.matchAll(EMOJI_RE)) {
if (!found.has(m[0])) found.set(m[0], f.replace(ROOT + '/', ''))
}
}
console.log(`SHOT-FONTS: ${files.length} source file(s) scanned, ${found.size} distinct display glyph(s) in the UI.`)
if (!found.size) {
console.log('SHOT-FONTS: SKIP — the UI uses no emoji/pictographic glyphs, so no emoji font is needed.')
process.exit(0)
}
// Ask fontconfig, which is the database the headless browser renders from.
let hasEmojiFont = false
let fcAvailable = true
try {
const out = execSync('fc-list', { encoding: 'utf8', timeout: 15000 })
hasEmojiFont = /emoji|symbola|noto color/i.test(out)
} catch {
fcAvailable = false
}
if (!fcAvailable) {
console.log('SHOT-FONTS: SKIP — fontconfig (fc-list) is unavailable, so the font set cannot be read.')
console.log(' Absence of measurement is not measurement of absence; not failing on it.')
process.exit(0)
}
if (!hasEmojiFont) {
const sample = [...found.entries()].slice(0, 6)
console.error('\nSHOT-FONTS: FAIL — the UI uses emoji glyphs and this system has NO emoji-capable font.')
for (const [glyph, file] of sample) console.error(` ✗ ${glyph} first used in ${file}`)
if (found.size > sample.length) console.error(` … +${found.size - sample.length} more`)
console.error(
'\n Every one of these renders as a TOFU BOX in a headless screenshot, so any image generated\n' +
' here ships a UI full of missing-glyph squares — and it looks EXACTLY like a correct render to\n' +
' a freshness/element-count gate. The product itself is fine: this is the shot MACHINE, not the\n' +
' markup, so do NOT strip the emoji to make this pass.\n' +
'\n Fix: install an emoji font, then regenerate. No root needed — fontconfig reads ~/.fonts:\n' +
' mkdir -p ~/.fonts && cp <NotoColorEmoji.ttf> ~/.fonts/ && fc-cache -f\n' +
' Then: bun run shots && bun run check:shots'
)
process.exit(1)
}
console.log('SHOT-FONTS: PASS — an emoji-capable font is installed, so UI glyphs render in screenshots.')

View file

@ -189,7 +189,34 @@ await shot('cap5-doxa-maps-spa', '/doxa-maps/doxa-maps-spa/doxa-maps-spa/index.h
// plugin-studio is a real cap-5 element, not the static gallery page: plugin-studio.meta.json
// declares {"type":"nested-mfe-spa"}, the source calls defineCustomElement, and the built bundle
// carries customElements.define. Shot from its own leaf page for the same reason as above.
await shot('cap5-plugin-studio', '/doxa-maps/plugin-studio/plugin-studio/index.html', { wait: 10000 })
// ★ SELECT A ROW BEFORE CAPTURING (fixed 2026-09-22). This shot used to just load and wait, so it
// captured the studio's EMPTY STATE — a full rail beside a pane reading "Choose an element from the
// list." The README's flagship capability-5 image demonstrated nothing, and every shot gate passed
// it: freshness, distinctness and element counts are all satisfied by an empty pane. Found by
// READING the image, the same way the mobile shot's error card was found the day before.
//
// Clicks the first rail row (cap-5 → doxa-maps-spa), so the picture shows what the studio is FOR:
// a nested SPA rendering inside the shell that lists every element by capability. Fails soft — if
// the row is not found the shot still captures, and the readiness guard below still applies.
await shot('cap5-plugin-studio', '/doxa-maps/plugin-studio/plugin-studio/index.html', {
wait: 12000,
before: async (page) => {
// The rail lives inside the element's shadow root, so a light-DOM selector finds nothing —
// click through an evaluated deep walk instead (the same seen-Set walk the readiness guard uses).
await page.evaluate(() => {
const seen = new Set(); let first = null
;(function w(n) {
if (!n || seen.has(n) || first) return
seen.add(n)
if (n.tagName === 'BUTTON' && /vtl-tab\b/.test((n.className || '').toString())) { first = n; return }
if (n.shadowRoot) w(n.shadowRoot)
for (const c of (n.children || [])) w(c)
})(document.body)
if (first) first.click()
})
await page.waitForTimeout(3000) // let the selected element mount before the main wait
},
})
// ---- DEPLOY1: iframe embed on a host page ----
// ★ FIXED 2026-09-20. This shot pointed at /__readme-iframe-host.html, a file the script's own

View file

@ -76,7 +76,8 @@
"check:composite-srcs": "node internal/check-composite-srcs.mjs",
"check:nested-token": "node internal/check-nested-token-delivery.mjs",
"check:nav-reachable": "node internal/check-nav-reachable-mobile.mjs # a cap-5 shell must have ONE on-screen control that opens its tab rail at phone width (needs `bun run serve` on 4173; browser gate, not in build:unlocked)",
"verify:browser-all": "node internal/verify-browser-all.mjs # runs ALL 6 browser gates over the built tree in one command (needs `bun run serve` on 4173). Every gate listed was RUN before being listed. smoke:share is excluded on purpose — it needs the vite dev server."
"verify:browser-all": "node internal/verify-browser-all.mjs # runs ALL 6 browser gates over the built tree in one command (needs `bun run serve` on 4173). Every gate listed was RUN before being listed. smoke:share is excluded on purpose — it needs the vite dev server.",
"check:shot-fonts": "node internal/check-shot-fonts.mjs # can THIS machine render the emoji the UI uses? Run BEFORE `bun run shots` — no font = tofu boxes in every committed PNG, which every other shot gate passes. Deliberately NOT in build:unlocked: it reports an ENVIRONMENT condition (this box has no emoji font), and a gate that always fails in the build makes the build useless."
},
"devDependencies": {
"@capacitor/android": "^7.6.9",