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.
|
Before Width: | Height: | Size: 971 KiB After Width: | Height: | Size: 971 KiB |
|
Before Width: | Height: | Size: 355 KiB After Width: | Height: | Size: 355 KiB |
|
Before Width: | Height: | Size: 352 KiB After Width: | Height: | Size: 350 KiB |
|
Before Width: | Height: | Size: 996 KiB After Width: | Height: | Size: 1,024 KiB |
|
Before Width: | Height: | Size: 1 MiB After Width: | Height: | Size: 1,000 KiB |
|
Before Width: | Height: | Size: 892 KiB After Width: | Height: | Size: 892 KiB |
|
Before Width: | Height: | Size: 888 KiB After Width: | Height: | Size: 888 KiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 163 KiB After Width: | Height: | Size: 1,022 KiB |
|
Before Width: | Height: | Size: 818 KiB After Width: | Height: | Size: 833 KiB |
|
Before Width: | Height: | Size: 881 KiB After Width: | Height: | Size: 851 KiB |
|
Before Width: | Height: | Size: 690 KiB After Width: | Height: | Size: 825 KiB |
125
internal/check-shot-fonts.mjs
Normal 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.')
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||