diff --git a/documentation/reference-capability-exemplars.md b/documentation/reference-capability-exemplars.md index 886cc9f6f..d389b951d 100644 --- a/documentation/reference-capability-exemplars.md +++ b/documentation/reference-capability-exemplars.md @@ -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) @@ -56,6 +56,7 @@ Generated from 28 bundle(s). | `poster-design` | `poster-sizes` | `single` | | `poster-design` | `poster-styles` | `single` | | `prayer-walk-planner` | `prayer-walk-planner` | `single` | +| `regions-dashboard` | `country-poster-sections` | `single` | | `regions-dashboard` | `region-dashboards` | `single` | | `regions-dashboard` | `regions-list` | `single` | @@ -67,6 +68,7 @@ Generated from 28 bundle(s). | `doxa-simple-map` | `doxa-simple-map` | `parameterized` | | `doxa-vision-card` | `doxa-vision-card` | `parameterized` | | `people-groups-grid` | `people-groups-grid` | `parameterized` | +| `poster-sections` | `poster-sections` | `parameterized` | ## Capability 3 — multi-view-element (monolithic) diff --git a/library/components/PeopleGroupDetail.vue b/library/components/PeopleGroupDetail.vue index 2279e605a..c16d26b0a 100644 --- a/library/components/PeopleGroupDetail.vue +++ b/library/components/PeopleGroupDetail.vue @@ -119,7 +119,7 @@ import { getApiBaseUrl } from '../utils/apiBaseUrl.js'; const { t, locale } = useI18n(); useShadowStyles(` -.people-group-detail{display:flex;flex-direction:column;height:100%;overflow-y:auto;} +.people-group-detail{display:flex;flex-direction:column;height:100%;overflow-y:auto;overscroll-behavior:contain;} .people-group-detail.dark-mode{background:#1a1a2e;color:#eee;} .detail-header{display:flex;justify-content:space-between;align-items:flex-start;padding:16px;border-bottom:1px solid #e0e0e0;background:white;position:sticky;top:0;z-index:10;} .dark-mode .detail-header{background:#16213e;border-bottom-color:#2d2d4e;} @@ -133,7 +133,7 @@ useShadowStyles(` .dark-mode .close-button{color:#aaa;} .close-button:hover{color:#333;} .dark-mode .close-button:hover{color:#fff;} -.detail-content{padding:6px 12px 12px;flex:1;overflow-y:auto;} +.detail-content{padding:6px 12px 12px;flex:1;overflow-y:auto;overscroll-behavior:contain;} .dark-mode .detail-content{background:#1a1a2e;} .quick-stats{display:flex;flex-direction:column;gap:4px;margin-bottom:8px;} .stat-item{display:flex;justify-content:space-between;align-items:baseline;font-size:13px;} @@ -467,6 +467,8 @@ function handleImageError(event) { flex-direction: column; height: 100%; overflow-y: auto; + /* a scroll that reaches this panel's end never chains to the page (the mobile sheet's detail view) */ + overscroll-behavior: contain; } /* Header Section */ @@ -540,6 +542,7 @@ function handleImageError(event) { padding: 6px 12px 12px; flex: 1; overflow-y: auto; + overscroll-behavior: contain; } /* Quick Stats */ diff --git a/library/components/legends/LegendMobile.vue b/library/components/legends/LegendMobile.vue index 71ae01970..bc353ee44 100644 --- a/library/components/legends/LegendMobile.vue +++ b/library/components/legends/LegendMobile.vue @@ -23,7 +23,7 @@ useShadowStyles(` mobile chrome (pull tab, collapse/expand caret, touch drag). ── */ /* bottom = the sheet's height (it shows only while the sheet is open, whose height fits its content) + a 16px gap. */ -.floating-mini-image{position:absolute;inset-inline-start:16px;bottom:calc(var(--legend-img-seat,var(--legend-sheet-h,30%)) + 16px);z-index:999;pointer-events:auto;cursor:pointer;} +.floating-mini-image{position:absolute;inset-inline-start:16px;bottom:calc(var(--legend-img-seat,var(--legend-sheet-h,30%)) + 16px);z-index:999;pointer-events:auto;cursor:pointer;touch-action:none;} /* The image SITS on --legend-img-seat, a copy of the sheet's height that moves only while the image shows (the sheet open, no finger on it; script: IMG_SEAT_VAR). Dragged up, and through the snap to full open, the sheet (z 1000) slides over the still image (z 999), so it goes INTO the legend instead of riding its top edge; dragged @@ -118,7 +118,7 @@ useShadowStyles(` max-height reads --legend-sheet-top-reserve, set on this element from the TOP_RESERVE_PX constant in the script — the SAME number the touch-drag clamp uses, so CSS and JS can never disagree about how tall the sheet may get. */ -.legend-mobile-sheet{position:absolute;bottom:0;inset-inline-start:0;inset-inline-end:0;z-index:1000;background:white;border-top-left-radius:var(--map-radius,16px);border-top-right-radius:var(--map-radius,16px);box-shadow:0 -4px 20px rgba(0,0,0,0.2);transition:height 0.3s ease-out;display:flex;flex-direction:column;overflow:hidden;max-height:calc(100% - var(--legend-sheet-top-reserve,80px));} +.legend-mobile-sheet{overscroll-behavior:contain;position:absolute;bottom:0;inset-inline-start:0;inset-inline-end:0;z-index:1000;background:white;border-top-left-radius:var(--map-radius,16px);border-top-right-radius:var(--map-radius,16px);box-shadow:0 -4px 20px rgba(0,0,0,0.2);transition:height 0.3s ease-out;display:flex;flex-direction:column;overflow:hidden;max-height:calc(100% - var(--legend-sheet-top-reserve,80px));} /* While a finger is resizing the sheet the height must be applied INSTANTLY — with the 0.3s transition left on, the measured height lagged the finger by 14-30px (mobile-sheet-probe baseline 2026-09-16) and the release logic read a @@ -964,12 +964,20 @@ onBeforeUnmount(() => { if (_creditTimer) { clearTimeout(_creditTimer); _creditT beside the legend, so it shows in no resting state (collapsed, or open with nothing selected). A tapped dot's detail view keeps its note inside the sheet, where it explains shared dots; the tree's own copy stays off (:pin-note="false" below). Desktop is unchanged. --> - +
{ if (_creditTimer) { clearTimeout(_creditTimer); _creditT v-if="legendState !== 'collapsed'" class="sheet-grab-lip" aria-hidden="true" - @touchstart="onSheetTouchStart" - @touchmove="onSheetTouchMove" - @touchend="onSheetTouchEnd" - @touchcancel="onSheetTouchEnd" + @touchstart.capture="onSheetTouchStart" + @touchmove.capture="onSheetTouchMove" + @touchend.capture="onSheetTouchEnd" + @touchcancel.capture="onSheetTouchEnd" >
-
{ if (_creditTimer) { clearTimeout(_creditTimer); _creditT class="legend-mobile-sheet" :class="[legendState, { 'sheet-dark': isDark, dragging: isDragging, fit: fitOpen }]" :style="{ height: fitOpen ? 'auto' : legendHeight, '--legend-sheet-top-reserve': TOP_RESERVE_PX + 'px' }" - @touchstart="onSheetTouchStart" - @touchmove="onSheetTouchMove" - @touchend="onSheetTouchEnd" - @touchcancel="onSheetTouchEnd" + @touchstart.capture="onSheetTouchStart" + @touchmove.capture="onSheetTouchMove" + @touchend.capture="onSheetTouchEnd" + @touchcancel.capture="onSheetTouchEnd" >
diff --git a/library/components/poster/PosterSectionView.vue b/library/components/poster/PosterSectionView.vue index 50dccfd5a..c7cf74aa9 100644 --- a/library/components/poster/PosterSectionView.vue +++ b/library/components/poster/PosterSectionView.vue @@ -1,7 +1,7 @@ + + diff --git a/plugin-designs/poster-sections/index.js b/plugin-designs/poster-sections/index.js new file mode 100644 index 000000000..909e319a7 --- /dev/null +++ b/plugin-designs/poster-sections/index.js @@ -0,0 +1,24 @@ +import { defineMapElement } from '@map/elements/defineMapElement.js' +import { registerPosterSectionElement } from '@map/elements/posterSectionElement.js' +/** + * plugin-designs/poster-sections/index.js + * + * Bundle entry. Registers TWO tags: + * - the Poster Sections page: a panel of choices over the live sections, plus the tag to copy + * - the library's poster section element itself, so a website that loads ONLY this script + * can place the tags the page hands out: + * + * + * + */ +const profileModules = import.meta.glob('./*.vue') + +const PosterSectionsElement = defineMapElement({ profileModules }) + +if (typeof customElements !== 'undefined' && !customElements.get('poster-sections')) { + customElements.define('poster-sections', PosterSectionsElement) +} + +registerPosterSectionElement() + +export default PosterSectionsElement diff --git a/plugin-designs/poster-sections/poster-sections.instances.json b/plugin-designs/poster-sections/poster-sections.instances.json new file mode 100644 index 000000000..c84add8cc --- /dev/null +++ b/plugin-designs/poster-sections/poster-sections.instances.json @@ -0,0 +1,77 @@ +{ + "//": "Presets of the poster-sections parameterized profile (capability 2, the same shape as doxa-simple-map's simple-map.instances.json). ONE profile, one preset per row: a poster type × a region (scope; 'all' = one section per region). The build emits a standalone page and embed snippet per preset at poster-sections/poster-sections//index.html and lists them in manifest.json. GENERATED by `bun run gen:poster-sections-presets` from each poster type's own scopes(), and check:poster-sections holds it to them: country posters = DX Life's /regions regions; a type joins once its type file is on the tree and its page can load it. 'configure' adds the panel of choices and Copy embed, a tool for making new presets.", + "profile": "poster-sections", + "instances": [ + { + "id": "countries-all", + "title": "Country posters — all regions", + "props": { + "type": "country", + "scope": "all" + } + }, + { + "id": "countries-asia", + "title": "Country posters — Asia", + "props": { + "type": "country", + "scope": "asia" + } + }, + { + "id": "countries-middle-east", + "title": "Country posters — Middle East", + "props": { + "type": "country", + "scope": "middle-east" + } + }, + { + "id": "countries-latin-america-and-caribbean", + "title": "Country posters — Latin America & Caribbean", + "props": { + "type": "country", + "scope": "latin-america-and-caribbean" + } + }, + { + "id": "countries-africa", + "title": "Country posters — Africa", + "props": { + "type": "country", + "scope": "africa" + } + }, + { + "id": "countries-europe", + "title": "Country posters — Europe", + "props": { + "type": "country", + "scope": "europe" + } + }, + { + "id": "countries-north-america-and-non-spanish-caribbean", + "title": "Country posters — North America & Non-Spanish Caribbean", + "props": { + "type": "country", + "scope": "north-america-and-non-spanish-caribbean" + } + }, + { + "id": "countries-oceania", + "title": "Country posters — Oceania", + "props": { + "type": "country", + "scope": "oceania" + } + }, + { + "id": "configure", + "title": "Configure — make a new preset", + "props": { + "configure": true + } + } + ] +} diff --git a/plugin-designs/poster-sections/poster-sections.meta.json b/plugin-designs/poster-sections/poster-sections.meta.json new file mode 100644 index 000000000..3fcf7cd72 --- /dev/null +++ b/plugin-designs/poster-sections/poster-sections.meta.json @@ -0,0 +1,4 @@ +{ + "label": "Poster Sections — presets", + "note": "One profile, many presets (capability 2): each region's poster section (country posters by DX Life's /regions regions), plus all regions, each with its own page and embed snippet in manifest.json. Other poster types join when their type can load on the page. The Configure preset adds a panel of choices and Copy embed for making new presets." +} diff --git a/plugin-designs/poster-sections/poster-sections.vue b/plugin-designs/poster-sections/poster-sections.vue new file mode 100644 index 000000000..f4311fea3 --- /dev/null +++ b/plugin-designs/poster-sections/poster-sections.vue @@ -0,0 +1,182 @@ + + + diff --git a/plugin-designs/poster-sections/scripts/check-poster-sections.mjs b/plugin-designs/poster-sections/scripts/check-poster-sections.mjs new file mode 100644 index 000000000..dd5a4df40 --- /dev/null +++ b/plugin-designs/poster-sections/scripts/check-poster-sections.mjs @@ -0,0 +1,133 @@ +#!/usr/bin/env node +// check:poster-sections — the Poster Sections panel's choices reach the element unchanged. +// For every choice the panel offers (type, one region or All regions, each language, collapsed or open, 1-5 per +// row), the tags src/embed.js builds, read back through the ELEMENT's own attribute reader (posterSectionConfig), +// give exactly those choices; "All regions" is one section per region, each once; the snippet's script and tags +// are the same tags the page renders. +// PRESETS: poster-sections.instances.json has, per poster type, exactly "-all" + one preset per region its +// adapter's scopes() returns, no more, no fewer. So a region the data no longer has (a stale "other") or a new one +// cannot drift from the presets. A type whose file is NOT on the tree must have no presets at all (its pages would +// only say "No posters"): 'upg100-list' is listed once its type file is on the tree. +// PAGES: the built preset pages (public//poster-sections/poster-sections//) are exactly the presets: the tree +// generator writes a page per preset but never deletes one, so a dropped preset would leave a stale page behind. +// --self-test: a builder that drops `columns`, one that drops `collapsed`, one that repeats a region under All, and a +// presets file with a region the type does not have must each FAIL. +import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs' +import { join, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' +// the site's languages = the catalogue's locale folders (library/i18n/index.js needs Vite's import.meta.glob) +const LOC = join(dirname(fileURLToPath(import.meta.url)), '../../../library/i18n/locales') +const SUPPORTED_LOCALES = readdirSync(LOC).filter((d) => statSync(join(LOC, d)).isDirectory()).sort() +import { posterSectionConfig } from '../../../library/elements/posterSectionConfig.js' +import * as real from '../src/embed.js' +const ROOT = join(dirname(fileURLToPath(import.meta.url)), '../../..') +const PRESETS = JSON.parse(readFileSync(join(ROOT, 'plugin-designs/poster-sections/poster-sections.instances.json'), 'utf8')) +// poster type → [preset id prefix, the file that defines the type's adapter (its default export has scopes())] +const TYPES = { country: ['countries', 'library/components/poster/types/countryPosterType.js'], 'upg100-list': ['upg100', 'plugin-designs/my-upg-100-list/data/upg100-poster-type.js'] } + +// Each type's scopes(), loaded through Vite (the adapters import through the @map alias). +async function loadScopes() { + const { createServer } = await import('vite') + const server = await createServer({ root: ROOT, configFile: false, logLevel: 'error', appType: 'custom', server: { middlewareMode: true, hmr: false, ws: false }, resolve: { alias: { '@map': join(ROOT, 'library') } } }) + const out = {} + try { + for (const [type, [, file]] of Object.entries(TYPES)) { + if (!existsSync(join(ROOT, file))) { out[type] = null; continue } + const a = (await server.ssrLoadModule(join(ROOT, file))).default + out[type] = a.scopes() + } + } finally { await server.close() } + return out +} + +function presetArm(presets, scopesByType) { + const fails = [] + for (const [type, [prefix]] of Object.entries(TYPES)) { + const scopes = scopesByType[type] + const ids = presets.instances.filter((i) => i.props && i.props.type === type).map((i) => i.id) + if (scopes == null) { // the type's file is not on this tree: a preset of it could only say "No posters" + if (ids.length) fails.push(`presets for "${type}" exist (${ids.join(', ')}) but its type is not on this tree, so their pages would show no posters`) + continue + } + const want = [`${prefix}-all`, ...scopes.map((s) => `${prefix}-${s}`)].sort() + const have = [...ids].sort() + const extra = have.filter((x) => !want.includes(x)), missing = want.filter((x) => !have.includes(x)) + if (extra.length || missing.length) fails.push(`presets for "${type}" differ from its scopes(): extra ${extra.join(', ') || '-'}; missing ${missing.join(', ') || '-'}`) + for (const i of presets.instances.filter((x) => x.props && x.props.type === type && x.id !== `${prefix}-all`)) { + if (i.id !== `${prefix}-${i.props.scope}`) fails.push(`preset "${i.id}" shows scope "${i.props.scope}"`) + } + } + if (!presets.instances.some((i) => i.id === 'configure' && i.props && i.props.configure === true)) fails.push('no "configure" preset') + return fails +} + +// The built preset pages, when a tree is built: every folder under poster-sections/poster-sections/ that holds a page. +function builtPages() { + const pub = join(ROOT, 'public') + if (!existsSync(pub)) return null + for (const tree of readdirSync(pub)) { + const dir = join(pub, tree, 'poster-sections', 'poster-sections') + if (existsSync(dir)) return readdirSync(dir).filter((d) => existsSync(join(dir, d, 'index.html'))) + } + return null +} +function pagesArm(presets, pages) { + if (pages == null) return [] + const ids = presets.instances.map((i) => i.id) + const stale = pages.filter((p) => !ids.includes(p)), missing = ids.filter((i) => !pages.includes(i)) + return stale.length || missing.length ? [`built preset pages differ from the presets: stale ${stale.join(', ') || '-'}; missing ${missing.join(', ') || '-'} (delete a dropped preset's page; rebuild the tree for a new one)`] : [] +} + +const SCOPES = ['africa', 'asia', 'europe', 'latin-america-and-caribbean', 'middle-east', 'north-america-and-non-spanish-caribbean', 'oceania', 'other'] +const fake = (attrs) => ({ getAttribute: (n) => (n in attrs ? attrs[n] : null), hasAttribute: (n) => n in attrs }) + +function arms(embed) { + const fails = [] + for (const scope of ['', ...SCOPES]) for (const locale of SUPPORTED_LOCALES) for (const collapsed of [false, true]) for (const columns of [1, 2, 3, 4, 5]) { + const choices = { type: 'country', scope, scopes: SCOPES, locale, collapsed, columns, assetsBase: 'https://example.org/dx-maps/assets' } + const tags = embed.sectionAttrs(choices) + const want = scope ? [scope] : SCOPES + if (tags.map((a) => a.scope).join() !== want.join()) { fails.push(`scope "${scope || 'All'}": sections ${tags.map((a) => a.scope).join()}`); continue } + for (const a of tags) { + const c = posterSectionConfig(fake(a)) + const bad = [c.type !== 'country' && 'type', c.locale !== locale && 'locale', c.collapsed !== collapsed && 'collapsed', + c.columns !== columns && 'columns', c.assetsBase !== choices.assetsBase && 'assets-base', c.collapsible !== true && 'collapsible'].filter(Boolean) + if (bad.length) { fails.push(`${a.scope}/${locale}/${collapsed ? 'shut' : 'open'}/${columns}: the element reads back a different ${bad.join(', ')}`); break } + } + if (fails.length > 3) return fails + } + const snip = embed.embedSnippet({ type: 'country', scope: '', scopes: SCOPES, locale: 'ar', collapsed: true, columns: 3 }, 'https://example.org/dx-maps/poster-sections/poster-sections.js') + const lines = snip.split('\n') + if (lines[0] !== '') fails.push(`snippet: first line is not the bundle script: ${lines[0]}`) + if (lines.length !== SCOPES.length + 1 || !lines.slice(1).every((l, i) => l === ``)) fails.push(`snippet: the tags are not one per region with the chosen attributes:\n${snip}`) + return fails +} + +const SCOPES_BY_TYPE = await loadScopes() + +if (process.argv.includes('--self-test')) { + const mutants = { + 'drops columns': { ...real, sectionAttrs: (c) => real.sectionAttrs(c).map(({ columns, ...a }) => a) }, + 'drops collapsed': { ...real, sectionAttrs: (c) => real.sectionAttrs(c).map(({ collapsed, ...a }) => a) }, + 'repeats a region under All': { ...real, sectionAttrs: (c) => { const r = real.sectionAttrs(c); return c.scope ? r : [...r, r[0]] } }, + } + let ok = true + const stale = { ...PRESETS, instances: [...PRESETS.instances, { id: 'countries-atlantis', props: { type: 'country', scope: 'atlantis' } }] } + const pg = pagesArm(PRESETS, [...PRESETS.instances.map((i) => i.id), 'countries-atlantis']) + console.log(` mutant "a built page for a preset that no longer exists": ${pg.length ? 'FAILS (good)' : 'PASSES (bad)'}`) + if (!pg.length) ok = false + const pf = presetArm(stale, SCOPES_BY_TYPE) + console.log(` mutant "a preset for a region the type does not have": ${pf.length ? 'FAILS (good)' : 'PASSES (bad)'}`) + if (!pf.length) ok = false + for (const [name, m] of Object.entries(mutants)) { + const f = arms(m) + console.log(` mutant "${name}": ${f.length ? 'FAILS (good)' : 'PASSES (bad)'}`) + if (!f.length) ok = false + } + console.log(ok ? 'POSTER-SECTIONS SELF-TEST: PASS — every mutant fails the check.' : 'POSTER-SECTIONS SELF-TEST: FAIL') + process.exit(ok ? 0 : 1) +} +const fails = [...arms(real), ...presetArm(PRESETS, SCOPES_BY_TYPE), ...pagesArm(PRESETS, builtPages())] +const measured = Object.entries(SCOPES_BY_TYPE).filter(([, v]) => v).map(([k, v]) => `${k} ${v.length}`).join(', ') +if (fails.length) { console.log(`POSTER-SECTIONS: FAIL (${fails.length})`); fails.forEach((f) => console.log(` - ${f}`)); process.exit(1) } +console.log(`POSTER-SECTIONS: PASS — every panel choice (${SCOPES.length + 1} region choices × ${SUPPORTED_LOCALES.length} languages × open/collapsed × 5 per row) reads back through the element unchanged; All regions = one section per region; the snippet is the script + those tags; presets = each type's scopes() + all (${measured}).`) diff --git a/plugin-designs/poster-sections/scripts/gen-presets.mjs b/plugin-designs/poster-sections/scripts/gen-presets.mjs new file mode 100644 index 000000000..42641d09d --- /dev/null +++ b/plugin-designs/poster-sections/scripts/gen-presets.mjs @@ -0,0 +1,42 @@ +#!/usr/bin/env node +// gen-presets.mjs (bun run gen:poster-sections-presets) — writes poster-sections.instances.json from each poster type's +// OWN regions: "-all" plus one preset per scope its adapter's scopes() returns, titled by the type's own +// (English) section title, then "configure". The same source check:poster-sections holds the file to, so when the +// data's regions change, running this is the whole fix. A type whose file is not on the tree gets NO presets: its +// pages could only say "No posters". +import { readFileSync, writeFileSync, existsSync } from 'node:fs' +import { join, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), '../../..') +const FILE = join(ROOT, 'plugin-designs/poster-sections/poster-sections.instances.json') +const TYPES = [ + { type: 'country', prefix: 'countries', noun: 'Country posters', file: 'library/components/poster/types/countryPosterType.js' }, + { type: 'upg100-list', prefix: 'upg100', noun: 'UPG 100 posters', file: 'plugin-designs/my-upg-100-list/data/upg100-poster-type.js' }, +] +const old = JSON.parse(readFileSync(FILE, 'utf8')) +const { createServer } = await import('vite') +const server = await createServer({ root: ROOT, configFile: false, logLevel: 'error', appType: 'custom', server: { middlewareMode: true, hmr: false, ws: false }, resolve: { alias: { '@map': join(ROOT, 'library') } } }) +// English from the real catalogue (library/i18n/locales/en/.json), so a title the type translates reads as the +// site's English; a key the catalogue lacks comes back as itself, which every adapter treats as "use the region name". +const english = (key) => { + const [area, ...parts] = String(key).split('.') + const f = join(ROOT, `library/i18n/locales/en/${area}.json`) + if (!parts.length || !existsSync(f)) return key + let o = JSON.parse(readFileSync(f, 'utf8')) + for (const p of parts) { if (!o || typeof o !== 'object' || !(p in o)) return key; o = o[p] } + return typeof o === 'string' ? o : key +} +const instances = [] +try { + for (const { type, prefix, noun, file } of TYPES) { + if (!existsSync(join(ROOT, file))) continue + const a = (await server.ssrLoadModule(join(ROOT, file))).default + instances.push({ id: `${prefix}-all`, title: `${noun} — all regions`, props: { type, scope: 'all' } }) + for (const scope of a.scopes()) instances.push({ id: `${prefix}-${scope}`, title: `${noun} — ${a.title(scope, english)}`, props: { type, scope } }) + } +} finally { await server.close() } +instances.push({ id: 'configure', title: 'Configure — make a new preset', props: { configure: true } }) +writeFileSync(FILE, JSON.stringify({ ...old, instances }, null, 2) + '\n') +const before = new Set(old.instances.map((i) => i.id)), after = new Set(instances.map((i) => i.id)) +console.log(`poster-sections presets: ${instances.length} (removed: ${[...before].filter((x) => !after.has(x)).join(', ') || 'none'}; added: ${[...after].filter((x) => !before.has(x)).join(', ') || 'none'})`) diff --git a/plugin-designs/poster-sections/src/embed.js b/plugin-designs/poster-sections/src/embed.js new file mode 100644 index 000000000..fda282f54 --- /dev/null +++ b/plugin-designs/poster-sections/src/embed.js @@ -0,0 +1,31 @@ +/** + * embed.js — the Poster Sections panel's choices → the tags that show them. Pure (no Vue, no + * DOM), so the page and scripts/check-poster-sections.mjs use the SAME function: what the live sections render is + * exactly what "Copy embed" hands a website. + * + * choices = { type, scope, scopes, locale, collapsed, columns, assetsBase } + * scope '' = "All regions": one section per scope in `scopes`, in that order + * columns 0 = leave it to the wall (no attribute) + */ +export const TAG = 'dxm-poster-section' + +export function sectionAttrs(choices) { + const { type = 'country', scope = '', scopes = [], locale = '', collapsed = false, columns = 0, assetsBase = '' } = choices || {} + const list = scope ? [scope] : scopes + return list.map((s) => { + const a = { type, scope: s } + if (locale) a.locale = locale + if (collapsed) a.collapsed = '' + if (columns) a.columns = String(columns) + if (assetsBase) a['assets-base'] = assetsBase + return a + }) +} + +const esc = (v) => String(v).replace(/&/g, '&').replace(/"/g, '"').replace(/ `<${TAG}${Object.entries(a).map(([k, v]) => (v === '' ? ` ${k}` : ` ${k}="${esc(v)}"`)).join('')}>`) + return [``, ...tags].join('\n') +} diff --git a/plugin-designs/posterverse/scripts/check-posterverse-shelf.mjs b/plugin-designs/posterverse/scripts/check-posterverse-shelf.mjs index 922d007c2..b87e55992 100644 --- a/plugin-designs/posterverse/scripts/check-posterverse-shelf.mjs +++ b/plugin-designs/posterverse/scripts/check-posterverse-shelf.mjs @@ -8,7 +8,7 @@ * GATED. The allow-list lives in src/tabs.js; this gate is what keeps it one. * * WHAT IT ASSERTS (each arm is poisoned by --self-test before a pass is trusted): - * A. the rail's rows are exactly the named sets: Posters = upg-100, country-posters, poster-design; + * A. the Experimental folder starts open, and the rail's rows are exactly the named sets: Posters = upg-100, country-posters, poster-design; * Experimental = wall-of-people-groups, bible-posters (poster plugins only). Ids unique, * every row in a declared section. * B. no row names a NEVER bundle (key-cities, the shells themselves), and resolving the built @@ -52,6 +52,9 @@ export function checkShelf({ SECTIONS, TABS, NEVER, resolveTabs, manifest, meta, const got = TABS.filter((t) => t.section === sec).map((t) => t.id) if (!sameList(got, want)) errs.push(`A: section ${sec} rows are [${got.join(', ')}], expected [${want.join(', ')}]`) } + // The Experimental folder starts OPEN: closed, under one short section, it read as an empty caption. + const exp = SECTIONS.find((g) => g.key === 'experimental') + if (!exp || !exp.folder || exp.open !== true) errs.push('A: the Experimental folder must be declared folder:true, open:true (closed, it is easy to miss)') const extra = TABS.filter((t) => !EXPECT[t.section]).map((t) => t.id) if (extra.length) errs.push(`A: rows outside the allow-listed sections: ${extra.join(', ')}`) // B. never a gated bundle — by declaration and by resolution over the real manifest @@ -114,6 +117,7 @@ function selfTest(base) { const clone = (o) => JSON.parse(JSON.stringify(o)) const cases = [ ['A: key-cities added to Experimental', (c) => { c.TABS = [...c.TABS, { id: 'key-cities', section: 'experimental', bundle: 'key-cities', profile: 'key-cities' }] }, /^A: section experimental|^B: row key-cities/], + ['A: the Experimental folder closed again', (c) => { c.SECTIONS = c.SECTIONS.map((g) => g.key === 'experimental' ? { ...g, open: false } : g) }, /^A: the Experimental folder must be/], ['A: a row dropped', (c) => { c.TABS = c.TABS.filter((t) => t.id !== 'wall-of-people-groups') }, /^A: section experimental/], ['B: resolver that shows whatever the manifest carries', (c) => { c.resolveTabs = (m) => (m.bundles || []).map((b) => ({ id: b.bundle, bundle: b.bundle, profile: b.bundle, tag: b.bundle, scriptUrl: b.scriptUrl })) }, /^B: resolution produced a key-cities row/], ['C: a composite row falls back to a leaf', (c) => { c.manifest = clone(c.manifest); const b = c.manifest.bundles.find((x) => x.bundle === 'my-upg-100-list'); b.profiles = b.profiles.filter((p) => p.profile !== 'upg-100-mfe') }, /^C: row upg-100 opens/], diff --git a/plugin-designs/posterverse/src/tabs.js b/plugin-designs/posterverse/src/tabs.js index c251b6605..97966b7c4 100644 --- a/plugin-designs/posterverse/src/tabs.js +++ b/plugin-designs/posterverse/src/tabs.js @@ -20,10 +20,14 @@ * composite is the plugin as its owner designed it (and it is what makes Posterverse capability 5). */ -// The rail's sections, in order. `folder` renders collapsible and starts closed, like the gallery's. +// The rail's sections, in order. Experimental is a collapsible folder, like the gallery's, but it starts +// OPEN: the gallery's closed folder sits at the foot of a long rail, while Posterverse has one short +// section above it, so a closed folder sat mid-drawer with empty space below and read as a caption with +// nothing under it (measured at 390: the heading at y 335 in an 800px drawer, ~500px empty below). +// Open, its plugins show like any section's rows; a reader can still collapse it. export const SECTIONS = [ { key: 'posters', i18n: 'sec.posters', icon: 'poster' }, - { key: 'experimental', i18n: 'sec.experimental', icon: 'flask', folder: true, open: false }, + { key: 'experimental', i18n: 'sec.experimental', icon: 'flask', folder: true, open: true }, ] // Every row Posterverse can show, in rail order. diff --git a/plugin-designs/regions-dashboard/country-poster-sections.meta.json b/plugin-designs/regions-dashboard/country-poster-sections.meta.json new file mode 100644 index 000000000..9f3005b33 --- /dev/null +++ b/plugin-designs/regions-dashboard/country-poster-sections.meta.json @@ -0,0 +1,5 @@ +{ + "type": "single", + "label": "Regions — country posters by region", + "note": "The Regions dashboard's third view: one collapsible per region (the library's element, the same cards and print options as the Country Posters wall), starting shut, in the page's locale. The regions are the ones the poster data files its countries under, in the dashboard's region order." +} diff --git a/plugin-designs/regions-dashboard/country-poster-sections.vue b/plugin-designs/regions-dashboard/country-poster-sections.vue new file mode 100644 index 000000000..9dae22f4b --- /dev/null +++ b/plugin-designs/regions-dashboard/country-poster-sections.vue @@ -0,0 +1,66 @@ + + + diff --git a/plugin-designs/regions-dashboard/regions-list.vue b/plugin-designs/regions-dashboard/regions-list.vue index b6e660a57..61da5969c 100644 --- a/plugin-designs/regions-dashboard/regions-list.vue +++ b/plugin-designs/regions-dashboard/regions-list.vue @@ -44,7 +44,6 @@ import { applyPeopleGroupList } from '@map/lists/peopleGroupList.js' import { SHADOW_BOX_SIZING_RESET } from './src/shadowReset.js' import { splitLastWord } from './src/useLastWordHighlight.js' import { useRegionsI18n } from '@map/i18n/useRegionsI18n.js' -import { countryName } from '@map/utils/countryName.js' import { useOptionalT } from '@map/composables/useOptionalT.js' // ar renders right-to-left. RTL_LOCALES is the shared set and :dir on our own root is the // house pattern (doxa-simple-map.vue:18, :246, :1346) — an EMBEDDED element must not reach for @@ -60,20 +59,14 @@ import { DXLIFE_TOKENS } from './dxlife-tokens.js' // and the no-selection chooser. The requirement: a toggle at the bottom of each region section // that opens that region's country posters. // -// NOTHING HERE IS REBUILT. PosterWall is already the shared, fully parameterised grid that -// my-upg-100-list/poster-wall.vue and country-posters/country-poster-wall.vue both render, and -// it already owns the card layout, the "Show on map" header button, the Print Options footer -// and the client-side raster. This file supplies only data and a toggle — the same division of -// labour country-poster-wall.vue states in its own header ("this profile supplies only its -// data and taxonomy"). -import PosterWall from '@map/components/poster/PosterWall.vue' +// NOTHING HERE IS REBUILT. Each region's posters are the library's +// (library/elements/posterSectionElement.js), the same section, cards and print options as the Country Posters +// wall and the Regions dashboard's third view, so the list is composed from the shared element rather than +// drawing a wall of its own. This file supplies only the toggle and the region. import RegionSectionCard from '@map/components/regions/RegionSectionCard.vue' -import posterData from '@map/data/country-posters.json' -import { createPeopleGroupRecords } from '@map/lists/peopleGroupRecords.js' -import { makeCountrySheet, largeSheet, posterWithheld, rowsWithheld } from '@map/packet/countrySheet.js' -import { isPhonePrint } from '@map/composables/printInPlace.js' -import { keyedError } from '@map/i18n/keyedError.js' import { resolveAssetsBase } from '@map/utils/embedBase.js' +import { registerPosterSectionElement } from '@map/elements/posterSectionElement.js' +import { regionKey } from '@map/components/poster/types/countryPosterType.js' import { onBeforeUnmount } from 'vue' import { cyrillicHeading } from '@map/styles/portedGlobals.js' import { hostElement } from '@map/utils/hostElement.js' @@ -276,44 +269,32 @@ async function loadRegions() { } } -// ─── COUNTRY POSTERS, per region ───────────────────────────────────────────────────────── -// One wall per region, opened by the footer toggle. instanceId is suffixed with the region -// slug because PosterWall scopes its CustomEvent by instanceId so several embeds on one page -// never cross-talk (PosterWall.vue:40) — and here there genuinely are several. -const POSTER_EVENT = 'doxa-regions:show-country-on-map' +// ─── COUNTRY POSTERS, per region ───────────────────────────────────────────── +// One poster section per region, opened by the footer toggle: the library's . +// Its scope is the region's key through the country type's own regionKey, so the list and the element can never +// spell a region two ways. It is not collapsible (the toggle already opens and shuts it) and shows two cards a row, +// on a phone as well. +// ⚠ The element files countries under the poster data's region (library/data/country-posters.json), so this list +// shows exactly that data's membership for each region. +registerPosterSectionElement() +const SECTION_EVENT = 'doxa-country-posters:show-on-map' // Read off the injected config the same way every other value in this file is (see the note // above cfg) — a bare default here would make two embeds on one page answer each other's // poster events. const instanceId = computed(() => cfg.value.instanceId || 'regions-list') const posterAssetsBase = computed(() => resolveAssetsBase(cfg.value)) - -// ★ THE PRINT ROWS, AND THE SHEET DRAWN FROM THEM. Supplying `records` is what makes the Prayer -// List, Card Deck and Prayer Packet buttons appear on each country (PosterWall hides them when a -// wall cannot supply rows, rather than failing at click time). One fetch per language, cached in -// the provider and shared by every country in every region, so opening five regions is still one -// request. The same provider feeds the sheet, so the printed map and the printed list can never -// disagree about which groups are in a country. -const countryRecords = createPeopleGroupRecords({ - apiBase: () => prayBaseUrl.value, - lang: () => languageCode.value, - profileConfig: injectedCfg, +const sectionAttrs = (region) => ({ + type: 'country', + scope: regionKey(region.value || region.label), + locale: i18nLocale.value || languageCode.value || 'en', + collapsible: 'false', + columns: '2', + // Two posters a row on a phone too, as this list has always shown them: 150 px lets two cards fit at 390. + 'min-card-px': '150', + 'assets-base': posterAssetsBase.value, + // The same people-groups service this list reads its regions from, so a card's list and deck match it. + 'api-base-url': prayBaseUrl.value, }) -// Page 1 of a country packet, and the country poster, drawn at the click. Nothing is stored: no -// country sheet ships, and the packet's default lift would ask for posters/country-.html and -// 404 on all 175. -const sheetForRecord = makeCountrySheet({ - records: countryRecords, - assetsBase: () => posterAssetsBase.value, - // The same list the Country Posters wall computes its series from, so this tab's printed - // "map N of M" agrees with the wall's print and with the country's thumbnail. - countries: posterData.countries || [], -}) -// ★ THE POSTER IS ENGLISH BY RECORDED DECISION (series.json english_only_note), so the generator gets -// the DATA's own record, never the card: the card's label is localised (localisedPoster below), and -// handing it over printed "Pray for السودان" beside an English "Countries: Sudan" in Arabic. The -// Country Posters wall resolves the record by id the same way (country-poster-wall.vue, byId). -const POSTER_BY_ID = new Map((posterData.countries || []).map(c => [c.id, c])) -const countrySheet = (card, format) => sheetForRecord(POSTER_BY_ID.get(card && card.id) || card, format) const openPosters = ref(new Set()) function togglePosters(region) { @@ -323,74 +304,17 @@ function togglePosters(region) { } const postersOpen = (region) => openPosters.value.has(region.slug) -// The poster dataset is keyed by ISO-3 (`iso3`); the regions rollup keys by `code`. Same -// codes, so the join is direct. -const POSTER_BY_ISO3 = new Map((posterData.countries || []).map(c => [String(c.iso3 || '').toUpperCase(), c])) - -// Every country in the region gets a card, in PosterWall's own shape. A country with no -// generated poster is NOT dropped — it is passed with a reason, which is what the wall's -// `unsupported` slot is for, so the grid never silently loses a country. -// ★ EACH CARD NAMED IN THE READER'S LANGUAGE. item.label is the card's accessible name, the print -// HUD's title, and the title on the printed list, deck and packet — and country-posters.json carries -// it in English only, so every card was English in every locale. Same fix as the country-posters -// wall, and the same two caveats travel with it: -// · ENGLISH KEEPS THE DATA'S OWN NAMES. Intl's English differs from them for 32 of these 175 -// countries, and countryName.js records that swap as a product decision rather than a refactor. -// · THE SHEET GENERATOR STILL GETS THE ORIGINAL RECORD. The poster itself is English by recorded -// decision, so only the card's label is localised — postersFor's result feeds the wall, while -// countrySheet (above) swaps the card back for the json's own record, by id, before generating. -// The i18n locale REF is read, not a snapshot, so a runtime language switch renames the cards. -function localisedPoster(rec) { - const locale = i18nLocale.value || 'en' - if (/^en\b/i.test(locale)) return rec - const name = countryName(rec.iso3, locale) - return name && name !== rec.iso3 ? { ...rec, label: name } : rec -} - -function postersFor(region) { - return (region.countries || []).map((c) => { - const rec = POSTER_BY_ISO3.get(String(c.code || '').toUpperCase()) - if (rec) return localisedPoster(rec) - return { - id: `country-${String(c.code || '').toLowerCase()}`, - iso3: c.code, - label: c.name, - count: c.count, - unsupportedReason: t('No poster has been generated for this country yet.'), - } - }) -} - -// ★ THE REASON IS TRANSLATED, THE PRESENCE OF ONE IS STILL THE DATA'S. The record carries its own -// English note ("India has 855 people groups — too many…"), and returning it put one untranslated -// sentence on the wall in every locale. The record still DECIDES whether a country is unsupported; -// only the wording comes from the catalogue now. messages.countryPosterTooMany exists in all -// eleven, and needs no number in it: the card already shows the count directly above. -// India is the only country in the dataset carrying one (855 people groups, no sheet); China is -// fine at 3x4. My own not-yet reason, for a country with no record at all, is already through t(). -// ★ A COUNTRY TOO LARGE FOR PAPER (countrySheet.js's LARGE_SHEETS; India) prints as one large sheet through the same -// makeCountrySheet, so its record's "too many" note no longer makes it unsupported here. It is DESKTOP-ONLY: on a phone -// its card says so and offers no rows, rather than failing (the Country Posters wall applies the same rule, same table). -const ON_PHONE = isPhonePrint() -const phoneBlocked = (c) => ON_PHONE && Boolean(largeSheet(c)?.desktopOnly) -const posterUnsupported = (c) => (posterWithheld(c) ? tKey('messages.countryPosterTooMany') : phoneBlocked(c) ? tKey('poster.phoneLimitAll') - : c.unsupportedReason && !largeSheet(c) ? tKey('messages.countryPosterTooMany') : null) -// A blocked card's rows REJECT with the notice, never [] ("no people groups" on a card that has hundreds). -// A withheld whole-country poster (countrySheet.js WITHHELD: India) withholds its list, packet and cards too. -const posterRows = (item) => (rowsWithheld(item) ? Promise.reject(keyedError('messages.countryPosterTooMany', 'Too many people groups to fit on one poster legibly — a solution is in progress.')) : phoneBlocked(item) ? Promise.reject(keyedError('poster.phoneLimitAll', 'This needs a desktop or laptop computer.')) : countryRecords({ type: 'country', values: [item.iso3] })) - -// One region's wall is already inside a region section, so it needs no second level of -// collapsing and no grouping: everything in it is that one region. -const posterGroupBy = () => 'countries' - -// PosterWall's "Show on map" dispatches a window CustomEvent carrying listId (the poster id, +// A poster card's "Show on map" dispatches a window CustomEvent carrying listId (the poster id, // e.g. "country-chn") or null when the user toggles the same card off. Mapping that id back to // the country record and reusing selectCountry() is what makes the button open the dashboard // tab for that country: the requirement is that the view-on-map button opens the country // dashboard page for that country. +// The library's poster sections name themselves 'poster-section-country-' (PosterSectionView.vue). In the +// Regions composite this list is always mounted, so it also answers the third view's sections (Country Posters): +// ONE handler for both, which keeps the region context in the selection. function onPosterShowOnMap(ev) { const d = ev?.detail || {} - if (!String(d.instanceId || '').startsWith(instanceId.value)) return + if (!String(d.instanceId || '').startsWith('poster-section-country-')) return if (!d.listId) return // a deselect is not a navigation const iso3 = String(d.listId).replace(/^country-/, '').toUpperCase() for (const region of regions.value) { @@ -407,8 +331,8 @@ function onPosterShowOnMap(ev) { // already had this watcher (line 541); this tab was simply missed. watch(languageCode, () => { loadRegions() }) -onMounted(() => { if (typeof window !== 'undefined') window.addEventListener(POSTER_EVENT, onPosterShowOnMap) }) -onBeforeUnmount(() => { if (typeof window !== 'undefined') window.removeEventListener(POSTER_EVENT, onPosterShowOnMap) }) +onMounted(() => { if (typeof window !== 'undefined') window.addEventListener(SECTION_EVENT, onPosterShowOnMap) }) +onBeforeUnmount(() => { if (typeof window !== 'undefined') window.removeEventListener(SECTION_EVENT, onPosterShowOnMap) }) onMounted(loadRegions) @@ -583,32 +507,7 @@ useShadowStyles( @select-country="(c) => selectCountry(c, region)" > diff --git a/plugin-designs/regions-dashboard/regions-mfe.vue b/plugin-designs/regions-dashboard/regions-mfe.vue index 2efe33d2f..34de2472b 100644 --- a/plugin-designs/regions-dashboard/regions-mfe.vue +++ b/plugin-designs/regions-dashboard/regions-mfe.vue @@ -24,7 +24,7 @@ * listens for, and scrolls to tab 2. That is the same seam upg-100-mfe uses for the poster * wall's 'show-on-map', and it keeps each pane under its own ProfileLoader/Pinia. */ -import { ref, computed, inject, onMounted, onBeforeUnmount } from 'vue' +import { ref, computed, inject, onMounted, onBeforeUnmount, watch, nextTick } from 'vue' import { usePaneConfig } from '@map/composables/usePaneConfig.js' import { buildPaneColumn, PANE_GRID_STYLES } from '@map/layouts/pane-wrapper/pane-wrapper.js' import { useShadowStyles } from '@map/composables/useShadowStyles.js' @@ -47,8 +47,11 @@ import MapInfoButton from '@map/components/map-controls/MapInfoButton.vue' // them to 97 and fail the gate. The moment someone adds these three strings to all locales, // the strip localises with no code change here. import { useRegionsI18n } from '@map/i18n/useRegionsI18n.js' +// The third view's label is the gallery's own tab name for country posters (tabs.countryPosters, all eleven locales). +import { useOptionalT } from '@map/composables/useOptionalT.js' const { t } = useRegionsI18n() +const tKey = useOptionalT('') const cfg = inject('profileConfig', ref(null)) const uiStore = inject('uiStore', null) const isDark = computed(() => !!uiStore?.isDark) @@ -70,6 +73,8 @@ const spec = computed(() => ({ panes: [ { id: 'list', label: t('Regions List'), at: [0, 0], config: { profile: 'regions-list' } }, { id: 'dashboard', label: t('Region Dashboards'), at: [1, 0], config: { profile: 'region-dashboards' } }, + // THE THIRD VIEW: country posters, one collapsible section per region (the library's ). + { id: 'posters', label: tKey('tabs.countryPosters'), at: [2, 0], config: { profile: 'country-poster-sections' } }, ], // The View switcher lives in this element's own header, so the layout's "… below ↓" pill // would be a second, redundant control (same call upg-100-mfe makes). @@ -146,6 +151,13 @@ function go(id) { column?.scrollTo(id) } +// On a phone the three tabs scroll sideways (see the 600px rule below): keep the current one in view. +const hdr = ref(null) +watch(view, () => nextTick(() => { + const b = hdr.value && hdr.value.querySelector('.seg button[aria-pressed="true"]') + if (b && b.scrollIntoView) b.scrollIntoView({ inline: 'center', block: 'nearest' }) +})) + // ── tab 1 -> tab 2: forward the selection, then swap the visible pane ─────── const SELECT_EVENT = 'doxa-regions-dashboard:select' @@ -169,6 +181,10 @@ function onCountrySelected(e) { forwardSelection({ regionSlug: d.region?.slug || '', countrySlug: d.slug || '' }) } +// A card's "Show on map" in the third view (Country Posters) opens that country's dashboard: the Regions list pane +// (always mounted here) answers every poster section's event with its own selectCountry(), which arrives above as +// 'country-selected'. One handler for both views (regions-list.vue onPosterShowOnMap). + onMounted(() => { if (!stage.value) return column = buildPaneColumn(spec.value, { host: stage.value }) @@ -234,6 +250,14 @@ useShadowStyles(SHADOW_BOX_SIZING_RESET + PANE_GRID_STYLES + '\n' + // ON A PHONE the centred view switcher (about 290 px at 390) leaves no room for two buttons at one end, so the // ⓘ takes the header's START edge, where the floating ⓘ sat before; Share keeps the end. '@media (max-width: 600px){ .rmfe .hdr .hdr-btns > :first-child{ position:absolute; inset-inline-start:12px; top:50%; transform:translateY(-50%); } }' + + // THREE VIEWS DO NOT FIT A PHONE'S HEADER (measured at 390: the tabs need about 390 px and ran under the corner + // buttons). The switcher keeps the room between the two 36 px buttons (12 + 36 + 8 on each side) and its tab row + // scrolls sideways, every label whole; the current tab is scrolled into view on each change. A fieldset is + // min-content wide by default, hence min-width:0. Desktop is unchanged. + '@media (max-width: 600px){ .rmfe .hdr .viewsel{ min-width:0; max-width:calc(100% - 112px); }' + + ' .rmfe .hdr .viewsel .seg{ overflow-x:auto; scrollbar-width:none; scroll-snap-type:x proximity; -webkit-overflow-scrolling:touch; }' + + ' .rmfe .hdr .viewsel .seg::-webkit-scrollbar{ display:none; }' + + ' .rmfe .hdr .viewsel .seg button{ flex:0 0 auto; scroll-snap-align:center; } }' + // Stage: the pane-wrapper column mounts here (its .pane-col is absolute). '.rmfe .stage{ position:relative; flex:1 1 auto; min-height:0; }' + '.pane > .pane-mount > *{ position:absolute; inset:0; width:100%; height:100%; display:block; }', @@ -243,7 +267,7 @@ useShadowStyles(SHADOW_BOX_SIZING_RESET + PANE_GRID_STYLES + '\n' +