Add India Posters review plugin (preview-only)

A review gallery for the whole-India poster challenge — fitting every
India people group on one printable sheet. It gathers each design
attempt with what was tried and the measured result, and lets a
reviewer label it, score it 1-10, and tag it a best practice or one to
avoid (saved locally, exportable as JSON).

Withheld from every public build: the attempts, their descriptions,
and the poster documents load only under the dev server, in a dev-only
gallery module that compiles away. A production build shows only the
"solution in progress" notice and emits zero whole-country-poster
bytes. Element <india-posters>; stage experimental, visibility team.
This commit is contained in:
Doxa Maps 2026-10-02 10:55:46 +00:00
parent c7b77b0a12
commit 40cfb1da68
23 changed files with 3389 additions and 6 deletions

View file

@ -13,7 +13,7 @@
> bundle `parameterized` (cap-2) no matter how light it looks, and a `meta.json` `type`
> declares tiers 3/4/5.
Generated from 28 bundle(s).
Generated from 29 bundle(s).
### One canonical exemplar per tier (generated — quote these)
@ -51,6 +51,7 @@ Generated from 28 bundle(s).
| `gospel-resources` | `data-tables` | `single` |
| `gospel-resources` | `resources-list` | `single` |
| `gospel-resources` | `resources-map` | `single` |
| `india-posters` | `india-posters` | `single` |
| `my-upg-100-list` | `poster-wall` | `single` |
| `my-upg-100-list` | `upg-100-list` | `single` |
| `poster-design` | `poster-sizes` | `single` |

View file

@ -0,0 +1,24 @@
# india-posters — whole-India poster design attempts (review + grading)
A review surface, **not a map**. It gathers every attempt at putting all ~855 India people groups
on one poster — the leadered sheets, the by-state sheets, and the classic4 numbered family
(numbered pins, range badges, and the forked-leader experiments) — each with **what we tried** and
the **measured result**. Open any attempt full, then record a **label**, a **1–10 score**, a
**Best practice / To avoid** tag, and notes. The record is saved in the browser (localStorage) and
can be **exported/imported as JSON** so the catalogue of best practices + pitfalls travels with the repo.
## B25 — withheld from every public build
The whole-India poster is not shipped until it prints legibly. So the attempts render **only** under
the Vite dev server (the pilot), gated by `import.meta.env.DEV && globalThis.DXM_PREVIEW_UNRELEASED`.
A production build (the research landing and DX Life's `/dx-maps`) compiles that branch — and every
poster import inside it — away, leaving **only the withheld sentence** and **zero India-poster bytes**
in `public/`. Poster documents are loaded with DEV-guarded **static dynamic `?raw` imports** (never
`import.meta.glob` or `?url`, which emit assets) and shown in a `srcdoc` iframe. Enforced by
`check:preview-flag-dev-only` and the DX Life drop-in guard.
## Files
- `index.js` — registers `<india-posters>`; sets the dev-only preview flag (guarded).
- `india-posters.vue` — the gallery + grading UI.
- `data/attempts.json` — the attempts, each with `tried` + measured `result`.
- `data/posters/*.html` — the poster documents (source only; dev-loaded, never shipped).
- `data/grades.seed.json` — optional committed grades to seed the UI (localStorage overrides).

View file

@ -0,0 +1,86 @@
{
"_comment": "The whole-India poster design attempts, oldest → newest, for review + grading. 'file' matches a .html in ./posters/ (imported ?url, lazy). 'result' is the MEASURED outcome. Grades are entered live in the UI and persisted separately (localStorage + export); nothing here is a grade.",
"attempts": [
{
"id": "leadered-60x96",
"title": "Leadered · 60×96",
"family": "Leadered (a line to every name)",
"size": "60×96 in (5×8 ft)",
"file": "leadered-60x96.html",
"tried": "Every group is a pin joined by a thin leader line to its name in the four margins, names on 45° rows.",
"result": "All 855 at 22.4pt WITH photos, 0 crossings — but a grey web of leaders crosses central India and two dark cones funnel over Kashmir and under Sri Lanka, muddying the map."
},
{
"id": "leadered-66x66",
"title": "Leadered · 66×66",
"family": "Leadered (a line to every name)",
"size": "66×66 in (5.5×5.5 ft)",
"file": "leadered-66x66.html",
"tried": "Same leadered design on the square sheet.",
"result": "855 at 19.7pt, 7.3mm photos, but 2 crossings and the same leader web + cones."
},
{
"id": "bystate-60x96",
"title": "By-state · 60×96",
"family": "By-state (grouped, leaderless)",
"size": "60×96 in (5×8 ft)",
"file": "bystate-60x96.html",
"tried": "Names grouped under state headings, state-coloured dots, NO per-name leaders — locate a group by its state band + colour.",
"result": "Clean and calm, no web — but on the tall 60×96 the map shrinks to a weak island. By-state wants the square sheet."
},
{
"id": "bystate-66x66",
"title": "By-state · 66×66",
"family": "By-state (grouped, leaderless)",
"size": "66×66 in (5.5×5.5 ft)",
"file": "bystate-66x66.html",
"tried": "The leaderless by-state design on the square sheet, every group an equal photo/placeholder slot.",
"result": "855 at 17.5pt, 11.6mm photos, clean calm map, no web. (Set aside in favour of the classic4 numbered approach.)"
},
{
"id": "classic4-a-numbered-60x96",
"title": "Classic4 (a) numbered pins · 60×96",
"family": "Classic4 (numbered, imitates the library sheet)",
"size": "60×96 in (5×8 ft)",
"file": "classic4-a-numbered-60x96.html",
"tried": "Numbered pins (the number in the pin AND before the name), one column left + one right + a 45° row top and bottom, map framed to India's own bounds. No leaders.",
"result": "0 leader crossings; 164 pin overlaps where the coasts are dense. Every name legible and listed."
},
{
"id": "classic4-b-rangebadge-60x96",
"title": "Classic4 (b) range badges · 60×96 ★ recommended",
"family": "Classic4 (numbered, imitates the library sheet)",
"size": "60×96 in (5×8 ft)",
"file": "classic4-b-rangebadge-60x96.html",
"tried": "Same as (a), but co-located groups share one '12–18' range badge so dense coasts stop piling up.",
"result": "0 crossings AND 0 pin overlaps, 168 range badges, all 855 at 11pt, India edge-to-edge (13.9% of the page — the honest ceiling with 855 four-side names). The recommended design."
},
{
"id": "classic4-c1-fork-row-60x96",
"title": "Classic4 (c1) forked leaders — row · 60×96",
"family": "Classic4 forked leaders (bringing the lines back)",
"size": "60×96 in (5×8 ft)",
"file": "classic4-c1-fork-row-60x96.html",
"tried": "Bring the lines back: paired, forked leaders that split at the row end to reach two names at once.",
"result": "2062 leader crossings — a web across the whole map. Confirms leaders cannot stay untangled at 855-density."
},
{
"id": "classic4-c2-fork-trunk-60x96",
"title": "Classic4 (c2) forked leaders — trunk · 60×96",
"family": "Classic4 forked leaders (bringing the lines back)",
"size": "60×96 in (5×8 ft)",
"file": "classic4-c2-fork-trunk-60x96.html",
"tried": "Forked leaders with a shared trunk before the fork.",
"result": "6235 crossings — worse still; near-vertical trunks fill the north and a solid block of leaders buries India's outline."
},
{
"id": "classic4-b-rangebadge-66x66",
"title": "Classic4 (b) range badges · 66×66 (comparison)",
"family": "Classic4 (numbered, imitates the library sheet)",
"size": "66×66 in (5.5×5.5 ft)",
"file": "classic4-b-rangebadge-66x66.html",
"tried": "The recommended (b) design on the square sheet, to compare India's prominence.",
"result": "India only 10.4% of the page vs 13.9% at 60×96 — the tall sheet actually suits India better (India is taller than wide)."
}
]
}

View file

@ -0,0 +1 @@
{}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,20 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>doxa-maps · India Posters staging</title>
<link rel="icon" href="data:,">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body { background: #f7f4ec; min-height: 100%; font-family: system-ui, sans-serif; }
india-posters { display: block; width: 100%; min-height: 100vh; }
</style>
</head>
<body>
<!-- In dev (the pilot) the preview flag is on and the attempts render; a production build shows
only the withheld sentence. The element registers itself from ./index.js. -->
<india-posters></india-posters>
<script type="module" src="./index.js"></script>
</body>
</html>

View file

@ -0,0 +1,38 @@
/**
* india-posters / index.js — Bundle entry.
*
* ONE folder → ONE bundle → ONE custom element: <india-posters>.
*
* A REVIEW gallery, not a map: it shows every whole-India poster design attempt we tried
* (leadered, by-state, and the classic4 numbered family), lets the reviewer open each full,
* and record a label + a 1–10 score + a "Best practice / To avoid" tag + notes per attempt.
* The record persists in the browser (localStorage) and can be exported/imported as JSON so
* the catalogue of best practices and pitfalls travels with the repo.
*
* ★ WHY A PLAIN ELEMENT (not defineMapElement): this surface draws no map and needs no
* pinia/i18n/token machinery — it only iframes self-contained poster documents and keeps a
* small grading record. So it registers as a lean Vue custom element, exactly like
* plugin-studio's index.js, and can be nested/placed by any of the five deploy methods.
*
* Usage from a host page:
* <script src="/dx-maps/india-posters/india-posters.js"></script>
* <india-posters></india-posters>
*/
import { defineCustomElement } from 'vue'
import IndiaPosters from './india-posters.vue'
// ★ PREVIEW-UNRELEASED FLAG (B25). The whole-India poster is withheld from every public build until it
// prints legibly. This surface shows the attempts ONLY under the Vite dev server (the pilot), where the
// flag is on; a production build (research landing + DX Life's /dx-maps) compiles this line away, so the
// built tree never names the flag and shows only the withheld sentence. Same guarded-setter pattern as
// library/elements/defineMapElement.js, enforced by check:preview-flag-dev-only.
if (import.meta.env.DEV && typeof globalThis !== 'undefined') globalThis.DXM_PREVIEW_UNRELEASED = true
const IndiaPostersElement = defineCustomElement(IndiaPosters)
if (typeof customElements !== 'undefined' && !customElements.get('india-posters')) {
customElements.define('india-posters', IndiaPostersElement)
}
export default IndiaPostersElement

View file

@ -0,0 +1,6 @@
{
"label": "India Posters (design attempts — preview only)",
"stage": "experimental",
"visibility": "team",
"_comment": "B25: the whole-India poster is withheld from every public build until it prints legibly. This review surface renders the attempts ONLY under the dev server (import.meta.env.DEV && globalThis.DXM_PREVIEW_UNRELEASED); a production build shows only the withheld sentence and emits ZERO India-poster bytes to public/ (enforced by check:preview-flag-dev-only + the DX Life drop-in guard). stage=experimental and visibility=team declared explicitly though both are the defaults."
}

View file

@ -0,0 +1,112 @@
<!--
india-posters.vue — the <india-posters> element's root.
@description A review surface (not a map) for every whole-India poster design attempt. In a PUBLIC
build it renders only the withheld sentence. Under the Vite dev server (the pilot), with the
preview flag on, it mounts the full review gallery (india-gallery.vue) where each attempt can be
opened, labelled, scored 1–10, tagged Best practice / To avoid, and noted.
★ B25 — WITHHELD FROM EVERY PUBLIC BUILD. The whole-India poster is not shipped until it prints
legibly. The gallery — and therefore attempts.json, the grades seed, and every poster document —
lives in india-gallery.vue, imported ONLY behind the import.meta.env.DEV && globalThis.DXM_PREVIEW_UNRELEASED guard.
A production build (research landing + DX Life's /dx-maps) compiles that import away, so the module
never enters the bundle: the built tree carries ONLY this withheld sentence and ZERO India-poster
bytes. Enforced by check:preview-flag-dev-only + the DX Life drop-in guard.
Styles are declared here (non-scoped) so the custom element injects them once into its shadow root;
the shadow root encapsulates them, and they also style the async-loaded gallery child.
-->
<template>
<div class="ip-root">
<!-- PUBLIC BUILD: the only thing that ships + the only thing shown off the dev server. -->
<div v-if="!preview" class="ip-withheld">
<h1>India Posters</h1>
<p>Too many people groups to fit on one poster legibly — a solution is in progress.</p>
</div>
<!-- DEV PREVIEW (pilot only): the full review gallery, loaded dev-only. -->
<component v-else-if="Gallery" :is="Gallery" />
</div>
</template>
<script setup>
/**
* india-posters.vue — the <india-posters> element.
*
* @element india-posters
* @description A preview-only review board for the whole-India poster problem — fitting every one of
* India's people groups onto a single printable sheet. It gathers the design attempts, each with
* what was tried and how it measured, and lets a reviewer label, score 1 to 10, and tag each as a
* best practice or one to avoid. Public builds show only a short notice; the poster is withheld
* until it prints legibly.
*/
import { shallowRef, defineAsyncComponent } from 'vue'
const preview = import.meta.env.DEV && globalThis.DXM_PREVIEW_UNRELEASED === true
// Dev-only: the gallery module (with attempts.json + the posters) is pulled in ONLY here, behind the
// compile-time-false guard, so it never enters a production bundle.
const Gallery = shallowRef(null)
if (import.meta.env.DEV) Gallery.value = defineAsyncComponent(() => import('./src/india-gallery.vue'))
</script>
<style>
.ip-root { font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif; color: #1c1c17; background: #f7f4ec; padding: 20px; box-sizing: border-box; }
.ip-withheld { max-width: 48ch; margin: 40px auto; text-align: center; }
.ip-withheld h1 { font-size: 22px; margin: 0 0 8px; }
.ip-withheld p { color: #55503f; }
.ip-top { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; flex-wrap: wrap; border-bottom: 2px solid #d9d2c2; padding-bottom: 14px; margin-bottom: 18px; }
.ip-gallery h1 { font-size: 22px; margin: 0 0 4px; }
.ip-sub { margin: 0; max-width: 64ch; font-size: 13px; color: #55503f; }
.ip-sub em { color: #b23b2e; font-style: italic; }
.ip-actions { display: flex; align-items: center; gap: 8px; }
.ip-btn { background: #1c1c17; color: #f7f4ec; border: 0; border-radius: 6px; padding: 8px 12px; font-size: 13px; cursor: pointer; }
.ip-btn:hover { background: #3a3a2e; }
.ip-file { display: inline-flex; align-items: center; }
.ip-saved { color: #2e7d32; font-size: 13px; font-weight: 600; }
.ip-summary { display: flex; gap: 28px; flex-wrap: wrap; background: #fffdf7; border: 1px solid #e4ddcc; border-radius: 10px; padding: 12px 18px; margin-bottom: 20px; }
.ip-col { min-width: 220px; }
.ip-col h2 { font-size: 14px; margin: 4px 0 6px; }
.ip-h-best { color: #2e7d32; }
.ip-h-avoid { color: #b23b2e; }
.ip-col ol { margin: 0; padding-left: 20px; font-size: 13px; }
.ip-col li { margin: 2px 0; }
.ip-score { display: inline-block; min-width: 22px; text-align: center; font-weight: 700; border-radius: 4px; padding: 0 4px; margin-right: 4px; font-size: 12px; }
.ip-score-best { background: #dcefdc; color: #2e7d32; }
.ip-score-avoid { background: #f6dcd8; color: #b23b2e; }
.ip-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 18px; }
.ip-card { background: #fffdf7; border: 1px solid #e4ddcc; border-radius: 12px; padding: 14px; display: flex; flex-direction: column; }
.ip-card.is-best { border-color: #2e7d32; box-shadow: 0 0 0 2px #2e7d3233; }
.ip-card.is-avoid { border-color: #b23b2e; box-shadow: 0 0 0 2px #b23b2e33; }
.ip-card-head { display: flex; justify-content: space-between; font-size: 11px; color: #7a7360; text-transform: uppercase; letter-spacing: .04em; }
.ip-title { font-size: 15px; margin: 6px 0 10px; }
.ip-preview { position: relative; height: 260px; background: #efe9db; border: 1px solid #e4ddcc; border-radius: 8px; overflow: hidden; display: flex; align-items: center; justify-content: center; cursor: pointer; }
.ip-thumb-frame { width: 250%; height: 250%; border: 0; transform: scale(0.4); transform-origin: top left; pointer-events: none; }
.ip-load { background: #1c1c17; color: #f7f4ec; border: 0; border-radius: 6px; padding: 8px 14px; cursor: pointer; font-size: 13px; }
.ip-open-hint { position: absolute; bottom: 6px; right: 8px; font-size: 11px; background: #1c1c17cc; color: #f7f4ec; padding: 2px 6px; border-radius: 4px; }
.ip-facts { margin: 12px 0; font-size: 12.5px; }
.ip-facts dt { font-weight: 700; color: #55503f; margin-top: 6px; }
.ip-facts dd { margin: 2px 0 0; color: #333; }
.ip-grade { margin-top: auto; border-top: 1px dashed #d9d2c2; padding-top: 10px; display: flex; flex-direction: column; gap: 8px; }
.ip-label-row, .ip-notes-row { display: flex; flex-direction: column; gap: 3px; font-size: 12px; font-weight: 600; color: #55503f; }
.ip-label-row input, .ip-notes-row textarea { font: inherit; font-weight: 400; color: #1c1c17; border: 1px solid #d9d2c2; border-radius: 6px; padding: 6px 8px; }
.ip-score-row { display: flex; align-items: center; gap: 8px; font-size: 12px; font-weight: 600; color: #55503f; }
.ip-score-row input { flex: 1; }
.ip-score-row output { min-width: 42px; text-align: right; font-weight: 700; color: #1c1c17; }
.ip-cat-row { display: flex; gap: 6px; }
.ip-cat { flex: 1; border: 1px solid #d9d2c2; background: #fff; border-radius: 6px; padding: 6px 4px; font-size: 12px; cursor: pointer; }
.ip-cat-best.on { background: #2e7d32; color: #fff; border-color: #2e7d32; }
.ip-cat-avoid.on { background: #b23b2e; color: #fff; border-color: #b23b2e; }
.ip-cat-clear.on { background: #d9d2c2; }
.ip-dialog { border: 0; padding: 0; border-radius: 12px; width: min(92vw, 1100px); height: 90vh; max-width: 92vw; max-height: 90vh; background: #f7f4ec; overflow: hidden; display: flex; flex-direction: column; }
.ip-dialog:not([open]) { display: none; }
.ip-dialog::backdrop { background: #000000cc; }
.ip-modal-bar { display: flex; justify-content: space-between; align-items: center; padding: 10px 14px; border-bottom: 1px solid #d9d2c2; flex: 0 0 auto; }
.ip-full-frame { flex: 1 1 auto; width: 100%; border: 0; background: #fff; }
</style>

View file

@ -0,0 +1,214 @@
<!--
india-gallery.vue — the review gallery itself (header, summary, cards, grading, modal).
★ DEV-ONLY MODULE. This component is imported ONLY from india-posters.vue behind
import.meta.env.DEV (a compile-time false in every build), so in a production build Rollup never
pulls this module into the graph — and with it, attempts.json, the grades seed, and every poster
document stay out of public/ entirely. That is why this file may use ordinary static imports and
import.meta.glob freely: none of it is reachable from a shipped bundle. See india-posters.vue +
README.md for the B25 rationale. Styling lives in india-posters.vue (the custom element's root),
injected once into the shadow root, so this child needs no <style> of its own.
-->
<template>
<div class="ip-gallery">
<header class="ip-top">
<div>
<h1>India Posters — design attempts</h1>
<p class="ip-sub">
Every attempt at putting all ~855 India people groups on one poster. Open each, then
<b>label</b> it, score it <b>1–10</b>, and tag it <b>Best practice</b> or <b>To avoid</b>
so we keep a record of what to repeat and what to steer clear of.
<em>Preview only — withheld from every public build.</em>
</p>
</div>
<div class="ip-actions">
<button class="ip-btn" @click="exportJson">Export record (JSON)</button>
<label class="ip-btn ip-file">
Import record
<input type="file" accept="application/json" @change="importJson" hidden />
</label>
<span class="ip-saved" v-if="savedFlash">saved ✓</span>
</div>
</header>
<section class="ip-summary" v-if="graded.best.length || graded.avoid.length">
<div class="ip-col">
<h2 class="ip-h-best">Best practice ({{ graded.best.length }})</h2>
<ol>
<li v-for="a in graded.best" :key="a.id">
<span class="ip-score ip-score-best">{{ rec[a.id].score }}</span>
{{ rec[a.id].label || a.title }}
</li>
</ol>
</div>
<div class="ip-col">
<h2 class="ip-h-avoid">To avoid ({{ graded.avoid.length }})</h2>
<ol>
<li v-for="a in graded.avoid" :key="a.id">
<span class="ip-score ip-score-avoid">{{ rec[a.id].score }}</span>
{{ rec[a.id].label || a.title }}
</li>
</ol>
</div>
</section>
<div class="ip-grid">
<article
v-for="a in attempts"
:key="a.id"
class="ip-card"
:class="{ 'is-best': rec[a.id].category === 'best', 'is-avoid': rec[a.id].category === 'avoid' }"
>
<div class="ip-card-head">
<span class="ip-family">{{ a.family }}</span>
<span class="ip-size">{{ a.size }}</span>
</div>
<h3 class="ip-title">{{ a.title }}</h3>
<div class="ip-preview" @click="open(a)">
<iframe
v-if="srcdoc[a.id]"
:srcdoc="srcdoc[a.id]"
class="ip-thumb-frame"
title="poster preview"
></iframe>
<button v-else class="ip-load" @click.stop="reveal(a)">Load preview</button>
<span class="ip-open-hint" v-if="srcdoc[a.id]">click to open full →</span>
</div>
<dl class="ip-facts">
<dt>What we tried</dt>
<dd>{{ a.tried }}</dd>
<dt>Measured result</dt>
<dd>{{ a.result }}</dd>
</dl>
<div class="ip-grade">
<label class="ip-label-row">
<span>Label</span>
<input type="text" v-model="rec[a.id].label" :placeholder="a.title" @input="save" />
</label>
<div class="ip-score-row">
<span>Score</span>
<input type="range" min="0" max="10" step="1" v-model.number="rec[a.id].score" @input="save" />
<output>{{ rec[a.id].score === 0 ? '–' : rec[a.id].score }}/10</output>
</div>
<div class="ip-cat-row">
<button class="ip-cat ip-cat-best" :class="{ on: rec[a.id].category === 'best' }" @click="setCat(a.id, 'best')">Best practice</button>
<button class="ip-cat ip-cat-avoid" :class="{ on: rec[a.id].category === 'avoid' }" @click="setCat(a.id, 'avoid')">To avoid</button>
<button class="ip-cat ip-cat-clear" :class="{ on: !rec[a.id].category }" @click="setCat(a.id, '')">Ungraded</button>
</div>
<label class="ip-notes-row">
<span>Notes</span>
<textarea v-model="rec[a.id].notes" rows="2" @input="save" placeholder="What to repeat / what to steer clear of…"></textarea>
</label>
</div>
</article>
</div>
<!-- Native <dialog> + showModal(): its top-layer promotion escapes the shell pane's
contain:layout (a position:fixed overlay would be trapped in the pane), and Escape-to-close
comes for free. @click.self closes on a backdrop click. -->
<dialog ref="dlg" class="ip-dialog" @close="openId = ''" @click.self="closeDialog">
<div class="ip-modal-bar">
<strong>{{ current.title }}</strong>
<button class="ip-btn" @click="closeDialog">Close ✕</button>
</div>
<iframe v-if="openId" :srcdoc="srcdoc[openId]" class="ip-full-frame" title="poster full"></iframe>
</dialog>
</div>
</template>
<script setup>
import { reactive, ref, computed, onMounted, nextTick } from 'vue'
import data from '../data/attempts.json'
import seed from '../data/grades.seed.json'
const STORE_KEY = 'india-posters.grades.v1'
// Lazy per-poster loaders: each resolves the poster document as a string, on demand. Fine here —
// this whole module is dev-only, so none of these assets reach a production build.
const posters = import.meta.glob('../data/posters/*.html', { query: '?raw', import: 'default' })
const attempts = data.attempts
const openId = ref('')
const dlg = ref(null)
const current = computed(() => attempts.find((a) => a.id === openId.value) || {})
const srcdoc = reactive({}) // id → loaded poster HTML string
const savedFlash = ref(false)
const blank = (a) => ({ label: a.title, score: 0, category: '', notes: '' })
const rec = reactive({})
for (const a of attempts) rec[a.id] = blank(a)
function load() {
const merged = { ...(seed || {}) }
try {
const raw = window.localStorage.getItem(STORE_KEY)
if (raw) Object.assign(merged, JSON.parse(raw))
} catch {}
for (const a of attempts) if (merged[a.id]) Object.assign(rec[a.id], merged[a.id])
}
let flashT
function save() {
try {
window.localStorage.setItem(STORE_KEY, JSON.stringify(rec))
savedFlash.value = true
clearTimeout(flashT)
flashT = setTimeout(() => (savedFlash.value = false), 1200)
} catch {}
}
function setCat(id, cat) {
rec[id].category = rec[id].category === cat ? '' : cat
save()
}
async function reveal(a) {
if (srcdoc[a.id]) return
const loader = posters[`../data/posters/${a.file}`]
if (!loader) return
try { srcdoc[a.id] = await loader() } catch {}
}
async function open(a) {
await reveal(a)
openId.value = a.id
await nextTick()
if (dlg.value && !dlg.value.open) dlg.value.showModal()
}
function closeDialog() {
if (dlg.value && dlg.value.open) dlg.value.close()
else openId.value = ''
}
const graded = computed(() => {
const best = attempts.filter((a) => rec[a.id].category === 'best').sort((x, y) => rec[y.id].score - rec[x.id].score)
const avoid = attempts.filter((a) => rec[a.id].category === 'avoid').sort((x, y) => rec[y.id].score - rec[x.id].score)
return { best, avoid }
})
function exportJson() {
const blob = new Blob([JSON.stringify(rec, null, 2)], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'india-posters.grades.json'
a.click()
URL.revokeObjectURL(url)
}
function importJson(e) {
const file = e.target.files && e.target.files[0]
if (!file) return
const reader = new FileReader()
reader.onload = () => {
try {
const obj = JSON.parse(String(reader.result))
for (const a of attempts) if (obj[a.id]) Object.assign(rec[a.id], obj[a.id])
save()
} catch { /* ignore malformed */ }
}
reader.readAsText(file)
}
onMounted(load)
</script>

View file

@ -2,7 +2,7 @@
This folder is GENERATED by `bun run build`; do not edit it by hand. It holds exactly one thing:
- **`dx-maps/`** — the output folder: 28 plugin bundles and 43 pages, self-contained.
- **`dx-maps/`** — the output folder: 29 plugin bundles and 44 pages, self-contained.
Copy this one folder to any web host (or open it from disk) and it works.
- `dx-maps/index.html` — the front page: the Plugin Studio, every plugin in one place.
- `dx-maps/<plugin>/<plugin>.js` — a plugin's bundle (a custom element; load it with a `<script>` tag).

View file

@ -2,8 +2,8 @@
"schema": "doxa-maps-manifest@1",
"generatedFrom": "plugin-designs/*/*.vue",
"embedBase": ".",
"bundleCount": 28,
"profileCount": 43,
"bundleCount": 29,
"profileCount": 44,
"runtime": {
"apiBaseUrl": "https://pray.doxa.life"
},
@ -925,6 +925,34 @@
}
]
},
{
"bundle": "india-posters",
"scriptUrl": "./india-posters/india-posters.js",
"output": "dx-maps/india-posters/india-posters.js",
"integrity": "sha384-AZTLfyEPBd7yrap4tKeJX5SeauB35KtoqOFf82B1Yan6FbgN+p0B+Y3bl1cqhvYr",
"elements": [
"india-posters"
],
"stage": "experimental",
"profiles": [
{
"profile": "india-posters",
"stage": "experimental",
"label": "India Posters (design attempts — preview only)",
"file": "india-posters.vue",
"description": "A preview-only review board for the whole-India poster problem — fitting every one of",
"element": "india-posters",
"type": "single",
"isMap": false,
"iframeUrl": "./india-posters/india-posters/index.html",
"scriptUrl": "./india-posters/india-posters.js",
"embed": {
"iframe": "<iframe src=\"./india-posters/india-posters/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./india-posters/india-posters.js\" integrity=\"sha384-AZTLfyEPBd7yrap4tKeJX5SeauB35KtoqOFf82B1Yan6FbgN+p0B+Y3bl1cqhvYr\" crossorigin=\"anonymous\"></script>\n<!-- provider: \"maplibre\" is the default; change it to \"mapbox\" and fill tk to use Mapbox. -->\n<india-posters profile-config='{\"profile\":\"india-posters\",\"provider\":\"maplibre\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></india-posters>"
}
}
]
},
{
"bundle": "my-upg-100-list",
"scriptUrl": "./my-upg-100-list/my-upg-100-list.js",

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,250 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- THIS PAGE IS OURS. The only thing that knows a document belongs to the plugin is the page
the generator writes, so it says so here. library/i18n reads it before reflecting an RTL
locale on <html dir>; nothing else sets it, so a PARTNER page defaults to "not ours" and
is never touched. Absence must mean restraint — an embedded plugin flipping a partner's
whole <html> because a reader followed a ?lang=ar link is the bug this prevents. -->
<script>window.MAP_OWNS_PAGE = true;</script>
<title>india-posters · india-posters</title>
<link rel="icon" href="data:,">
<!-- NON-MAP leaf (app-shell / composite / grid / card / chart-table): NO map peer.
This element draws no map tiles, so it needs no Mapbox/MapLibre engine and MUST NOT
gate on a token. Any nested MAP (in a composite/shell) renders in its own iframe leaf,
which loads its own map peer + token. A peer here would be dead weight + imply a gate. -->
<style>
/* BARE full-page map — no chrome. The custom element IS the page, so the
browser URL shares the map itself.
HEIGHT IN svh, NOT inset:0. A fixed box with inset:0 follows the phone's DYNAMIC viewport, so the map
(and a legend sheet sized from it) changes height every time the address bar hides or shows. 100svh is
the viewport WITH the bar shown and never changes; when the bar hides, the page background shows below
instead of the map resizing. On a desktop svh = the viewport, so nothing moves there. The 100% before it
is the fallback for a browser without svh, which would otherwise size the box to its content.
--profile-min-h:0 opts out of ProfileLoader's 100dvh floor, which would grow the element again. */
html, body { height: 100%; margin: 0; background: #0a0e15; }
#slot, india-posters { position: fixed; inset: 0 auto auto 0; width: 100%; height: 100%; height: 100svh; --profile-min-h: 0; display: block; }
#status { position: fixed; inset: 0; z-index: 2; display: grid; place-items: center;
padding: 24px; text-align: center; color: #9aa7b8;
font: 13px/1.6 ui-monospace, SFMono-Regular, Menlo, monospace; }
#status.err { color: #f87171; }
/* ── PRINT, BOTH LEAF SHAPES ────────────────────────────────────────────────────────────
Two things every generated leaf got wrong on paper, because no generated page has ever
carried an @media print rule (measured: 0 across the tree).
The background is #0a0e15 — a dark page, printed. Browsers drop background colours by
default, but a reader who ticks "Background graphics" to get the MAP also gets the page
flooded black, and that is the reader who most wants the printout.
#status is the LOADING OVERLAY: position:fixed, inset:0, over everything. On paper it is
a full sheet reading "loading…" above the content that did in fact load. It is hidden
only while it is NOT an error — an error still prints, because a blank sheet with the
explanation suppressed is worse than a sheet that says what went wrong. */
@media print {
html, body { height: auto; background: #fff; }
#status:not(.err) { display: none; }
/* ★ RELEASE THE FIXED HOST, MEASURED (a print check on a035408e2). The leaf pins both the
slot and the element with position:fixed; inset:0. A fixed element is painted once, on the
first page, so the printed document was exactly ONE viewport: 900px tall, while the content
continued to y=1753. Every plugin whose leaf is taller than a screen has been printing its
top screenful and silently dropping the rest — the dashboard printed its stats and its map,
then blank to the bottom of the sheet.
min-height, not a released height alone: a full-bleed map leaf takes its SIZE from this rule,
so height:auto with nothing else can leave the element a zero-height box and print an empty
page — a worse failure than the one being fixed, and the discriminator is not isMap
(region-dashboards is isMap:true and is a scrolling dashboard). The floor keeps a definite
box for the map case while letting a taller document grow onto page two. */
#slot, india-posters { position: static; inset: auto; width: 100%; height: auto; min-height: 14cm; }
}
</style>
</head>
<body>
<div id="slot"></div>
<div id="status">loading…</div>
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <india-posters> custom element. -->
<script src="../india-posters.js" integrity="sha384-AZTLfyEPBd7yrap4tKeJX5SeauB35KtoqOFf82B1Yan6FbgN+p0B+Y3bl1cqhvYr" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:
// 1. prop — a token hardcoded into this page's profile-config (config.tk)
// 2. ?tk= URL param — token carried in the shared link
// 3. baked token — opt-in literal token inlined at build time (build-config.token)
// A token from rungs 1-3 is used DIRECTLY (no network). Otherwise a token
// *endpoint* is resolved and fetched:
// 4. window.MAP_TOKEN_URL override 5. configured TOKEN_SRC URL 6. same-origin /api/maps/token
// TOKEN_SRC is treated as DATA, never eval'd: the literal "window.MAP_TOKEN_URL"
// means "read that global"; anything else is used as a URL — so both a global
// name and a plain URL are safe (no SyntaxError footgun).
(function () {
var slot = document.getElementById('slot');
var status = document.getElementById('status');
var BASE_CONFIG = {"profile":"india-posters","dataSource":"pray-tools","instanceId":"india-posters-india-posters","assetsBase":"../../assets"};
// MapLibre map pages AND shell pages (app-shell / nested-mfe-spa) need NO token:
// the ladder below is skipped entirely and the element mounts immediately.
// (A leaf whose engine is MapLibre at RUNTIME joins them — see PROVIDER below.)
var TOKEN_FREE = true;
// A token-free COMPOSITE still nests plugins, and a nested Mapbox map reads window.MAP_TOKEN
// rather than this page's profile-config. With no baked token (the public copy), it must get
// one from ?tk= or a pasted token, or be asked for one — see the rungs below.
var NESTS_MAPS = false;
var TOKEN_SRC = "window.MAP_TOKEN_URL";
var BAKED_TOKEN = "";
// Expose the baked token as window.MAP_TOKEN so a BAKED composite (a compiled element that mounts
// a map component directly, with no ProfileLoader/profile-config wrapper) still finds a token —
// useMapInstance falls back to window.MAP_TOKEN. No effect when BAKED_TOKEN is empty.
// A token the HOST set before this page ran outranks the baked one (measured: it used to lose).
var HOST_TOKEN = (typeof window.MAP_TOKEN === 'string' && window.MAP_TOKEN) || '';
if (BAKED_TOKEN && typeof window !== 'undefined' && !window.MAP_TOKEN) { window.MAP_TOKEN = BAKED_TOKEN; }
var DEFAULT_TOKEN_URL = '/api/maps/token';
function fail(msg) { if (status) { status.className = 'err'; status.textContent = '⚠ ' + msg; } }
// No token anywhere on the ladder → ask ONCE, store locally, remount. No dead ends.
function askToken() {
if (!status) return;
status.className = 'err';
status.innerHTML = 'map needs a Mapbox token — ' +
'<input id="tkIn" type="password" placeholder="paste pk.… (stays in this browser)" ' +
'style="font:12px ui-monospace,monospace;padding:6px 8px;border-radius:6px;border:1px solid #223047;background:#0e1623;color:#eef2f7;width:min(320px,70vw)"> ' +
'<button id="tkGo" style="font:700 11px ui-monospace,monospace;padding:7px 12px;border-radius:999px;border:0;cursor:pointer;background:linear-gradient(90deg,#46d4ff,#b9a6f5);color:#0a0e15">use it</button>';
var go = document.getElementById('tkGo');
if (go) go.addEventListener('click', function () {
var v = (document.getElementById('tkIn').value || '').trim();
if (!v) return;
try { localStorage.setItem('MAPS_STAGING_TK', v); } catch (e) {}
mount(v);
});
}
/* ★ RELEASE THE LOAD BACKDROP WHEN THE ELEMENT MOUNTS (BUG-GOSPEL-MAP-BLACK-RECTANGLE).
html/body carry background:#0a0e15 so the light #status text (#9aa7b8) is readable while the
bundle loads. That backdrop is LOAD-STATE CHROME, but it outlived its moment: any gap the
mounted element does not cover shows it through, because every layer above body is
transparent. On gospel-resources/resources-map that produced a 352x126 DARK RECTANGLE between
the legend card and the detail panel — MEASURED rgb(8,12,18), i.e. the page background, NOT an
unpainted canvas as first supposed. Clearing it here (the one place we already know the
element is up) is a one-shot style write — no resize, no repaint loop. Keep it a one-shot
write: do NOT turn this into a per-frame or per-event handler (a resize loop strobes the screen). */
function done() {
if (status && status.parentNode) { status.parentNode.removeChild(status); }
document.documentElement.style.background = '#f3f3f1';
document.body.style.background = '#f3f3f1';
}
function mount(token) {
if (!token && !TOKEN_FREE) { return askToken(); }
// Nested maps (a composite's children) read window.MAP_TOKEN, not this element's config.
if (token && typeof window !== 'undefined' && !window.MAP_TOKEN) { window.MAP_TOKEN = token; }
try {
var cfg = token ? Object.assign({}, BASE_CONFIG, { tk: token }) : BASE_CONFIG;
var el = document.createElement("india-posters");
el.setAttribute('profile-config', JSON.stringify(cfg));
slot.innerHTML = '';
slot.appendChild(el);
done();
} catch (e) { fail(e && e.message ? e.message : String(e)); }
}
function qp(name) {
try { return new URLSearchParams(location.search).get(name) || ''; }
catch (e) { return ''; }
}
function extractToken(data) {
if (typeof data === 'string') return data.trim();
if (data && typeof data === 'object') return data.token || data.tk || data.accessToken || '';
return '';
}
// Rungs 1-3b: an actual token → mount directly, no fetch.
// 2b = localStorage: the paste-once store shared with the staging pages
// (MAPS_STAGING_TK) — paste a token ONCE anywhere in the tree, every page uses it.
var stored = '';
try { stored = localStorage.getItem('MAPS_STAGING_TK') || ''; } catch (e) {}
var urlTk = qp('tk');
if (urlTk) { try { localStorage.setItem('MAPS_STAGING_TK', urlTk); } catch (e) {} }
var directToken = (BASE_CONFIG && BASE_CONFIG.tk) || urlTk || stored || HOST_TOKEN || BAKED_TOKEN;
// PROVIDER — the same ladder as resolveMapProvider (library/composables/useMapProvider.js):
// profile-config.provider → ?provider= → window.MAP_PROVIDER → 'auto'. An explicit engine wins,
// and 'auto' draws with MapLibre whether or not a token is available (the element decides that).
// This page only decides whether to FIND a token first: when one is on offer (a rung above, a
// host-set window.MAP_TOKEN, or a token endpoint) it resolves it, since place search uses it on
// either engine; with none, it mounts at once, keyless: no token prompt, no request to Mapbox.
var ASKED = String((BASE_CONFIG && BASE_CONFIG.provider) || qp('provider') || window.MAP_PROVIDER || '').toLowerCase().trim();
var EXPLICIT = (ASKED === 'mapbox' || ASKED === 'maplibre') ? ASKED : '';
var HAS_TOKEN = !!(directToken || (typeof window.MAP_TOKEN === 'string' && window.MAP_TOKEN));
var HAS_ENDPOINT = !!(window.MAP_TOKEN_URL || (TOKEN_SRC && TOKEN_SRC !== 'window.MAP_TOKEN_URL'));
if (EXPLICIT === 'maplibre' || (!EXPLICIT && !HAS_TOKEN && !HAS_ENDPOINT)) { TOKEN_FREE = true; }
if (TOKEN_FREE) {
if (directToken && typeof window !== 'undefined' && !window.MAP_TOKEN) { window.MAP_TOKEN = directToken; }
mount('');
// A token-free composite with no token anywhere: its content renders, but its nested maps
// cannot. Ask once; the token is stored for the whole tree and the page reloads with it.
// Only when Mapbox was ASKED for: under 'auto' the nested maps draw with MapLibre instead.
if (NESTS_MAPS && !directToken && EXPLICIT === 'mapbox') {
var bar = document.createElement('div');
bar.id = 'status';
bar.setAttribute('style', 'position:fixed;left:12px;right:12px;bottom:12px;z-index:2147483000;padding:10px 12px;border-radius:10px;background:#0e1623;color:#eef2f7;font:13px system-ui,sans-serif;box-shadow:0 6px 24px rgba(0,0,0,.25)');
document.body.appendChild(bar);
status = bar;
askToken();
var goBtn = document.getElementById('tkGo');
if (goBtn) goBtn.addEventListener('click', function () {
var v = (document.getElementById('tkIn').value || '').trim();
if (v) { location.reload(); }
});
}
return;
}
if (directToken) { return mount(directToken); }
// Rungs 4-6: resolve the token ENDPOINT candidates and try them IN ORDER — the first
// one that answers with a token wins. Order: window.MAP_TOKEN_URL override → the
// configured TOKEN_SRC (an ABSOLUTE url, so a tree hosted on a CDN away from the API
// still finds it) → same-origin /api/maps/token (dev, or the site hosting both).
// A single hard-coded pick used to kill the page when its one url 404'd or was
// blocked by CORS; a shared link must work wherever the tree is hosted.
var candidates = [];
function addCandidate(u) { if (u && candidates.indexOf(u) === -1) candidates.push(u); }
addCandidate(window.MAP_TOKEN_URL);
addCandidate(TOKEN_SRC && TOKEN_SRC !== 'window.MAP_TOKEN_URL' ? TOKEN_SRC : '');
addCandidate(DEFAULT_TOKEN_URL);
function fetchToken(i, errors) {
if (i >= candidates.length) {
// Every endpoint failed. Under 'auto' that means no token: draw with MapLibre, keyless.
if (!EXPLICIT) { TOKEN_FREE = true; return mount(''); }
// Mapbox was asked for: say which endpoints failed, and still offer the paste-once box.
askToken();
if (status) {
var d = document.createElement('div');
d.textContent = '⚠ Token fetch failed from ' + candidates.join(', ') + ' — set window.MAP_TOKEN_URL or pass ?tk=. (' + errors.join(' | ') + ')';
status.insertBefore(d, status.firstChild);
}
return;
}
var url = candidates[i];
return fetch(url)
.then(function (r) {
if (!r.ok) throw new Error(url + ' → HTTP ' + r.status);
var ct = r.headers.get('content-type') || '';
return ct.indexOf('json') !== -1 ? r.json() : r.text();
})
.then(function (data) {
var tk = extractToken(data);
if (!tk) throw new Error(url + ' → no token in response');
mount(tk);
})
.catch(function (e) {
errors.push(e && e.message ? e.message : String(e));
return fetchToken(i + 1, errors);
});
}
fetchToken(0, []);
})();
</script>
</body>
</html>

View file

@ -2,8 +2,8 @@
"schema": "doxa-maps-manifest@1",
"generatedFrom": "plugin-designs/*/*.vue",
"embedBase": ".",
"bundleCount": 28,
"profileCount": 43,
"bundleCount": 29,
"profileCount": 44,
"runtime": {
"apiBaseUrl": "https://pray.doxa.life"
},
@ -925,6 +925,34 @@
}
]
},
{
"bundle": "india-posters",
"scriptUrl": "./india-posters/india-posters.js",
"output": "dx-maps/india-posters/india-posters.js",
"integrity": "sha384-AZTLfyEPBd7yrap4tKeJX5SeauB35KtoqOFf82B1Yan6FbgN+p0B+Y3bl1cqhvYr",
"elements": [
"india-posters"
],
"stage": "experimental",
"profiles": [
{
"profile": "india-posters",
"stage": "experimental",
"label": "India Posters (design attempts — preview only)",
"file": "india-posters.vue",
"description": "A preview-only review board for the whole-India poster problem — fitting every one of",
"element": "india-posters",
"type": "single",
"isMap": false,
"iframeUrl": "./india-posters/india-posters/index.html",
"scriptUrl": "./india-posters/india-posters.js",
"embed": {
"iframe": "<iframe src=\"./india-posters/india-posters/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./india-posters/india-posters.js\" integrity=\"sha384-AZTLfyEPBd7yrap4tKeJX5SeauB35KtoqOFf82B1Yan6FbgN+p0B+Y3bl1cqhvYr\" crossorigin=\"anonymous\"></script>\n<!-- provider: \"maplibre\" is the default; change it to \"mapbox\" and fill tk to use Mapbox. -->\n<india-posters profile-config='{\"profile\":\"india-posters\",\"provider\":\"maplibre\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></india-posters>"
}
}
]
},
{
"bundle": "my-upg-100-list",
"scriptUrl": "./my-upg-100-list/my-upg-100-list.js",