doxa-maps/library/components/ResearchMapSideMenu.vue
Doxa Maps 36f2818b37 Every animating library component now honours prefers-reduced-motion itself
17 of 25 library components that ship motion carried no reduced-motion rule, so
they animated regardless of the reader's OS setting.

The reason it cannot be fixed once, centrally: a @media (prefers-reduced-motion)
rule in the host document does not cross a shadow boundary, and these render
inside one. Measured while surveying: NO generated leaf page ships such a rule
at all — 0 occurrences across the whole built tree — so there was not even a
page rule to inherit. Each component owns its own.

CSS-ONLY AND ADDITIVE. Nothing but new @media blocks; no existing rule changed.
Verified by diff: 0 real removals across 17 files. The two lines git reports as
removed are a trailing comma moving as a new string was chained onto an existing
concatenation, and both original rules are still present — checked by grepping
for them, not by reading the diff.

WHERE MOTION CARRIES MEANING, it is kept rather than hidden:
  · SemanticTreeLegend's spinner MEANS "loading". Stopped dead it is a static
    ring that says nothing, so it is slowed to a step crawl (2.4s, steps(8)) —
    visibly working, no smooth spin.
  · LegendProgressBar's only motion is a hover filter, so it simply stops; the
    bar's FILL is geometry, not animation, and still reports its value.

The rule goes where each component's styles actually go, and that varies more
than expected: 13 append into a useShadowStyles template literal, 3 into
string-concatenated sheets, and VerticalTabsLayout into its VTL_SHADOW_CSS
constant AND its duplicate <style> block, because its own header says the two
copies must stay identical.

check:reduced-motion asserts both halves, each mutation-tested to fail before I
trusted a pass: strip the rule from an animating component and it fails; move a
rule from the injected sheet into a <style> block and it fails too, because on a
useShadowStyles component that rule would never reach the shadow root. Both
mutated files restored byte-identical.

Two of my own errors caught by tooling rather than by review, and worth naming:
my first patch asserted useShadowStyles existed in every target and RAISED on
VerticalTabsLayout instead of silently skipping it — the assert-the-anchor habit
from the last registration failure working as intended. Then I appended CSS
after the label argument on two concat-built sheets, corrupting the injection
key; check:shadow-css-parses named both files and the fix was to insert before
the label. A gate I did not write caught what I got wrong.

Registered in all three places and verified by re-reading the parsed
package.json.
2026-09-29 01:38:07 +00:00

154 lines
8.4 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script setup>
/**
* ResearchMapSideMenu.vue — Side drawer specific to the research-map profile.
*
* Hosts:
* - Cluster mode toggle (off / mapbox / mst / network)
* - Pin-opacity dim slider (visible only when clustering is on)
* - Slot for the multi-field filter panel (parent passes <ResearchMapFilterPanel />)
* - Poster export button (emits 'open-poster' — parent opens PosterDialog)
* - Reload-data button
*
* Reads/writes mapStore.clusteringMode and mapStore.clusterPinOpacity.
* Does NOT touch the map directly — the useMapClustering composable in the
* profile subscribes to those store fields.
*
* Props:
* isOpen — drawer visible flag (parent owns)
* Emits:
* close — backdrop or X clicked
* open-poster — poster button clicked
* reload-map — reload-data button clicked
*
* Slot (default): filter UI (kept as a slot so other profiles can pass their
* own filter component without forking this drawer).
*/
import { inject, computed } from 'vue'
import { useShadowStyles } from '../composables/useShadowStyles.js'
defineProps({
isOpen: { type: Boolean, default: false }
})
const emit = defineEmits(['close', 'open-poster', 'reload-map'])
import { tStatic } from '@map/i18n/index.js'
// ★ THIS PANEL WAS ENGLISH IN ALL ELEVEN LOCALES. Measured on the composite under ?lang=ru: every
// Latin-script string left on the page belonged to this menu and its filter sibling — the map
// around them was fully translated. Read through tStatic rather than useI18n: these components are
// reachable from mounts that install no i18n plugin, and an unguarded useI18n() throws and takes
// the subtree with it (the defect that blanked people-groups-grid, fixed in 6603dc17). English
// stays as the last argument, so a missing key degrades to today's text rather than to a key name.
const L = (key, en) => tStatic('controls.' + key) || tStatic('dashboard.' + key) || en
const mapStore = inject('mapStore')
const uiStore = inject('uiStore')
// ─── Cluster controls ────────────────────────────────────────────────────────
const CLUSTER_MODES = [
{ id: 'off', label: L('off', 'Off'), desc: L('off_desc', 'Show every pin individually') },
{ id: 'mapbox', label: L('native', 'Native'), desc: L('native_desc', 'Mapbox circle clusters (fastest)') },
{ id: 'mst', label: L('mst', 'MST'), desc: L('mst_desc', 'Minimum-spanning-tree affinity webs') },
{ id: 'network', label: L('network', 'Network'), desc: L('network_desc', 'Nearest-neighbor connection lines') }
]
const clusterMode = computed({
get: () => mapStore?.clusteringMode ?? 'off',
set: (v) => { if (mapStore) mapStore.clusteringMode = v }
})
const pinOpacity = computed({
get: () => mapStore?.clusterPinOpacity ?? 0.6,
set: (v) => { if (mapStore) mapStore.clusterPinOpacity = Number(v) }
})
useShadowStyles(`
.rm-side-backdrop { position:absolute;inset:0;background:rgba(0,0,0,0.45);z-index:1500;opacity:0;pointer-events:none;transition:opacity .18s; }
.rm-side-backdrop.open { opacity:1;pointer-events:auto; }
/* box-shadow lives on .open ONLY: when closed the drawer is translateX(100%) off
the right edge, but a left-pointing shadow (-4px) bled back onto the map's right
edge — the mystery "shadow on the right side" (coder staging QA 2026-06-25). */
.rm-side-drawer { position:absolute;top:0;right:0;bottom:0;width:340px;max-width:100vw;background:#171c23;color:#e7ebf0;border-left:1px solid #232a33;z-index:1600;transform:translateX(100%);transition:transform .22s ease;display:flex;flex-direction:column;font:13px -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif; }
.rm-side-drawer.open { transform:translateX(0); box-shadow:-4px 0 16px rgba(0,0,0,0.4); }
.rm-side-header { display:flex;align-items:center;justify-content:space-between;padding:14px 18px;border-bottom:1px solid #232a33;flex:0 0 auto; }
.rm-side-title { font-size:14px;font-weight:600;color:#e7ebf0;margin:0; }
.rm-side-close { background:none;border:none;color:#a8b2bd;font-size:22px;line-height:1;cursor:pointer;padding:2px 6px;border-radius:4px; }
.rm-side-close:hover { color:#e7ebf0;background:rgba(255,255,255,0.06); }
.rm-side-body { flex:1 1 auto;overflow-y:auto;padding:14px 18px; }
.rm-section { margin-bottom:22px; }
.rm-section h4 { margin:0 0 8px;font-size:11px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;color:#a8b2bd; }
.rm-cluster-grid { display:grid;grid-template-columns:1fr 1fr;gap:6px; }
.rm-cluster-btn { background:#232a33;border:1px solid #2c343f;color:#e7ebf0;padding:8px 10px;border-radius:6px;cursor:pointer;font-size:12px;text-align:left;line-height:1.3; }
.rm-cluster-btn:hover { background:#2c343f;border-color:#3a4250; }
.rm-cluster-btn.active { background:rgba(124,140,248,0.16);border-color:#7c8cf8;color:#a8b8ff; }
.rm-cluster-btn .label { display:block;font-weight:600;font-size:12px;margin-bottom:2px; }
.rm-cluster-btn .desc { display:block;font-size:10.5px;color:#a8b2bd; }
.rm-slider-row { display:flex;align-items:center;gap:10px;margin-top:8px; }
.rm-slider-row input[type=range] { flex:1; }
.rm-slider-row .val { width:36px;text-align:right;font-variant-numeric:tabular-nums;color:#a8b2bd;font-size:11px; }
.rm-side-footer { flex:0 0 auto;padding:12px 18px;border-top:1px solid #232a33;display:flex;flex-direction:column;gap:8px; }
.rm-btn { display:flex;align-items:center;justify-content:center;gap:8px;background:#7c8cf8;border:none;color:#0f1216;padding:10px 14px;border-radius:6px;cursor:pointer;font-size:13px;font-weight:600; }
.rm-btn:hover { background:#9aa6ff; }
.rm-btn.ghost { background:transparent;color:#a8b2bd;border:1px solid #2c343f; }
.rm-btn.ghost:hover { background:#232a33;color:#e7ebf0; }
/* ★ REDUCED MOTION, OWNED BY THIS COMPONENT. A page-level rule cannot reach into a shadow root,
and no generated leaf ships one anyway (measured: 0 across the whole tree), so a component
that animates must carry its own. Additive only: it removes motion when the OS asks and
changes nothing otherwise. */
@media (prefers-reduced-motion: reduce) {
.rmsm-root, .rmsm-item { transition: none !important; animation: none !important; }
}
`, 'research-map-side-menu')
</script>
<template>
<div class="rm-side-backdrop" :class="{ open: isOpen }" @click="emit('close')" />
<aside class="rm-side-drawer" :class="{ open: isOpen }" role="dialog" :aria-label="L('map_controls', 'Research map controls')">
<header class="rm-side-header">
<h3 class="rm-side-title">{{ L('research_controls', 'Research controls') }}</h3>
<button class="rm-side-close" :aria-label="tStatic('buttons.close') || 'Close'" @click="emit('close')">×</button>
</header>
<div class="rm-side-body">
<!-- Clustering ─────────────────────────────────────────────────── -->
<div class="rm-section">
<h4>{{ L('clustering', 'Clustering') }}</h4>
<div class="rm-cluster-grid">
<button
v-for="m in CLUSTER_MODES"
:key="m.id"
class="rm-cluster-btn"
:class="{ active: clusterMode === m.id }"
@click="clusterMode = m.id"
>
<span class="label">{{ m.label }}</span>
<span class="desc">{{ m.desc }}</span>
</button>
</div>
<div v-if="clusterMode !== 'off'" class="rm-slider-row">
<label for="rm-pin-op">{{ L('pin_dim', 'Pin dim') }}</label>
<input
id="rm-pin-op"
type="range"
min="0.05"
max="1"
step="0.05"
:value="pinOpacity"
@input="pinOpacity = $event.target.value"
/>
<span class="val">{{ Math.round(pinOpacity * 100) }}%</span>
</div>
</div>
<!-- Filter panel slot ──────────────────────────────────────────── -->
<div class="rm-section">
<h4>{{ L('filters', 'Filters') }}</h4>
<slot />
</div>
</div>
<footer class="rm-side-footer">
<button class="rm-btn" @click="emit('open-poster')">{{ L('export_poster', 'Export poster…') }}</button>
<button class="rm-btn ghost" @click="emit('reload-map')">{{ L('reload_data', 'Reload data') }}</button>
</footer>
</aside>
</template>