From 89bd9c5d79cd0140abdafe34d884eeb1a1aeabfe Mon Sep 17 00:00:00 2001 From: doxa-maps-88655c70 <88627970+doxa-maps-88655c70@users.noreply.github.com> Date: Fri, 25 Sep 2026 05:55:15 +0000 Subject: [PATCH] Poster wall: the banner gets stickers, top and bottom MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The card is a BANNER that displays a map, the way a label displays a design on a bottle. A banner taped up gets a sticker at each end, and each sticker straddles the edge it tapes: part on the banner's own white space, part overhanging. Neither one touches the map. MEASURED at 1440px before this change: banner 240-426, map 241-425 -> 1px of white space at each end header sticker 35px above the banner's top edge, -1px below footer sticker 1px above the bottom edge, 67px below So the map filled the banner edge to edge and there was no banner surface for a sticker to sit on. The footer row could only hang off into space, which is what shipped, and every assertion in the gate was green through all of it. The banner now reserves white space at both ends (--banner-pad-top / --banner-pad-bottom on .main), and each sticker centres on its edge: header sticker 17px on the banner / 17px above it footer sticker 35px on the banner / 33px overhanging map covered by either: 0px The print row also moved INSIDE .main. It was a sibling, so top:100% resolved against the CARD, 27px past the banner - the re-parenting and 07d3341c's centring transform are one fix, and neither works alone. That commit's per-row localStorage preference is preserved. Card Deck is no longer a peer button with a 162px . Print-button labels WRAP instead of clipping. With the per-row default now 2, a 390px viewport halves the card to ~170px, where Romanian's "Pachet de cartonașe" cannot share a line: measured 54 of 81 controls clipped at ro/390px, en and zh clean. The ruling on this wall is that a long label wraps; nowrap was forcing a clip. Also fixes the transparent strip above a pinned section header. Sticky pins to the scroll container's PADDING box, so with .wall's 22px padding-top the header rested 22px down and posters scrolled visibly through the gap. A ::before extends the header's opaque fill upward by exactly that padding, read from the same var so the two cannot drift. Measured by sampling the strip's pixels: 1 colour, 100% header background, where before it was 1338-2831 colours of poster artwork. The MFE shell's own chrome is translated, in all 11 locales. Measured with ?lang=ar: "Select View", "Views", "Prayer Resources" and "Map" rendered English while the poster wall's buttons inside the panes translated correctly, because each pane installs i18n and the wrapper around them never did - the chrome a reader meets FIRST was the untranslated part. The wall's own heading, tag and intro go through t() for the same reason, with the inside the translated string so each language emphasises its own words. The gate grew four assertions, each mutation-tested to fail on the defect it was written for: off-frame (row floated off the banner), head-off (header sticker not straddling), map-covered (a sticker on the poster), and selects (a native dropdown back in the row). Includes a rebuild of the four PosterWall consumers, which also picks up studio's shared useMapInstance unmount guard and exp-shepherd's translated LegendMobile labels. --- internal/check-poster-card-fits.mjs | 105 ++++++- library/components/poster/PosterWall.vue | 270 ++++++++++++++---- library/i18n/locales/ar/messages.json | 5 +- library/i18n/locales/ar/tabs.json | 8 +- library/i18n/locales/de/messages.json | 5 +- library/i18n/locales/de/tabs.json | 8 +- library/i18n/locales/en/messages.json | 5 +- library/i18n/locales/en/tabs.json | 8 +- library/i18n/locales/es/messages.json | 5 +- library/i18n/locales/es/tabs.json | 8 +- library/i18n/locales/fr/messages.json | 5 +- library/i18n/locales/fr/tabs.json | 8 +- library/i18n/locales/hi/messages.json | 5 +- library/i18n/locales/hi/tabs.json | 8 +- library/i18n/locales/it/messages.json | 5 +- library/i18n/locales/it/tabs.json | 8 +- library/i18n/locales/pt/messages.json | 5 +- library/i18n/locales/pt/tabs.json | 8 +- library/i18n/locales/ro/messages.json | 5 +- library/i18n/locales/ro/tabs.json | 8 +- library/i18n/locales/ru/messages.json | 5 +- library/i18n/locales/ru/tabs.json | 8 +- library/i18n/locales/zh/messages.json | 5 +- library/i18n/locales/zh/tabs.json | 8 +- .../my-upg-100-list/poster-wall.vue | 22 +- .../my-upg-100-list/upg-100-mfe.vue | 24 +- .../country-posters/countries-map/index.html | 2 +- .../country-poster-wall/index.html | 2 +- .../country-posters-mfe/index.html | 2 +- .../country-posters/country-posters.js | 158 +++++----- public/doxa-maps/index.html | 2 +- public/doxa-maps/key-cities/key-cities.js | 178 ++++++------ public/doxa-maps/manifest.json | 24 +- .../my-upg-100-list/my-upg-100-list.js | 204 ++++++------- .../my-upg-100-list/poster-wall/index.html | 2 +- .../my-upg-100-list/upg-100-list/index.html | 2 +- .../my-upg-100-list/upg-100-mfe/index.html | 2 +- .../region-dashboards/index.html | 2 +- .../regions-dashboard/regions-dashboard.js | 132 ++++----- .../regions-dashboard/regions-list/index.html | 2 +- .../regions-dashboard/regions-mfe/index.html | 2 +- 41 files changed, 830 insertions(+), 450 deletions(-) diff --git a/internal/check-poster-card-fits.mjs b/internal/check-poster-card-fits.mjs index fe7b9b28e..e663e6feb 100644 --- a/internal/check-poster-card-fits.mjs +++ b/internal/check-poster-card-fits.mjs @@ -203,6 +203,8 @@ async function measure(width, lang) { const boxes = cards.map((c) => ({ c, b: c.getBoundingClientRect() })) let intrusions = 0, overflowing = 0, worst = 0, coveringPreview = 0, worstY = 0 let previewsPainted = 0, previewsBroken = 0, noPreview = 0, unnamedPreview = 0 + let notTaped = 0, worstTape = 0, selectsInRow = 0 + let headNotTaped = 0, worstHead = 0, mapCovered = 0, worstCover = 0 for (const { c, b } of boxes) { const row = c.querySelector('.pfs') if (!row) continue @@ -247,6 +249,69 @@ async function measure(width, lang) { worstY = Math.max(worstY, Math.round(ib.bottom - rb.top)) } } + // ★ A STICKER STRADDLES THE EDGE IT TAPES (Driver 2026-09-25, the sticker/banner brief). + // The card is a BANNER displaying a map; each sticker sits centred on a banner edge, part + // on the banner's own white space and part overhanging. Three things were each true while + // this gate was green, so all three are now asserted: + // 1. the row floated 26px BELOW the banner, touching nothing (the shipped state); + // 2. the row sat entirely below the edge, touching at one pixel; + // 3. the row straddled correctly but landed ON THE MAP — "offensive to the map". + // over-preview above catches only a row overlapping the from above. + const frame = c.querySelector('.main') + if (frame) { + const fb = frame.getBoundingClientRect() + // STRADDLE: some of the sticker on the banner, some off it. Requiring a near-even split + // would encode a ratio the Driver explicitly left open ("probably an equal or more + // overlap"); requiring BOTH sides to be non-trivial is the property actually stated. + const onBanner = Math.round(fb.bottom - rb.top) + const offBanner = Math.round(rb.bottom - fb.bottom) + if (onBanner < 4 || offBanner < 4) { + notTaped++ + const d = onBanner < 4 ? onBanner : -offBanner + worstTape = Math.abs(d) > Math.abs(worstTape) ? d : worstTape + } + } + // ★ NO NATIVE DROPDOWN IN THE DEFAULT ROW (same ruling). The Driver's reason is not + // cosmetic: "I don't want to add the dropdown for the card because it makes it hard for + // others to have dropdowns too" — a + // reappearing in .pfs is the regression this catches. + if (row.querySelector('select')) selectsInRow++ + // ★ AND THE HEADER STICKER STRADDLES ITS EDGE TOO. Same requirement, other end of the + // banner, and it was ALSO wrong while everything here was green: MEASURED 35px above the + // banner's top edge and -1px below, i.e. floating over the card touching the banner + // nowhere. Asserting only the footer would have left that in place indefinitely. + const headSticker = c.querySelector('.tape-h') + if (frame && headSticker) { + const hb = headSticker.getBoundingClientRect() + const fb2 = frame.getBoundingClientRect() + const onB = Math.round(hb.bottom - fb2.top) + const offB = Math.round(fb2.top - hb.top) + if (onB < 4 || offB < 4) { + headNotTaped++ + const d = onB < 4 ? onB : -offB + worstHead = Math.abs(d) > Math.abs(worstHead) ? d : worstHead + } + } + // ★ NOTHING TOUCHES THE MAP. The brief is unambiguous and is the reason the banner grew + // at all: "the preview banner to showcase a map should not have anything overlapping the + // map because the map needs to be fully visible." Measured on the first straddle attempt: + // header over the map by 6px, footer by 8px — a correct straddle onto a banner with too + // little white space reserved. THIS is the assertion that catches that, and neither the + // straddle checks above nor over-preview can: over-preview only sees a row whose top is + // above the image bottom, which is a different geometry from a sticker landing on it. + if (img) { + const ib3 = img.getBoundingClientRect() + if (ib3.height > 2) { + const byFoot = Math.round(ib3.bottom - rb.top) + if (byFoot > 0) { mapCovered++; worstCover = Math.max(worstCover, byFoot) } + if (headSticker) { + const hb2 = headSticker.getBoundingClientRect() + const byHead = Math.round(hb2.bottom - ib3.top) + if (byHead > 0) { mapCovered++; worstCover = Math.max(worstCover, byHead) } + } + } + } for (const o of boxes) { if (o.c === c) continue const ox = Math.min(rb.right, o.b.right) - Math.max(rb.left, o.b.left) @@ -295,7 +360,8 @@ async function measure(width, lang) { return { cards: cards.length, rows: cards.filter((c) => c.querySelector('.pfs')).length, overflowing, worst, intrusions, coveringPreview, worstY, previewsPainted, previewsBroken, noPreview, unnamedPreview, clipped: clipped.length, - controls: ctrls.length, tiny: tiny.length, perRow, cardW } + controls: ctrls.length, tiny: tiny.length, perRow, cardW, + notTaped, worstTape, selectsInRow, headNotTaped, worstHead, mapCovered, worstCover } })()`) await page.close() return { width, lang, hover, reach, ...out } @@ -315,6 +381,10 @@ for (const r of results) { + ` · overflow ${r.overflowing}${r.worst ? ` (${r.worst}px)` : ''}` + ` · neighbour ${r.intrusions}` + ` · over-preview ${r.coveringPreview}${r.worstY ? ` (${r.worstY}px)` : ''}` + + ` · off-frame ${r.notTaped}${r.worstTape ? ` (${r.worstTape > 0 ? '+' : ''}${r.worstTape}px)` : ''}` + + ` · selects ${r.selectsInRow}` + + ` · head-off ${r.headNotTaped}${r.worstHead ? ` (${r.worstHead > 0 ? '+' : ''}${r.worstHead}px)` : ''}` + + ` · map-covered ${r.mapCovered}${r.worstCover ? ` (${r.worstCover}px)` : ''}` + ` · previews ${r.previewsPainted} painted` + `${r.previewsBroken ? `/${r.previewsBroken} BROKEN` : ''}${r.noPreview ? `/${r.noPreview} none` : ''}` + ` · max ${r.perRow}/row @ ${r.cardW}px` @@ -362,6 +432,39 @@ for (const r of results) { fails.push(`${at}: ${r.previewsBroken} preview image(s) failed to load (naturalWidth 0) — ` + 'a broken preview is not something the geometry checks can see') } + // ★ THE OTHER EDGE OF over-preview. That assertion catches a row sitting too HIGH; this one + // catches it sitting too LOW, which is the state that actually shipped and that nothing here + // could see: MEASURED frame bottom 426, row top 452, and a full green run. + if (r.notTaped) { + fails.push(`${at}: ${r.notTaped} print row(s) do not STRADDLE the banner's bottom edge ` + + `(worst ${r.worstTape > 0 ? '+' : ''}${r.worstTape}px on one side; positive = too little ` + + 'on the banner, negative = too little overhanging). The footer sticker sits centred on ' + + 'that edge, part on the banner\'s white space and part hanging onto the page below — ' + + '"slight overlap over the banner, but not over the content of the banner". A row ' + + 'floating clear of the banner is as wrong as one covering the map.') + } + // ★ THE ONE THE DRIVER CARES ABOUT MOST. "The preview banner to showcase a map should not + // have anything overlapping the map because the map needs to be fully visible." + if (r.mapCovered) { + fails.push(`${at}: ${r.mapCovered} sticker(s) OVERLAP the map preview (worst ${r.worstCover}px). ` + + 'The banner\'s header and footer stickers straddle the banner\'s edges; the map inside is ' + + 'never to be touched — "if I put a piece of tape over the text it would be offensive to ' + + 'the map". Reserve more banner white space via --banner-pad-top/--banner-pad-bottom on ' + + '.main rather than moving the sticker: a sticker centred on the edge puts HALF its ' + + 'height on the banner, so the padding must exceed that half.') + } + if (r.headNotTaped) { + fails.push(`${at}: ${r.headNotTaped} header sticker(s) do not straddle the banner's top edge ` + + `(worst ${r.worstHead > 0 ? '+' : ''}${r.worstHead}px on one side). A sticker sits ON the ` + + 'edge it tapes, part on the banner and part overhanging — measured 35px above / -1px ' + + 'below before this was asserted, i.e. floating free of the banner entirely.') + } + if (r.selectsInRow) { + fails.push(`${at}: ${r.selectsInRow} native here also collides with the host page\'s own dropdowns.') + } // EVERY card falling back to the placeholder means the previews are gone. MEASURED by aborting // every previews/** request: the product degrades WELL — all 27 cards swap to a .ph placeholder // reading "poster not generated yet", with no broken-image icons and no left at all. That diff --git a/library/components/poster/PosterWall.vue b/library/components/poster/PosterWall.vue index 6ff7ac366..c746f1f6d 100644 --- a/library/components/poster/PosterWall.vue +++ b/library/components/poster/PosterWall.vue @@ -409,6 +409,31 @@ const prints = usePosterPrints({ const canPrintRows = computed(() => typeof props.records === 'function') const previewIsControl = computed(() => props.previewAction === 'print') +// ── CARD-DECK DISCLOSURE ──────────────────────────────────────────────────── +// Which card currently has its size row revealed, by item id. Holding an ID +// rather than a boolean-per-card means opening one CLOSES the others for free: +// two size rows open at once would overlap in the grid gap, and there is no +// reading of the Driver's request where two are wanted. +const deckOpenFor = ref(null) +function toggleDeck(id, ev) { + deckOpenFor.value = deckOpenFor.value === id ? null : id + // Tell the size row how far down to start: the print row's REAL height, which is + // one line in en and two in the long-label locales. Walking up from the clicked + // button to its own .pfs keeps this correct per card rather than per wall. + const pfs = ev?.currentTarget?.closest?.('.pfs') + if (pfs) pfs.parentElement?.style.setProperty('--pstocks-gap', `${pfs.offsetHeight}px`) +} + +// Choosing a size IS the print command — the reader already said "print the +// cards" by opening this row, so a second confirm step would be a click with +// nothing behind it. prints.stock reads pickedStock through a getter, so setting +// it here is what the deck prints at. +function printDeckAt(item, stockId) { + pickedStock.value = stockId + deckOpenFor.value = null + prints.printCardDeck(item) +} + function downloadPacket(item) { // Still the honest "not yet" when this wall has no records provider: NOT an // error — unbuilt, not broken. phase:'error' reads to a user as "something went @@ -479,12 +504,21 @@ function download(blob, name) { function dismissExport() { exportState.value = null } useShadowStyles( - '.wall{position:absolute;inset:0;overflow-y:auto;padding:22px 18px 44px;' + + // --wall-pad-top is read back by .sec-head::before to cover the scroll-padding strip + // above a pinned header (see there). Declared as a var so the two can never drift: + // typing 22px in both places is how that gap would silently re-open. + '.wall{--wall-pad-top:22px;position:absolute;inset:0;overflow-y:auto;' + + 'padding:var(--wall-pad-top) 18px 44px;' + "background:#f6f7f9;color:#0f1419;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif}" + '.wall.is-dark{background:#0a1410;color:#e5e7eb}' + // Embedded: normal flow, no pane background, no pane padding. inset:auto matters as much // as position:static — leaving inset set keeps the box stretched even once static. - '.wall--embedded{position:static;inset:auto;overflow-y:visible;padding:0;background:transparent}' + + // ⚠ --wall-pad-top MUST go to 0 here too, not just the padding. Embedded, the wall is not + // the scroll container — the HOST page is — so there is no scroll-padding strip to cover, + // and a 22px cover bar would paint the wall's background over the host's own content + // above each header. The var and the padding are one decision, set in one place. + '.wall--embedded{--wall-pad-top:0px;position:static;inset:auto;overflow-y:visible;' + + 'padding:0;background:transparent}' + '.wall--embedded.is-dark{background:transparent}' + '.wall-h{margin:0 0 6px;font-size:19px;font-weight:700;text-align:center}' + '.wall-tag{margin:0 auto 12px;width:fit-content;padding:3px 11px;border-radius:999px;' + @@ -513,8 +547,13 @@ useShadowStyles( 'border-radius:6px;background:#fff;color:inherit;cursor:pointer}' + '.is-dark .perrow__label{color:#9fb3a6}' + '.is-dark .perrow__select{background:#132019;border-color:rgba(110,231,183,.20)}' + - '.card{position:relative;background:#fff;border:1px solid #e3e6ea;border-radius:3px;' + - 'padding:24px 12px 26px;box-shadow:0 1px 3px rgba(0,0,0,.10),0 6px 18px rgba(0,0,0,.07);' + + // --card-pad-top IS the banner's top edge in card coordinates, and .tape-h reads it to + // centre itself there. Declared as a var so the two cannot drift apart the way a second + // hand-typed 24px would. + '.card{--card-pad-top:24px;position:relative;background:#fff;border:1px solid #e3e6ea;' + + 'border-radius:3px;' + + 'padding:var(--card-pad-top) 12px 26px;' + + 'box-shadow:0 1px 3px rgba(0,0,0,.10),0 6px 18px rgba(0,0,0,.07);' + 'transition:transform .16s ease,box-shadow .16s ease}' + // ★ THE BLUE OUTLINE IS A HOVER STATE, NOT A MISSING STATIC BORDER. Several reports have asked // for "the blue outline around the poster" to be restored, and MEASURED 2026-09-24 there has @@ -546,7 +585,16 @@ useShadowStyles( 'font-size:10.5px;font-weight:600;letter-spacing:.02em;cursor:pointer;' + 'border:1px solid rgba(0,0,0,.08);background:#eef2f6;color:#23303c}' + '.is-dark .tape{background:#1d2b23;color:#cfe9d9;border-color:rgba(255,255,255,.10)}' + - '.tape-h{top:-11px;width:82%;padding:4px 8px;border-radius:999px;' + + // ★ THE HEADER STICKER STRADDLES THE BANNER'S TOP EDGE, for the same reason the footer one + // straddles the bottom. MEASURED before: 35px above the banner's top edge, -1px below — it + // floated over the card, touching the banner nowhere. The Driver reads the rounded top + // sticker as the one that "looks beautiful and is really well spaced", so this keeps its + // size and shape exactly and only corrects WHERE it sits: centred on the edge it is taping. + // --banner-pad-top reserves the banner surface it lands on, so it never reaches the map. + // .tape-h is positioned against .card (it is a direct child), and the banner's top is the + // card's padding-top — hence top:24px, read from the card rather than restated. + '.tape-h{top:var(--card-pad-top,24px);transform:translate(-50%,-50%);' + + 'width:82%;padding:4px 8px;border-radius:999px;' + 'display:flex;align-items:center;justify-content:center;gap:5px}' + '.tape-h:hover{background:#e2e8ee}' + '.hcount{font-weight:800;font-variant-numeric:tabular-nums}' + @@ -570,19 +618,30 @@ useShadowStyles( // a vertical one the driver saw. Anchoring at top:100% pins it just under the card, so the // second line grows DOWN into empty space. MEASURED: 92px between the preview's bottom and // the next card, of which the single-line row used 35 — 51px spare. - // ★ THE FOOTER IS TAPE TOO (Driver): "see how the header for show on map is inline on the top of - // the poster box? but the footer print options are not inline on the border or outline of the - // box... we want the footer to imitate the header, by being inline like a piece of tape". - // The header does that with .tape-h{top:-11px}: a fixed offset that works because its height is - // fixed. The footer's height is NOT fixed -- it holds two to four controls plus a select, and it - // grows with translated labels -- so a fixed margin-top:-14px left it hanging BELOW the edge - // instead of sitting on it, and any height change moved it again. - // translate(-50%,-50%) at top:100% centres it on the bottom border at ANY height, which is the - // same "piece of tape" the header achieves and is self-correcting when a locale makes it taller. - // That also answers the rest of the Driver's rule -- "if the box is too tall to centre we just - // let it take up an overlap of the poster box, but not so far that it overlaps the poster blue - // outline": half the tape overlaps the card and half hangs below, so the overlap grows only half - // as fast as the tape does and never reaches the preview. + // ★ THE FOOTER STICKER STRADDLES THE BANNER'S BOTTOM EDGE (Driver): "see how the header for + // show on map is inline on the top of the poster box? but the footer print options are not + // inline on the border or outline of the box... we want the footer to imitate the header, by + // being inline like a piece of tape". + // + // top:100% + translate(-50%,-50%) centres it on that edge at ANY height, which matters because + // the row's height is NOT fixed: it holds two to four controls and grows with translated + // labels (MEASURED 68px in en/ro, 54px in zh). A fixed margin-top left it hanging below the + // edge and moved again whenever the height changed. Centring is self-correcting, so no + // constant here has to track the locale. + // + // ⚠ top:100% IS THE BANNER'S BOTTOM EDGE ONLY BECAUSE .pfs LIVES INSIDE .main. As a SIBLING + // of the banner it resolved against the CARD instead — MEASURED card bottom 453 vs banner + // bottom 426, so the row centred on a line 27px past the banner, touching it nowhere. The + // re-parenting and the centring are one fix; neither works alone. + // + // Half the sticker lands on the banner, so the banner must reserve white space for it or the + // sticker sits on the map: that is --banner-pad-bottom on .main, and the gate asserts the + // result (map-covered 0) rather than trusting the arithmetic. + // + // ⚠ DO NOT ADD margin-top HERE. `margin:0` in the shorthand below RESETS any margin-top + // declared above it — a shorthand always beats an earlier longhand in the same block. A + // margin-top:-1px sat here computing to 0px while I believed it was doing the positioning; + // a mutation test (set it to 26px, expect the row to move) proved it inert. '.pfs{position:absolute;top:100%;left:50%;transform:translate(-50%,-50%);' + // ⚠ min-width:0 ON THE FIELDSET ITSELF. A
gets `min-width:min-content` from the // UA stylesheet, which OVERRIDES width and max-width — measured: computed width 431px @@ -599,13 +658,30 @@ useShadowStyles( // of 108 controls clipped. Fitting a row by hiding what its buttons say is not fitting it. // flex-basis:auto + wrap lets each control take the width its LABEL needs and flow to a // second line, which there is room for now that the row grows downward. - '.pfs > .pbtn{flex:0 1 auto;min-width:0;white-space:nowrap}' + - // ⚠ THE sizes to - // its WIDEST OPTION and `flex:0 1 auto` keeps that as its basis. flex-basis:0 makes it share - // the row like the buttons do; a cap keeps it from dominating the three text controls. - '.pfs > .pstock{flex:0 1 auto;min-width:0;max-width:100%}' + + // ⚠ nowrap HERE IS A CLIPPING BUG IN THE NARROW x LONG-LABEL CORNER, and it only appeared once + // the per-row default became 2: at 390px that halves the card to ~170px, and Romanian's + // "Pachet de cartonașe" (133px) plus two more controls cannot share it on one line. + // MEASURED after the merge: 54 of 81 controls clipped at ro/390px, en and zh clean — long + // labels and a narrow card, exactly the pair each change was individually fine with. + // The standing ruling on this wall is that a label too long for its box WRAPS; nowrap forced + // the browser to clip instead. Wrapping costs a second line in the one corner that needs it. + '.pfs > .pbtn{flex:0 1 auto;min-width:0;white-space:normal;overflow-wrap:break-word;' + + 'text-align:center;line-height:1.2}' + '.is-dark .pfs{background:#132019;border-color:rgba(110,231,183,.24)}' + + // ── THE REVEALED SIZE ROW ──────────────────────────────────────────────────── + // Sits directly under the print row, still hanging into the grid's 88px row-gap. + // ⚠ THE OFFSET IS MEASURED, NOT GUESSED. Both rows share .main as containing block, so + // "under .pfs" means top:100% + .pfs's own height — and that height is NOT a constant: + // the row wraps to a second line in long-label locales (ro/ru measured at 2 lines). + // A hard-coded 20px would tuck the size row under the print row in exactly those + // locales. toggleDeck() reads the live offsetHeight and writes it to --pstocks-gap. + // Hangs below the footer sticker, which is itself half-overhanging the banner edge — so the + // offset starts at HALF the print row's height, not all of it. + '.pstocks{top:100%;margin-top:calc(var(--pstocks-gap,20px) / 2);width:92%;max-width:92%;' + + 'box-sizing:border-box;padding:3px;border-radius:2px;background:#fff;' + + 'display:flex;flex-wrap:wrap;gap:3px;justify-content:center;' + + 'box-shadow:0 1px 4px rgba(0,0,0,.10);cursor:default}' + + '.is-dark .pstocks{background:#132019;border-color:rgba(110,231,183,.24)}' + '.pfs > legend{margin:0 auto;padding:0 5px;font-size:8px;text-transform:uppercase;' + 'letter-spacing:.6px;font-weight:700;color:#8a93a0}' + '.is-dark .pfs > legend{color:#7f9c8c}' + @@ -616,13 +692,27 @@ useShadowStyles( 'border-radius:2px;background:transparent;transition:background .15s}' + '.pbtn:hover{color:#10b981}' + '.pbtn:hover::after{background:#10b981}' + - // Stock picker (cardStocks). Sized a shade smaller than .pbtn so it reads as a - // SETTING for the neighbouring Card Deck button rather than a fourth action, and - // `flex:0 1 auto` keeps it from taking an equal share of the row the way .pbtn does. - '.pstock{flex:0 1 auto;max-width:100%;border:1px solid #d8dde3;border-radius:3px;' + + // The deck button is a DISCLOSURE, so it says so while open — a caret that flips, + // plus the green the row already uses for "active", so the reader can see which + // card's sizes are showing without reading aria-expanded. + '.pbtn-deck[aria-expanded="true"]{color:#10b981}' + + '.pbtn-deck::before{content:"\\25B8";display:inline-block;margin-right:3px;font-size:8px;' + + 'transition:transform .15s}' + + '.pbtn-deck[aria-expanded="true"]::before{transform:rotate(90deg)}' + + // Size choices. These are BUTTONS, not