The pin-hint pill: two lines, the round ☰'s height and row, and still while the pane scrolls

- Two lines, each its own string, in a new catalogue area only PinHint names (line1 / line2 x 11,
  taken from today's one-sentence text at each locale's own separator; that text is untouched).
- 44px tall like the ☰, 12px text, a smaller bob. Capped to the width left between two ☰ footprints
  (--map-fab-beside), so it is centred with equal room and never reaches the ☰; with no ☰ it is
  simply centred.
- In the ☰'s row: while the layout shows the ☰, the base is the ☰'s own 12px bottom, and the clamp
  lifts it to 8px above the legend sheet, the gap the layout keeps for the ☰.
- It no longer reads --map-fab-clearance, which now carries the ☰'s scroll-tracked lift: inside the
  map, beside the legend, the pill stays over it by itself, and positioned from that lift it moved
  against the legend on every scroll.
This commit is contained in:
Doxa Maps 2026-09-30 09:47:21 +00:00
parent 4c7123f68e
commit 55ca562ed5
12 changed files with 85 additions and 17 deletions

View file

@ -60,7 +60,11 @@ import { useShadowStyles } from '../../composables/useShadowStyles.js'
// This call was mine, added in 9ba6ad7a. Same shape of fix: use the host's translations when it
// has them, the English literal otherwise. `te` becomes a "no, I don't have it" so every caller
// falls through to its own default.
const PINHINT_FALLBACK = { 'messages.pinHint.tapDot': 'Tap a dot to see who still needs prayer' }
const PINHINT_FALLBACK = {
'messages.pinHint.tapDot': 'Tap a dot to see who still needs prayer',
'pinHint.line1': 'Tap a coloured dot',
'pinHint.line2': 'someone still needs prayer',
}
// C4: the guard is library/composables/useOptionalI18n now. PINHINT_FALLBACK is passed in unchanged
// and `te` still answers false on a bare host, so every caller keeps its own default.
const { t, te } = useOptionalI18n('', { fallback: (key) => PINHINT_FALLBACK[key] })
@ -87,11 +91,17 @@ const isDark = computed(() => uiStore?.theme === 'dark')
// English, and therefore invisible to everyone who could.
// The key falls back to the English sentence when a locale lacks it, so a missing translation
// degrades to today's behaviour rather than to "messages.pinHint.tapDot".
const label = computed(() => {
// ★ TWO LINES, EACH ITS OWN STRING, in a catalogue area only this component names (so it ships only
// where this component does). Splitting the one-sentence string at its separator would have worked
// for today's eleven translations, but they do not share one (a dash, a colon, a double dash), and a
// future translation with an extra one would split in the wrong place with nothing to notice it; two
// keys are explicit per locale, and the parity gate guards both. A profile's own label still wins,
// on one line.
const lines = computed(() => {
const override = profileConfig?.value?.pinHintLabel
if (override) return override
const EN = 'Tap a coloured dot — someone still needs prayer'
try { return te('messages.pinHint.tapDot') ? t('messages.pinHint.tapDot') : EN } catch (_) { return EN }
if (override) return [override]
const one = (k) => { try { return te(k) ? t(k) : PINHINT_FALLBACK[k] } catch (_) { return PINHINT_FALLBACK[k] } }
return [one('pinHint.line1'), one('pinHint.line2')]
})
const visible = ref(false)
@ -268,9 +278,14 @@ function creditFloor(pill) {
} catch (_) { return Infinity }
}
// Whether the layout's round ☰ is shown (it publishes --map-fab-beside only then): the pill takes its row.
const withFab = ref(false)
function clampIntoView() {
const el = hintEl.value
if (!el || !onScreen.value || !paneActive.value) return
// The row decides the base the clamp measures from, so settle it first and measure after the re-render.
const fab = !!getComputedStyle(el).getPropertyValue('--map-fab-beside').trim()
if (fab !== withFab.value) { withFab.value = fab; nextTick(clampIntoView); return }
try {
// ★ ADJUST `bottom`, NEVER `transform`. My first attempt set el.style.transform and cleared it
// between measurements — both wrong here, and the diagnosis took two failed builds:
@ -462,21 +477,30 @@ useShadowStyles(`
drawer. 12 keeps it above the map's own base layers (the canvas, the scroll edges at 2, the control
column at 10) and below all of that chrome. It is never meant to cover anything else: its placement
already keeps it above the credit strip and the legend sheet (clampIntoView).
★ AND ABOVE THE ROUND MENU BUTTON. Wide, the pill reaches the bottom-left corner, where the gallery's
menu button sits (measured at 390: 800px² of the pill's dot end under it). The layout publishes that
button's height as --map-fab-clearance ONLY while it is rendered, so the bottom takes the larger of
the credit clearance and the button's; with no button (desktop, a partner page) nothing changes. */
★ IN THE ROW OF THE ROUND MENU BUTTON, never touching it. While the gallery's round ☰ is shown, the
layout publishes its footprint (--map-fab-beside: where a sibling may start). The pill is capped to
the width left between TWO such footprints, so it stays centred with equal room on both sides and
cannot reach the ☰ in either direction, and it sits in the ☰'s row (the pin-hint--fab rule below:
the ☰'s own 12px bottom; the clamp then lifts both kinds of control to 8px above the legend sheet,
the gap the layout keeps for the ☰). With no ☰ (desktop, a partner page) it is simply centred.
★ IT NEVER READS THE ☰'s LIFT. The layout moves the ☰ by a scroll-tracked lift so it stays over
the legend while the pane scrolls; this pill lives INSIDE the map, beside the legend, so it stays
over it by itself. Positioned from that lift, it moved against the legend on every scroll. */
.pin-hint { position: absolute; left: 50%;
bottom: calc(max(var(--pin-hint-attrib-clear, 20px) + 12px, var(--map-fab-clearance, 0px) + 8px) + var(--pin-hint-lift, 0px)); transform: translateX(-50%);
bottom: calc(max(var(--pin-hint-attrib-clear, 20px) + 12px, 8px) + var(--pin-hint-lift, 0px)); transform: translateX(-50%);
z-index: 12; pointer-events: none;
width: max-content; max-width: calc(100% - 32px); box-sizing: border-box;
width: max-content; max-width: calc(100% - 2 * var(--map-fab-beside, 16px)); box-sizing: border-box;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
animation: pinhint-in .5s ease both; }
.pin-hint-pill { pointer-events: auto; display: inline-flex; align-items: center; gap: 9px; text-align: center;
padding: 9px 14px 9px 11px; border-radius: 999px;
background: rgba(23, 29, 38, .92); color: #fff; font-size: 13.5px; font-weight: 600;
.pin-hint.pin-hint--fab { bottom: calc(12px + env(safe-area-inset-bottom, 0px) + var(--pin-hint-lift, 0px)); }
/* 44px, the round ☰'s height, with its two lines one above the other. */
.pin-hint-pill { pointer-events: auto; display: inline-flex; align-items: center; gap: 8px; text-align: center;
min-height: 44px; box-sizing: border-box; padding: 4px 12px 4px 10px; border-radius: 22px;
background: rgba(23, 29, 38, .92); color: #fff; font-size: 12px; line-height: 1.25; font-weight: 600;
letter-spacing: .01em; box-shadow: 0 4px 18px rgba(0,0,0,.30);
animation: pinhint-bob 2.6s ease-in-out 1s infinite; }
.pin-hint-text { display: flex; flex-direction: column; align-items: center; min-width: 0; }
.pin-hint-line { display: block; }
.pin-hint-dot { position: relative; width: 12px; height: 12px; flex: none; }
.pin-hint-dot::before { content: ''; position: absolute; inset: 0; border-radius: 50%;
background: #e05252; box-shadow: 0 0 0 2px rgba(224,82,82,.35); }
@ -497,7 +521,7 @@ useShadowStyles(`
.pin-hint.dark .pin-hint-x { color: #5a6470; }
.pin-hint.dark .pin-hint-x:hover { color: #1b212b; }
@keyframes pinhint-pulse { 0% { transform: scale(.6); opacity: .9 } 70% { transform: scale(1.5); opacity: 0 } 100% { opacity: 0 } }
@keyframes pinhint-bob { 0%,100% { transform: translateY(0) } 50% { transform: translateY(-5px) } }
@keyframes pinhint-bob { 0%,100% { transform: translateY(0) } 50% { transform: translateY(-3px) } }
@keyframes pinhint-in { from { opacity: 0; transform: translateX(-50%) translateY(10px) } to { opacity: 1; transform: translateX(-50%) translateY(0) } }
@media (prefers-reduced-motion: reduce) {
.pin-hint, .pin-hint-pill, .pin-hint-dot::after { animation: none !important; }
@ -506,10 +530,10 @@ useShadowStyles(`
</script>
<template>
<div v-if="visible" v-show="onScreen && paneActive" ref="hintEl" class="pin-hint" :class="{ dark: isDark }" role="status">
<div v-if="visible" v-show="onScreen && paneActive" ref="hintEl" class="pin-hint" :class="{ dark: isDark, 'pin-hint--fab': withFab }" role="status">
<span class="pin-hint-pill">
<span class="pin-hint-dot" aria-hidden="true"></span>
{{ label }}
<span class="pin-hint-text"><span v-for="(l, i) in lines" :key="i" class="pin-hint-line">{{ l }}</span></span>
<button class="pin-hint-x" type="button" :aria-label="T('aria.dismissHint', 'Dismiss hint')" @click="dismiss">×</button>
</span>
</div>

View file

@ -0,0 +1,4 @@
{
"line1": "اضغط على نقطة ملوّنة",
"line2": "لا يزال هناك من يحتاج إلى الصلاة"
}

View file

@ -0,0 +1,4 @@
{
"line1": "Tippe auf einen farbigen Punkt",
"line2": "jemand braucht noch Gebet"
}

View file

@ -0,0 +1,4 @@
{
"line1": "Tap a coloured dot",
"line2": "someone still needs prayer"
}

View file

@ -0,0 +1,4 @@
{
"line1": "Toca un punto de color",
"line2": "alguien todavía necesita oración"
}

View file

@ -0,0 +1,4 @@
{
"line1": "Touchez un point coloré",
"line2": "quelqu’un a encore besoin de prière"
}

View file

@ -0,0 +1,4 @@
{
"line1": "किसी रंगीन बिंदु पर टैप करें",
"line2": "किसी को अभी भी प्रार्थना की आवश्यकता है"
}

View file

@ -0,0 +1,4 @@
{
"line1": "Tocca un punto colorato",
"line2": "qualcuno ha ancora bisogno di preghiera"
}

View file

@ -0,0 +1,4 @@
{
"line1": "Toque num ponto colorido",
"line2": "alguém ainda precisa de oração"
}

View file

@ -0,0 +1,4 @@
{
"line1": "Atinge un punct colorat",
"line2": "cineva are încă nevoie de rugăciune"
}

View file

@ -0,0 +1,4 @@
{
"line1": "Нажмите на цветную точку",
"line2": "кому-то всё ещё нужна молитва"
}

View file

@ -0,0 +1,4 @@
{
"line1": "点按一个彩色圆点",
"line2": "仍有人需要祷告"
}