poster cards: a blue outline on hover, and a gate that can see it
Some checks are pending
build-gate / build (push) Waiting to run

The card asks for the Pinterest/Instagram behaviour: hovering a poster outlines
it in blue so a reader knows which one their click is about to act on.

★ THIS IS NOT A RESTORED BORDER. Several reports asked to bring back "the blue
outline around the poster", and MEASURED 2026-09-24 there has never been one:
all 136 elements painting a frame on this wall compute outline:none, and every
border is grey (.card #e3e6ea, .main #dfe3e7). Nothing regressed -- the
affordance was never built. The reports kept recurring because a hover state is
invisible to a screenshot, so each check confirmed "no outline" and concluded
the sighting was mistaken.

outline, NOT border, and that is load-bearing. An outline paints OUTSIDE the
box and takes no space, so it cannot move anything. The rest of this wall's gate
asserts where the print row sits at five widths; a hover that grew a border
would re-lay-out the card and push that row, so a cosmetic state could break a
gate about print controls.

:focus-within carries it to the keyboard, and @media (hover:none) drops the
whole state on touch -- with no pointer it would either never appear or stick
after a tap, leaving a card looking permanently selected.

THE GATE NOW MOVES THE POINTER. Everything it measured before was of a loaded,
untouched page, which is precisely why this affordance could go unbuilt while
being repeatedly requested.

⚠ AND ONE OF MY OWN ASSERTIONS WAS DECORATION UNTIL I MUTATION-TESTED IT. I
first asserted the card's OUTER width is unchanged by hover. It can't change:
the card is a grid item, so the track fixes its width and a border grown on
hover eats inward. MEASURED with border-width:4px on hover -- outer 224->224px,
unfalsifiable, while the CONTENT box went 222->216px. It now asserts clientWidth,
which is what actually repositions the print row.

MUTATION-TESTED: remove the outline -> FAIL at all four pointer widths (exit 1);
grow a border alongside it -> FAIL naming the content-box shift (exit 1);
restore -> PASS (exit 0), reporting "hover 2px rgb(47, 111, 219)" at 768-1920
and "no outline (touch, correct)" at 390.

The print/open controls the card also asks for are already there and were
verified clickable in the same pass: Prayer List, Card Deck, the stock select
and Poster Map, all four with full labels and real handlers, 0 clipped.
This commit is contained in:
doxa-maps-88655c70 2026-09-24 23:15:53 +00:00
parent 31b934bdf5
commit a7f57c117d
8 changed files with 117 additions and 12 deletions

View file

@ -49,6 +49,55 @@ async function measure(width) {
const page = await browser.newPage({ viewport: { width, height: 1000 }, isMobile: mobile, hasTouch: mobile })
await page.goto(LEAF, { waitUntil: 'load', timeout: 45000 })
await page.waitForTimeout(10000)
// ★ HOVER IS INVISIBLE TO A STATIC PROBE, which is exactly how the blue outline stayed
// unbuilt for so long. Repeated reports asked to "restore the blue outline around the poster";
// measuring the loaded page found `outline:none` on all 136 framed elements and concluded it had
// never existed — true, and useless, because the affordance being asked for only exists WHILE
// THE POINTER IS OVER THE CARD. The pointer has to actually move.
//
// ⚠ AND IT MUST NOT MOVE ANYTHING. The whole rest of this gate asserts where the print row sits.
// A hover that grew a border would re-lay-out the card and push that row, so a cosmetic state
// could break a gate about print controls. Outline paints outside the box; this asserts the box
// WIDTH is identical hovered and not, which is the property that keeps the two independent.
let hover = null
try {
const target = await page.evaluate(`(() => {
const all = (${WALK})(document)
const c = all.filter((e) => (e.className||'').toString().split(' ').includes('card'))
.filter((e) => { const b = e.getBoundingClientRect(); return b.width > 2 && b.height > 2 })[0]
if (!c) return null
c.scrollIntoView({ block: 'center' })
const r = c.getBoundingClientRect()
const cs = getComputedStyle(c)
// ⚠ MEASURE clientWidth, NOT THE BORDER BOX. The card is a GRID ITEM, so its outer width is
// fixed by the track (minmax(210px,1fr)) and CANNOT change no matter what the hover rule
// does — a border grown on hover eats INWARD and leaves getBoundingClientRect().width
// identical. Asserting on the outer width is therefore unfalsifiable here, which is a
// decorative assertion, not a real one. MEASURED: growing border-width to 4px on hover left
// the outer width at 212px and moved nothing this gate was watching. clientWidth is the
// CONTENT box — it shrinks when the border grows, and it is what actually repositions the
// print row, which is the property worth protecting.
return { x: r.x + r.width / 2, y: r.y + r.height / 2, w: Math.round(r.width),
inner: c.clientWidth, outlineStyle: cs.outlineStyle }
})()`)
if (target) {
await page.mouse.move(target.x, target.y)
await page.waitForTimeout(500)
const hovered = await page.evaluate(`(() => {
const all = (${WALK})(document)
const c = all.filter((e) => (e.className||'').toString().split(' ').includes('card'))
.filter((e) => { const b = e.getBoundingClientRect(); return b.width > 2 && b.height > 2 })[0]
if (!c) return null
const cs = getComputedStyle(c)
return { outlineStyle: cs.outlineStyle, outlineColor: cs.outlineColor,
outlineWidth: cs.outlineWidth, w: Math.round(c.getBoundingClientRect().width),
inner: c.clientWidth }
})()`)
if (hovered) hover = { before: target, after: hovered }
}
} catch (_) { /* hover is an enhancement; never let it break the geometry run */ }
const out = await page.evaluate(`(() => {
const all = (${WALK})(document)
const vis = (e) => { const b = e.getBoundingClientRect(); return b.width > 2 && b.height > 2 }
@ -115,7 +164,7 @@ async function measure(width) {
controls: ctrls.length, tiny: tiny.length }
})()`)
await page.close()
return { width, ...out }
return { width, hover, ...out }
}
const results = []
@ -134,7 +183,12 @@ for (const r of results) {
+ ` · over-preview ${r.coveringPreview}${r.worstY ? ` (${r.worstY}px)` : ''}`
+ ` · previews ${r.previewsPainted} painted`
+ `${r.previewsBroken ? `/${r.previewsBroken} BROKEN` : ''}${r.noPreview ? `/${r.noPreview} none` : ''}`
+ ` · ${r.controls} control(s), ${r.tiny} too small, ${r.clipped} clipped`)
+ ` · ${r.controls} control(s), ${r.tiny} too small, ${r.clipped} clipped`
+ (r.hover
? ` · hover ${r.hover.after.outlineStyle === 'none'
? 'no outline' + (r.width < 500 ? ' (touch, correct)' : '')
: `${r.hover.after.outlineWidth} ${r.hover.after.outlineColor}`}`
: ''))
}
const fails = []
@ -185,6 +239,34 @@ for (const r of results) {
+ `(top sits ${r.worstY}px above the image's bottom edge) — growing the row taller to fit it `
+ 'horizontally is not a fix')
}
// HOVER: the card must answer the pointer, and must not move when it does.
// Only asserted where a pointer exists — a touch viewport has `@media (hover:none)` suppressing
// the state deliberately, so demanding an outline there would fail the correct behaviour.
if (r.hover) {
const { before, after } = r.hover
const pointer = r.width >= 500
if (pointer) {
if (after.outlineStyle === 'none') {
fails.push(`${at}: hovering a card paints NO outline — the card does not answer the `
+ 'pointer, so a reader cannot tell which poster their click is about to act on')
} else if (before.outlineStyle !== 'none') {
fails.push(`${at}: the card carries an outline even when NOT hovered `
+ `(${before.outlineStyle}) — the outline is meant to be the hover affordance, not a `
+ 'permanent frame')
}
if (after.w !== before.w || after.inner !== before.inner) {
fails.push(`${at}: hovering RESIZED the card `
+ `(outer ${before.w}->${after.w}px, content ${before.inner}->${after.inner}px). The `
+ 'hover state must not re-lay-out anything: every print-row assertion above is about '
+ 'where that row sits, and a cosmetic hover that moves the box can break them. Use '
+ 'outline (painted OUTSIDE the box and taking no space), never a border change.')
}
} else if (after.outlineStyle !== 'none') {
fails.push(`${at}: a TOUCH viewport painted a hover outline (${after.outlineStyle}) — with `
+ 'no pointer this either never appears or sticks after a tap, leaving a card looking '
+ 'permanently selected')
}
}
}
if (fails.length) {

View file

@ -477,7 +477,30 @@ useShadowStyles(
'.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);' +
'transition:transform .16s ease,box-shadow .16s ease}' +
'.card:hover{transform:translateY(-3px);box-shadow:0 3px 8px rgba(0,0,0,.14),0 12px 28px rgba(0,0,0,.10)}' +
// ★ 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
// never been one: all 136 elements painting a frame on this wall compute `outline:none`, and
// every border is grey (.card #e3e6ea, .main #dfe3e7). Nothing regressed — the affordance was
// never built. Asked for as Pinterest/Instagram do it: the card answers the pointer.
//
// ⚠ `outline`, NOT `border`, and this is the load-bearing choice. An outline is painted OUTSIDE
// the box and takes no space, so it cannot move anything. Growing the border on hover would
// re-lay-out the card by 2px and push the print row — whose geometry is asserted at five widths
// by check:poster-card-fits (0 overflow / 0 over-preview / 0 neighbour intrusions) — meaning a
// cosmetic hover would break a gate about print controls. outline-offset keeps it clear of the
// rounded corner instead of cutting across it.
//
// ⚠ :focus-visible CARRIES IT TO THE KEYBOARD. A hover-only affordance tells a mouse user which
// card they are about to act on and tells a keyboard user nothing, on a card whose buttons this
// same wall already had to be fixed to name (791/791 accessible names).
'.card:hover,.card:focus-within{transform:translateY(-3px);' +
'box-shadow:0 3px 8px rgba(0,0,0,.14),0 12px 28px rgba(0,0,0,.10);' +
'outline:2px solid #2f6fdb;outline-offset:2px}' +
'.is-dark .card:hover,.is-dark .card:focus-within{outline-color:#5aa0ff}' +
// ⚠ A TOUCH READER NEVER HOVERS. On a phone the lift and the outline would either never appear
// or, worse, stick after a tap and leave a card looking selected. The print row on this wall is
// always visible, so touch loses no affordance by dropping the hover state entirely.
'@media (hover:none){.card:hover,.card:focus-within{transform:none;outline:none}}' +
'.is-dark .card{background:#132019;border-color:rgba(110,231,183,.20)}' +
// box-sizing is NOT inherited into a shadow root from the host page's reset.
'.tape{box-sizing:border-box;position:absolute;left:50%;transform:translateX(-50%);z-index:2;' +

File diff suppressed because one or more lines are too long

View file

@ -681,7 +681,7 @@
"bundle": "my-upg-100-list",
"scriptUrl": "./my-upg-100-list/my-upg-100-list.js",
"output": "doxa-maps/my-upg-100-list/my-upg-100-list.js",
"integrity": "sha384-p29J0vBYB023wjm27/6NkyLanFQot5B0C48ZZnnyxC9yQTGXva9kRPfjdJeUOj6y",
"integrity": "sha384-NlPYE8Ka9bbUPBeUKZ8tppvBrvF9xdMXZlHtaUym05SxsppfTes/YtnAbr1wq4es",
"elements": [
"my-upg-100-list-map"
],
@ -701,7 +701,7 @@
"scriptUrl": "./my-upg-100-list/my-upg-100-list.js",
"embed": {
"iframe": "<iframe src=\"./my-upg-100-list/poster-wall/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=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-p29J0vBYB023wjm27/6NkyLanFQot5B0C48ZZnnyxC9yQTGXva9kRPfjdJeUOj6y\" crossorigin=\"anonymous\"></script>\n<my-upg-100-list-map profile-config='{\"profile\":\"poster-wall\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-NlPYE8Ka9bbUPBeUKZ8tppvBrvF9xdMXZlHtaUym05SxsppfTes/YtnAbr1wq4es\" crossorigin=\"anonymous\"></script>\n<my-upg-100-list-map profile-config='{\"profile\":\"poster-wall\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
}
},
{
@ -715,7 +715,7 @@
"scriptUrl": "./my-upg-100-list/my-upg-100-list.js",
"embed": {
"iframe": "<iframe src=\"./my-upg-100-list/upg-100-list/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=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-p29J0vBYB023wjm27/6NkyLanFQot5B0C48ZZnnyxC9yQTGXva9kRPfjdJeUOj6y\" crossorigin=\"anonymous\"></script>\n<my-upg-100-list-map profile-config='{\"profile\":\"upg-100-list\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-NlPYE8Ka9bbUPBeUKZ8tppvBrvF9xdMXZlHtaUym05SxsppfTes/YtnAbr1wq4es\" crossorigin=\"anonymous\"></script>\n<my-upg-100-list-map profile-config='{\"profile\":\"upg-100-list\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
}
},
{
@ -729,7 +729,7 @@
"scriptUrl": "./my-upg-100-list/my-upg-100-list.js",
"embed": {
"iframe": "<iframe src=\"./my-upg-100-list/upg-100-mfe/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=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-p29J0vBYB023wjm27/6NkyLanFQot5B0C48ZZnnyxC9yQTGXva9kRPfjdJeUOj6y\" crossorigin=\"anonymous\"></script>\n<my-upg-100-list-map profile-config='{\"profile\":\"upg-100-mfe\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-NlPYE8Ka9bbUPBeUKZ8tppvBrvF9xdMXZlHtaUym05SxsppfTes/YtnAbr1wq4es\" crossorigin=\"anonymous\"></script>\n<my-upg-100-list-map profile-config='{\"profile\":\"upg-100-mfe\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
}
}
]

File diff suppressed because one or more lines are too long

View file

@ -26,7 +26,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <my-upg-100-list-map> custom element. -->
<script src="../my-upg-100-list.js" integrity="sha384-p29J0vBYB023wjm27/6NkyLanFQot5B0C48ZZnnyxC9yQTGXva9kRPfjdJeUOj6y" crossorigin="anonymous"></script>
<script src="../my-upg-100-list.js" integrity="sha384-NlPYE8Ka9bbUPBeUKZ8tppvBrvF9xdMXZlHtaUym05SxsppfTes/YtnAbr1wq4es" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -29,7 +29,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <my-upg-100-list-map> custom element. -->
<script src="../my-upg-100-list.js" integrity="sha384-p29J0vBYB023wjm27/6NkyLanFQot5B0C48ZZnnyxC9yQTGXva9kRPfjdJeUOj6y" crossorigin="anonymous"></script>
<script src="../my-upg-100-list.js" integrity="sha384-NlPYE8Ka9bbUPBeUKZ8tppvBrvF9xdMXZlHtaUym05SxsppfTes/YtnAbr1wq4es" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -40,7 +40,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <my-upg-100-list-map> custom element. -->
<script src="../my-upg-100-list.js" integrity="sha384-p29J0vBYB023wjm27/6NkyLanFQot5B0C48ZZnnyxC9yQTGXva9kRPfjdJeUOj6y" crossorigin="anonymous"></script>
<script src="../my-upg-100-list.js" integrity="sha384-NlPYE8Ka9bbUPBeUKZ8tppvBrvF9xdMXZlHtaUym05SxsppfTes/YtnAbr1wq4es" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order: