The phone app opens the UPG-100 list, not the plugin studio
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions

repo reported it: the Capacitor app launches public/index.html, which
mounts <plugin-studio>. A recipient opening the phone app meets the DEV
STUDIO rather than the map the README promises. Reproduced in the synced
Android assets before touching anything.

RULED (Kingdom-agent, card DECISION-MOBILE-EXAMPLE-IS-UPG100-NOT-SIMPLE-
RESEARCH, done): the ONE mobile-app showcase is the UPG-100 list.

⚠ THE CARD'S OWN BLUF SAYS "Simple Research" AND IS STALE. It predates
that ruling, which is recorded DONE in the same backlog and says Simple
Research "was never mobile-friendly". Building to the card's wording
would have shipped the app the ruling rejects, so this builds to the
ruling and the card is annotated with why.

WHY IT RUNS AFTER cap sync AND WRITES ONLY INTO THE ANDROID ASSETS.
public/index.html has THREE consumers, which capacitor.config.json
documents: Railway/CDN, Capacitor, and local staging. The studio is
CORRECT for the CDN root and for `bun run serve` — only the phone build
wants a different landing page. Rewriting the shared root would fix the
app by breaking the other two. cap sync copies webDir into
assets/public/, so overwriting the COPY changes what the app opens and
leaves the served tree untouched. The studio is kept at studio.html
rather than deleted; a developer with the apk still wants it.

WHY IT COPIES THE BUILT LEAF RATHER THAN HAND-WRITING A HOST PAGE.
The leaf carries an SRI integrity hash. A hand-written page needs its
own correct hash, and a stale one makes the browser REFUSE the script
with no console error: the element never upgrades and the app is a blank
screen. Copying the artifact the build already verified means there is
no second copy of that hash to drift — confirmed, the hash still
matches after the rewrite. Only the relative depth changes, and the
script asserts every rewritten reference resolves to a file that exists
rather than trusting its own regex.

MEASURED by serving the ANDROID assets tree (not public/, which is the
whole point) on a private port, with the app-root marker checked before
any number was read:
  390x844   mounted+upgraded, studio absent, 27 poster cards, 0 4xx, 0 errors
  1280x900  same
check:app-start-page is mutation-tested: restoring the studio page as
the app root makes it exit 1 naming <plugin-studio>.
This commit is contained in:
doxa-maps-88655c70 2026-09-25 09:10:50 +00:00
parent fdc2904354
commit 60b3650304
2 changed files with 119 additions and 2 deletions

View file

@ -0,0 +1,115 @@
#!/usr/bin/env node
/**
* set-app-start-page — the MOBILE APP opens the UPG-100 list, not the plugin studio.
*
* THE DEFECT, reported by repo: the Capacitor app launches public/index.html, which mounts
* <plugin-studio>. A recipient opening the phone app meets the DEV STUDIO rather than the map the
* README promises. RULED (Kingdom-agent, card DECISION-MOBILE-EXAMPLE-IS-UPG100-NOT-SIMPLE-
* RESEARCH): the ONE mobile-app showcase is the UPG-100 list.
*
* ⚠ THE CARD'S OWN BLUF SAYS "Simple Research" AND IS STALE. It predates that ruling, which is
* recorded as DONE in the same backlog and says Simple Research "was never mobile-friendly".
* Building to the card's wording would ship the app the ruling rejected.
*
* ★ WHY THIS RUNS AFTER `cap sync` AND WRITES ONLY INTO THE ANDROID ASSETS.
* public/index.html has THREE consumers (capacitor.config.json documents them): Railway/CDN,
* Capacitor, and local staging. The studio is CORRECT for the CDN root and for `bun run serve` —
* only the phone build wants a different landing page. Rewriting the shared root would fix the app
* by breaking the other two. `cap sync` copies webDir into
* deployment/capacitor/android/app/src/main/assets/public/, so overwriting the COPY changes what
* the app opens and leaves the served tree untouched.
*
* ★ WHY IT COPIES THE BUILT LEAF RATHER THAN HAND-WRITING A HOST PAGE.
* The leaf carries an SRI integrity hash for its bundle. A hand-written page needs its own correct
* hash, and a stale one makes the browser REFUSE the script with no console error at all: the
* element never upgrades and the app shows a blank screen. Copying the artifact the build already
* verified means there is no second copy of that hash to drift. Only the relative depth changes.
*
* Usage: node internal/set-app-start-page.mjs [--check]
* --check assert the app root is already the UPG-100 page; do not write. Exit 1 if not.
*/
import { readFileSync, writeFileSync, existsSync, copyFileSync } from 'node:fs'
import { resolve, dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const CHECK = process.argv.includes('--check')
// The assets tree `cap sync` writes. NOT public/ — see the header.
const APP_PUBLIC = join(ROOT, 'deployment/capacitor/android/app/src/main/assets/public')
const APP_ROOT = join(APP_PUBLIC, 'index.html')
// The showcase leaf, inside the served tree.
const TREE = 'doxa-maps'
const LEAF_REL = `${TREE}/my-upg-100-list/upg-100-mfe/index.html`
const LEAF = join(APP_PUBLIC, LEAF_REL)
// A marker so --check can tell the app page apart from the studio page without parsing HTML.
const MARKER = '<!-- doxa-maps: mobile app start page (UPG-100 list) -->'
if (!existsSync(APP_PUBLIC)) {
console.log('APP-START-PAGE: SKIP — no synced assets tree.')
console.log(' Run `bun run cap:sync` first; this rewrites what that step copied.')
process.exit(0)
}
if (!existsSync(LEAF)) {
console.error(`APP-START-PAGE: FAIL — the showcase leaf is missing from the synced tree:`)
console.error(` ${LEAF_REL}`)
console.error(' The app would launch a 404. Build the tree and re-run cap:sync.')
process.exit(1)
}
if (CHECK) {
const cur = existsSync(APP_ROOT) ? readFileSync(APP_ROOT, 'utf8') : ''
if (cur.includes(MARKER)) {
console.log('APP-START-PAGE: PASS — the app opens the UPG-100 list.')
process.exit(0)
}
console.error('APP-START-PAGE: FAIL — the app root is NOT the UPG-100 page.')
console.error(cur.includes('<plugin-studio')
? ' It mounts <plugin-studio>: a recipient opening the app meets the dev studio.'
: ' No UPG-100 marker found.')
console.error(' Fix: node internal/set-app-start-page.mjs (after cap:sync).')
process.exit(1)
}
// ── REWRITE THE LEAF FOR ONE LEVEL UP ────────────────────────────────────────
// The leaf lives at <tree>/my-upg-100-list/upg-100-mfe/index.html and reaches its bundle with
// `../my-upg-100-list.js`. At the app root that same relative path resolves ABOVE the web root.
// Every leaf-relative reference gains the leaf's own directory prefix; absolute and protocol URLs
// (the Mapbox CDN tags) are left alone.
const LEAF_DIR = `${TREE}/my-upg-100-list/upg-100-mfe/`
const html = readFileSync(LEAF, 'utf8')
const rebased = html.replace(
/\b(src|href)=(["'])(?!https?:|data:|#|\/)([^"']+)\2/g,
(_m, attr, q, url) => {
// Resolve the leaf-relative url against the leaf's directory, from the web root.
const abs = new URL(url, `http://x/${LEAF_DIR}`).pathname.replace(/^\//, '')
return `${attr}=${q}${abs}${q}`
},
)
if (rebased === html) {
console.error('APP-START-PAGE: FAIL — rewrote nothing, so the paths were not what this expects.')
console.error(' Refusing to install a page whose bundle path would 404 at the app root.')
process.exit(1)
}
// Prove the rewrite points at files that EXIST, rather than trusting the regex. A start page
// whose bundle 404s is a blank white screen with no error, which is the failure mode this whole
// script exists to prevent.
const referenced = [...rebased.matchAll(/\b(?:src|href)=["']([^"':]+)["']/g)].map((m) => m[1])
const missing = referenced.filter((r) => !r.startsWith('data:') && !existsSync(join(APP_PUBLIC, r)))
if (missing.length) {
console.error('APP-START-PAGE: FAIL — the rewritten page references files that are not there:')
for (const m of missing) console.error(` ${m}`)
process.exit(1)
}
// Keep the studio page reachable rather than deleting it: a developer with the apk still wants it.
const STUDIO_KEEP = join(APP_PUBLIC, 'studio.html')
if (existsSync(APP_ROOT) && !existsSync(STUDIO_KEEP)) copyFileSync(APP_ROOT, STUDIO_KEEP)
writeFileSync(APP_ROOT, rebased.replace(/<head>/i, `<head>\n ${MARKER}`))
console.log('APP-START-PAGE: the app now opens the UPG-100 list.')
console.log(` from ${LEAF_REL}`)
console.log(` wrote assets/public/index.html (${referenced.length} reference(s) rebased, all present)`)
console.log(' the studio page is kept at assets/public/studio.html')

View file

@ -24,7 +24,7 @@
"cap:verify": "test -f deployment/capacitor/android/app/src/main/assets/public/index.html && echo \"OK webDir copied\" || (echo \"FAIL: empty assets/public -> blank white screen. Check webDir.\" && exit 1)",
"check:tauri": "node internal/check-tauri-config.mjs",
"tauri:build": "./node_modules/.bin/tauri build",
"apk:debug": "bun run build && bun run cap:sync && bun run cap:verify && cd deployment/capacitor/android && ./gradlew assembleDebug",
"apk:debug": "bun run build && bun run cap:sync && bun run app:start-page && bun run cap:verify && cd deployment/capacitor/android && ./gradlew assembleDebug",
"apk:install": "adb install -r deployment/capacitor/android/app/build/outputs/apk/debug/app-debug.apk",
"phone": "bun run apk:debug && bun run apk:install",
"shell": "node internal/install-shell.mjs --no-token",
@ -193,7 +193,9 @@
"check:tree-weight": "node internal/check-published-tree-weight.mjs",
"check:i18n-never-translated": "node internal/check-i18n-never-translated.mjs",
"check:embed-base": "node internal/check-embed-base-resolution.mjs",
"check:composed-filters": "node internal/check-gospel-composed-filters.mjs"
"check:composed-filters": "node internal/check-gospel-composed-filters.mjs",
"app:start-page": "node internal/set-app-start-page.mjs",
"check:app-start-page": "node internal/set-app-start-page.mjs --check"
},
"//scripts": {
"_": "What each script does. These notes used to sit inside the command strings as trailing \"# …\" comments, which made the shell discard any arguments passed after \"--\" (so a gate given --url silently measured its hardcoded default port — possibly another agent's server). Keep notes here, never in a command.",