971 lines
41 KiB
Vue
971 lines
41 KiB
Vue
<script setup>
|
|
/**
|
|
* SemanticTreeLegend — reusable hierarchical legend for any one-to-many semantic tree.
|
|
*
|
|
* Decoupled from map and geocoder; communicates via the shared per-instance store
|
|
* (Mediator Pattern) when one is provided. Falls back to local state when standalone.
|
|
*
|
|
* Tree node shape:
|
|
* { id, label, color?, count?, pop?, filter?, info?, children?: TreeNode[] }
|
|
*
|
|
* Per QA architecture decisions:
|
|
* - Tab N = Generation N (1-indexed in user-facing labels). Tab 1 = root.
|
|
* - Click-through deselect: clicking a different row replaces selection.
|
|
* - Inline X chip on the selected row (slot reserved on every row → columns stay aligned).
|
|
* - 30% dim on non-selected rows EXCEPT descendants of the selected node (so the user
|
|
* can drill down into a selected family without losing visibility).
|
|
* - Breadcrumb: title row swaps to "{ParentTabName}: {ParentLabel}" when a child is
|
|
* selected (e.g. "Lang Family: Afro-Asiatic" when Arabic is selected on Languages tab).
|
|
* - Sortable UPGs / Pop column headers — click to toggle, click again to flip direction.
|
|
* - No in-legend search bar — search lives in the parent's PplrGeocoder above this panel
|
|
* (one search per map cluster, per QA "unified geocoder" decision).
|
|
*/
|
|
import { ref, computed, watch, nextTick } from 'vue'
|
|
import { useI18n } from 'vue-i18n'
|
|
|
|
// Accessible names from the shared catalog (legend.a11y.*, aria.*), the keys the library legend
|
|
// uses, so they are translated in all eleven locales. i18n is OPTIONAL: on a host without it, the
|
|
// English fallback below is used, never a raw key.
|
|
const A11Y_EN = {
|
|
'legend.a11y.expandRow': 'Expand {label}', 'legend.a11y.collapseRow': 'Collapse {label}',
|
|
'legend.a11y.clearSelection': 'Clear selection',
|
|
'aria.expandLegend': 'Expand legend', 'aria.collapseLegend': 'Collapse legend',
|
|
// The legend's own states and hints (keys shared with library/components/legends/SemanticTreeLegend.vue).
|
|
'legend.loadingRows': 'Loading {label}…', 'legend.loadingData': 'Loading data…',
|
|
'legend.noDataAtThisLevel': 'No data at this level', 'legend.showingTop': 'Showing top {n} of {m}',
|
|
'legend.useGeocoderHint': 'use the geocoder above to find the rest',
|
|
'legend.sortByPop': 'Sort by Population',
|
|
}
|
|
const fillA11y = (str, p) => String(str).replace(/\{(\w+)\}/g, (m, k) => (p && k in p ? p[k] : m))
|
|
let ta = (key, p) => fillA11y(A11Y_EN[key] ?? key, p)
|
|
try {
|
|
const i18n = useI18n()
|
|
ta = (key, p) => { const v = i18n.t(key, p || {}); return v === key ? fillA11y(A11Y_EN[key] ?? key, p) : v }
|
|
} catch (_) { /* no i18n on this host */ }
|
|
import { useInstance } from '../composables/usePplrInstance.js'
|
|
|
|
const props = defineProps({
|
|
nodes: { type: Array, default: () => [] },
|
|
tabs: { type: Array, default: null },
|
|
columns: { type: Array, default: () => ['count', 'pop'] },
|
|
title: { type: String, default: 'Legend' },
|
|
})
|
|
|
|
const emit = defineEmits(['select'])
|
|
|
|
const instance = useInstance()
|
|
|
|
// ── Tree traversal ─────────────────────────────────────────────────────────────
|
|
function treeMaxDepth(nodes, d = 0) {
|
|
if (!nodes?.length) return d
|
|
return Math.max(...nodes.map(n => treeMaxDepth(n.children ?? [], d + 1)))
|
|
}
|
|
function collectAtDepth(nodes, target, curr = 0) {
|
|
if (curr === target) return nodes.slice()
|
|
return nodes.flatMap(n => collectAtDepth(n.children ?? [], target, curr + 1))
|
|
}
|
|
function findAncestorChain(nodes, targetId, chain = []) {
|
|
for (const node of nodes) {
|
|
if (node.id === targetId) return chain
|
|
if (node.children?.length) {
|
|
const found = findAncestorChain(node.children, targetId, [...chain, node])
|
|
if (found) return found
|
|
}
|
|
}
|
|
return null
|
|
}
|
|
function collectAllDescendantIds(node, out = new Set()) {
|
|
if (!node?.children?.length) return out
|
|
for (const c of node.children) {
|
|
out.add(c.id)
|
|
collectAllDescendantIds(c, out)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// ── Derived ────────────────────────────────────────────────────────────────────
|
|
const treeDepth = computed(() => treeMaxDepth(props.nodes))
|
|
|
|
const tabList = computed(() => {
|
|
const src = props.tabs ?? []
|
|
const len = src.length || (treeDepth.value + 1)
|
|
return Array.from({ length: len }, (_, i) =>
|
|
src[i] ?? { id: `gen${i+1}`, label: `Generation ${i + 1}` }
|
|
)
|
|
})
|
|
|
|
// ── State ─────────────────────────────────────────────────────────────────────
|
|
const localActiveTab = ref(0)
|
|
const expandedIds = ref(new Set())
|
|
const localSelected = ref(null)
|
|
const panelOpen = ref(true)
|
|
const showInfoForTab = ref(null)
|
|
const sortBy = ref(null) // null = default (pop desc), 'count' | 'pop'
|
|
const sortDir = ref('desc') // 'desc' | 'asc'
|
|
|
|
const activeTab = computed({
|
|
get: () => instance ? instance.activeTab.value : localActiveTab.value,
|
|
set: v => {
|
|
if (instance) instance.activeTab.value = v
|
|
else localActiveTab.value = v
|
|
},
|
|
})
|
|
|
|
const selectedNode = computed({
|
|
get: () => instance ? instance.selection.value : localSelected.value,
|
|
set: v => {
|
|
if (instance) instance.selection.value = v
|
|
else localSelected.value = v
|
|
},
|
|
})
|
|
|
|
const selectedId = computed(() => selectedNode.value?.id || null)
|
|
|
|
watch(() => props.nodes, () => {
|
|
activeTab.value = 0
|
|
expandedIds.value = new Set()
|
|
selectedNode.value = null
|
|
emit('select', null)
|
|
}, { deep: false })
|
|
|
|
let _lastInternalId = null
|
|
if (instance) {
|
|
watch(() => instance.selection.value, sel => {
|
|
if (sel?.id === _lastInternalId) return
|
|
if (!sel) { _lastInternalId = null; return }
|
|
_lastInternalId = sel.id
|
|
const chain = findAncestorChain(props.nodes, sel.id) || []
|
|
if (typeof sel.depth === 'number') {
|
|
activeTab.value = Math.min(sel.depth, tabList.value.length - 1)
|
|
}
|
|
const tabIdx = activeTab.value
|
|
// Clear OTHER expansions; only expand the ancestor chain so the selection
|
|
// is actually visible in the rows (per QA: don't leave Indo-European open
|
|
// when user picked an Afro-Asiatic-equivalent result).
|
|
const newExp = new Set()
|
|
chain.slice(tabIdx).forEach(p => newExp.add(p.id))
|
|
expandedIds.value = newExp
|
|
emit('select', sel)
|
|
scrollSelectedIntoView()
|
|
})
|
|
}
|
|
|
|
// Scroll the currently-selected row into view inside the legend's scroll
|
|
// container — useful after geocoder picks where the row may be far down.
|
|
//
|
|
// Shadow-DOM caveat (per user 2026-05-02 — geocoder picks "selected" the row
|
|
// but the highlight never showed because the row was off-screen + the old
|
|
// document.querySelector couldn't see into the shadow root, so the
|
|
// scroll-into-view silently failed). Walk via panelRoot to query inside the
|
|
// shadow root, fall back to document for non-shadow hosts.
|
|
function scrollSelectedIntoView() {
|
|
nextTick(() => {
|
|
const id = selectedNode.value?.id
|
|
if (!id) return
|
|
const sel = `[data-row-id="${CSS.escape(id)}"]`
|
|
const root = panelRoot.value?.getRootNode?.()
|
|
const el = (root && root !== document && root.querySelector?.(sel))
|
|
|| panelRoot.value?.querySelector?.(sel)
|
|
|| document.querySelector(sel)
|
|
el?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
|
|
})
|
|
}
|
|
const panelRoot = ref(null)
|
|
|
|
// ── Sort ───────────────────────────────────────────────────────────────────────
|
|
function defaultSort(a, b) {
|
|
const pd = (b.pop ?? 0) - (a.pop ?? 0)
|
|
return pd !== 0 ? pd : (b.count ?? 0) - (a.count ?? 0)
|
|
}
|
|
|
|
const byActiveSort = computed(() => {
|
|
if (!sortBy.value) return defaultSort
|
|
const key = sortBy.value
|
|
const dir = sortDir.value === 'asc' ? 1 : -1
|
|
return (a, b) => (((a[key] ?? 0) - (b[key] ?? 0)) * dir)
|
|
})
|
|
|
|
function clickSortHeader(key) {
|
|
if (sortBy.value === key) {
|
|
// toggle direction; third click clears back to default
|
|
if (sortDir.value === 'desc') sortDir.value = 'asc'
|
|
else { sortBy.value = null; sortDir.value = 'desc' }
|
|
} else {
|
|
sortBy.value = key
|
|
sortDir.value = 'desc'
|
|
}
|
|
}
|
|
|
|
// ── Visible rows ───────────────────────────────────────────────────────────────
|
|
// Deferred per-tab rendering: tab switches happen INSTANTLY (activeTab updates
|
|
// synchronously, "Loading…" placeholder shows). The expensive walk + sort runs
|
|
// in setTimeout(0) so the click handler returns immediately. Cache prevents
|
|
// repeated work when re-visiting a tab.
|
|
//
|
|
// Why: clicking Tab 4 (PGIC, ~21k leaves) used to block the click handler for
|
|
// ~50ms while collectAtDepth walked 34k nodes. Deferred render makes the tab
|
|
// switch feel instant; rows fill in on the next frame.
|
|
const tabResults = ref([]) // tabIdx → sorted Array of nodes
|
|
const tabLoading = ref(false) // true while async work for current tab is in flight
|
|
|
|
let _pendingTab = -1
|
|
function ensureTabComputed(tab) {
|
|
if (tabResults.value[tab]) { tabLoading.value = false; return }
|
|
tabLoading.value = true
|
|
_pendingTab = tab
|
|
setTimeout(() => {
|
|
if (_pendingTab !== tab) return // user moved on; skip stale work
|
|
const result = [...collectAtDepth(props.nodes, tab)].sort(byActiveSort.value)
|
|
const next = [...tabResults.value]
|
|
next[tab] = result
|
|
tabResults.value = next
|
|
tabLoading.value = false
|
|
}, 0)
|
|
}
|
|
|
|
watch(activeTab, t => ensureTabComputed(t), { immediate: true })
|
|
watch(() => [props.nodes, sortBy.value, sortDir.value], () => {
|
|
// Underlying data or sort changed → invalidate every cached tab and recompute
|
|
// the current one.
|
|
tabResults.value = []
|
|
ensureTabComputed(activeTab.value)
|
|
}, { deep: false })
|
|
|
|
const topNodesAll = computed(() => tabResults.value[activeTab.value] || [])
|
|
const topNodes = computed(() => {
|
|
const limit = tabList.value[activeTab.value]?.topLimit
|
|
return limit ? topNodesAll.value.slice(0, limit) : topNodesAll.value
|
|
})
|
|
const topNodesOverflow = computed(() => {
|
|
const limit = tabList.value[activeTab.value]?.topLimit
|
|
if (!limit) return 0
|
|
return Math.max(0, topNodesAll.value.length - limit)
|
|
})
|
|
|
|
function buildRows(out, nodes, relDepth, parent, ancestors) {
|
|
for (const node of nodes) {
|
|
out.push({ node, relDepth, parent, ancestors })
|
|
if (expandedIds.value.has(node.id) && node.children?.length) {
|
|
buildRows(out, [...node.children].sort(byActiveSort.value), relDepth + 1, node, [...ancestors, node])
|
|
}
|
|
}
|
|
}
|
|
|
|
const visibleRows = computed(() => {
|
|
const out = []
|
|
buildRows(out, topNodes.value, 0, null, [])
|
|
// Annotate each row with hairline-trunk segments so we can render
|
|
// pipe / tee / elbow / blank lines per indent column. This makes the legend
|
|
// look like an ASCII tree (└── / ├──) — sibling-aware so the trunk only
|
|
// continues past a row if there are more siblings below at that ancestor level.
|
|
for (let i = 0; i < out.length; i++) {
|
|
const row = out[i]
|
|
const segs = []
|
|
for (let L = 0; L < row.relDepth; L++) {
|
|
// Identity of ancestor at level L for THIS row
|
|
const ancAtL = row.ancestors[L]
|
|
// Look forward to find: any later row whose ancestor at L is the same?
|
|
let trunkContinues = false
|
|
for (let j = i + 1; j < out.length; j++) {
|
|
const next = out[j]
|
|
if (next.relDepth <= L) break // walked out of this branch
|
|
if (next.relDepth === L + 1 && next.ancestors[L] === ancAtL) {
|
|
trunkContinues = true; break
|
|
}
|
|
if (next.relDepth > L + 1 && next.ancestors[L] === ancAtL) {
|
|
// descendants of an EARLIER child of ancestor — keep looking
|
|
continue
|
|
}
|
|
}
|
|
if (L === row.relDepth - 1) {
|
|
// Connector to MY parent — does my parent have more children below me?
|
|
let hasLaterSibling = false
|
|
for (let j = i + 1; j < out.length; j++) {
|
|
const next = out[j]
|
|
if (next.relDepth < row.relDepth) break
|
|
if (next.relDepth === row.relDepth && next.parent === row.parent) {
|
|
hasLaterSibling = true; break
|
|
}
|
|
}
|
|
segs.push(hasLaterSibling ? 'tee' : 'elbow')
|
|
} else {
|
|
segs.push(trunkContinues ? 'pipe' : 'blank')
|
|
}
|
|
}
|
|
row.trunks = segs
|
|
}
|
|
return out
|
|
})
|
|
|
|
const hasSel = computed(() => selectedId.value !== null)
|
|
const hasCount = computed(() => props.columns.includes('count'))
|
|
const hasPop = computed(() => props.columns.includes('pop'))
|
|
|
|
// Descendants of the selected node — kept full-opacity so the user can drill in
|
|
const selectedDescendants = computed(() => {
|
|
if (!selectedNode.value) return new Set()
|
|
return collectAllDescendantIds(selectedNode.value)
|
|
})
|
|
|
|
// ── Parent breadcrumb (uses parent's TAB name, not generic "Parent:") ────────
|
|
const parentBreadcrumb = computed(() => {
|
|
const sel = selectedNode.value
|
|
if (!sel) return null
|
|
const chain = findAncestorChain(props.nodes, sel.id)
|
|
if (!chain || !chain.length) return null
|
|
const parent = chain[chain.length - 1]
|
|
const parentDepth = chain.length - 1
|
|
const tabName = tabList.value[parentDepth]?.label || 'Parent'
|
|
return { node: parent, tabName }
|
|
})
|
|
|
|
// ── Actions ────────────────────────────────────────────────────────────────────
|
|
function selectNode(node) {
|
|
if (selectedId.value === node.id) {
|
|
_lastInternalId = null
|
|
selectedNode.value = null
|
|
emit('select', null)
|
|
} else {
|
|
_lastInternalId = node.id
|
|
selectedNode.value = node
|
|
emit('select', node)
|
|
scrollSelectedIntoView()
|
|
}
|
|
}
|
|
function toggleExpand(id, e) {
|
|
e?.stopPropagation()
|
|
const s = new Set(expandedIds.value)
|
|
s.has(id) ? s.delete(id) : s.add(id)
|
|
expandedIds.value = s
|
|
}
|
|
function switchTab(idx) {
|
|
if (activeTab.value === idx) {
|
|
showInfoForTab.value = showInfoForTab.value === idx ? null : idx
|
|
return
|
|
}
|
|
activeTab.value = idx
|
|
expandedIds.value = new Set()
|
|
_lastInternalId = null
|
|
selectedNode.value = null
|
|
showInfoForTab.value = null
|
|
emit('select', null)
|
|
}
|
|
function deselect(e) {
|
|
e?.stopPropagation()
|
|
_lastInternalId = null
|
|
selectedNode.value = null
|
|
emit('select', null)
|
|
}
|
|
function showInfo(idx, e) {
|
|
e?.stopPropagation()
|
|
showInfoForTab.value = showInfoForTab.value === idx ? null : idx
|
|
}
|
|
|
|
// ── Formatters ─────────────────────────────────────────────────────────────────
|
|
function fmtPop(n) {
|
|
if (!n) return '—'
|
|
if (n >= 1e6) return (n / 1e6).toFixed(1) + 'M'
|
|
if (n >= 1e3) return Math.round(n / 1e3) + 'k'
|
|
return n.toLocaleString()
|
|
}
|
|
function fmtCount(n) { return n != null ? n.toLocaleString() : '—' }
|
|
|
|
// Per-row class helper — descendants of selected stay full-opacity
|
|
function rowClasses(row) {
|
|
return {
|
|
selected: selectedId.value === row.node.id,
|
|
descendant: hasSel.value && selectedDescendants.value.has(row.node.id),
|
|
child: row.relDepth > 0,
|
|
leaf: !row.node.children?.length,
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<!-- Two roots so the collapsed-state chip isn't dragged off-screen by the
|
|
panel's translateX(-100%). The chip is a sibling, not a child. -->
|
|
<div ref="panelRoot" class="stl-panel" :class="{ closed: !panelOpen }" :data-theme="instance?.theme.value || 'dark'">
|
|
<div class="stl-inner">
|
|
|
|
<!-- Title bar — swaps to "{Tab name}: {Parent label}" when a child is selected.
|
|
Corner caret on the LEFT collapses the panel (replaces the old right-edge ribbon). -->
|
|
<div class="stl-titlebar">
|
|
<button
|
|
class="stl-collapse-btn"
|
|
:class="{ closed: !panelOpen }"
|
|
:title="panelOpen ? 'Collapse legend' : 'Expand legend'"
|
|
:aria-label="panelOpen ? ta('aria.collapseLegend') : ta('aria.expandLegend')"
|
|
:aria-expanded="String(panelOpen)"
|
|
@click="panelOpen = !panelOpen">
|
|
<svg width="9" height="9" viewBox="0 0 12 12" fill="none">
|
|
<path d="M4.5 3L7.5 6L4.5 9" stroke="currentColor" stroke-width="1.8"
|
|
stroke-linecap="round" stroke-linejoin="round"/>
|
|
</svg>
|
|
</button>
|
|
<template v-if="parentBreadcrumb && selectedNode">
|
|
<span class="stl-tb-eyebrow">{{ parentBreadcrumb.tabName }}</span>
|
|
<span v-if="parentBreadcrumb.node.color" class="stl-tb-dot"
|
|
:style="{ background: parentBreadcrumb.node.color }"></span>
|
|
<span class="stl-tb-text" :title="parentBreadcrumb.node.label">{{ parentBreadcrumb.node.label }}</span>
|
|
</template>
|
|
<template v-else>
|
|
<span class="stl-tb-title">{{ title }}</span>
|
|
</template>
|
|
</div>
|
|
|
|
<!-- Tabs (one per generation) — single row -->
|
|
<div class="stl-tabs-wrap">
|
|
<div class="stl-tabs">
|
|
<div v-for="(tab, idx) in tabList" :key="tab.id"
|
|
class="stl-tab-chip" :class="{ active: activeTab === idx }">
|
|
<button class="stl-tab" @click="switchTab(idx)">{{ tab.label }}</button>
|
|
<button v-if="tab.info" class="stl-tab-info" :title="tab.info"
|
|
@click="showInfo(idx, $event)">i</button>
|
|
</div>
|
|
</div>
|
|
<div v-if="showInfoForTab !== null && tabList[showInfoForTab]?.info" class="stl-info-pop">
|
|
{{ tabList[showInfoForTab].info }}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Column headers — sortable -->
|
|
<div class="stl-col-hdr">
|
|
<span class="stl-caret-sp"></span>
|
|
<span class="stl-dot-sp"></span>
|
|
<span class="stl-hdr-name">{{ tabList[activeTab]?.label || 'Name' }}</span>
|
|
<button v-if="hasCount" class="stl-hdr-num"
|
|
:class="{ active: sortBy === 'count' }"
|
|
title="Sort by UPGs"
|
|
@click="clickSortHeader('count')">
|
|
UPGs
|
|
<span v-if="sortBy === 'count'" class="stl-sort-arrow">{{ sortDir === 'desc' ? '▼' : '▲' }}</span>
|
|
</button>
|
|
<button v-if="hasPop" class="stl-hdr-num"
|
|
:class="{ active: sortBy === 'pop' }"
|
|
:title="ta('legend.sortByPop')"
|
|
@click="clickSortHeader('pop')">
|
|
Pop
|
|
<span v-if="sortBy === 'pop'" class="stl-sort-arrow">{{ sortDir === 'desc' ? '▼' : '▲' }}</span>
|
|
</button>
|
|
<span class="stl-x-slot"></span>
|
|
</div>
|
|
|
|
<!-- Rows -->
|
|
<div class="stl-rows" :class="{ 'has-sel': hasSel }">
|
|
<div v-for="row in visibleRows" :key="row.node.id"
|
|
class="stl-row" :class="rowClasses(row)"
|
|
:data-row-id="row.node.id"
|
|
:style="{
|
|
'--trunk-color': (row.parent?.color || '#6e7681'),
|
|
'--row-color': (row.node.color || '#73A17F'),
|
|
}"
|
|
@click="selectNode(row.node)">
|
|
<!-- Hairline tree connectors (one per indent level) — sibling-aware -->
|
|
<span v-for="(seg, i) in row.trunks" :key="`tr${i}`"
|
|
class="stl-trunk" :class="`stl-trunk-${seg}`"></span>
|
|
<button v-if="row.node.children?.length"
|
|
class="stl-caret" :class="{ open: expandedIds.has(row.node.id) }"
|
|
:aria-expanded="expandedIds.has(row.node.id) ? 'true' : 'false'"
|
|
:aria-label="ta(expandedIds.has(row.node.id) ? 'legend.a11y.collapseRow' : 'legend.a11y.expandRow', { label: row.node.label })"
|
|
@click.stop="toggleExpand(row.node.id, $event)">
|
|
<svg width="9" height="9" viewBox="0 0 12 12" fill="none">
|
|
<path d="M4.5 3L7.5 6L4.5 9" stroke="currentColor" stroke-width="1.6"
|
|
stroke-linecap="round" stroke-linejoin="round"/>
|
|
</svg>
|
|
</button>
|
|
<span v-else class="stl-caret-sp"></span>
|
|
<span v-if="row.node.color" class="stl-dot" :style="{ background: row.node.color }"></span>
|
|
<span v-else class="stl-dot-sp"></span>
|
|
<span class="stl-name">{{ row.node.label }}</span>
|
|
<span v-if="hasCount" class="stl-num">{{ fmtCount(row.node.count) }}</span>
|
|
<span v-if="hasPop" class="stl-num">{{ fmtPop(row.node.pop) }}</span>
|
|
<span class="stl-x-slot">
|
|
<button v-if="selectedId === row.node.id"
|
|
class="stl-x-chip" :title="ta('legend.a11y.clearSelection')" :aria-label="ta('legend.a11y.clearSelection')"
|
|
@click.stop="deselect($event)">
|
|
<svg width="8" height="8" viewBox="0 0 12 12" fill="none">
|
|
<path d="M2 2L10 10M10 2L2 10" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
|
</svg>
|
|
</button>
|
|
</span>
|
|
</div>
|
|
|
|
<div v-if="tabLoading && !visibleRows.length" class="stl-empty stl-loading">
|
|
<span class="stl-spinner"></span>
|
|
<span>{{ ta('legend.loadingRows', { label: tabList[activeTab]?.label || 'rows' }) }}</span>
|
|
</div>
|
|
<div v-else-if="!visibleRows.length && nodes.length === 0" class="stl-empty">{{ ta('legend.loadingData') }}</div>
|
|
<div v-else-if="!visibleRows.length" class="stl-empty">{{ ta('legend.noDataAtThisLevel') }}</div>
|
|
<div v-if="topNodesOverflow > 0" class="stl-overflow">
|
|
{{ ta('legend.showingTop', { n: tabList[activeTab].topLimit.toLocaleString(), m: topNodesAll.length.toLocaleString() }) }} —
|
|
<span class="stl-overflow-hint">{{ ta('legend.useGeocoderHint') }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Collapsed-state pill — sibling of .stl-panel so the panel's translateX
|
|
doesn't take it off-screen. Sits where the panel used to be. -->
|
|
<button v-if="!panelOpen"
|
|
class="stl-reopen"
|
|
:data-theme="instance?.theme.value || 'dark'"
|
|
:title="ta('aria.expandLegend')"
|
|
:aria-label="ta('aria.expandLegend')"
|
|
aria-expanded="false"
|
|
@click="panelOpen = true">
|
|
<svg class="stl-reopen-icon" width="11" height="11" viewBox="0 0 12 12" fill="none">
|
|
<path d="M2 4h8M2 6h8M2 8h5" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/>
|
|
</svg>
|
|
<span class="stl-reopen-label">
|
|
<template v-if="selectedNode">
|
|
<span v-if="selectedNode.color" class="stl-reopen-dot"
|
|
:style="{ background: selectedNode.color }"></span>
|
|
{{ selectedNode.label }}
|
|
</template>
|
|
<template v-else>{{ title }}</template>
|
|
</span>
|
|
<svg width="9" height="9" viewBox="0 0 12 12" fill="none" class="stl-reopen-caret">
|
|
<path d="M4.5 3L7.5 6L4.5 9" stroke="currentColor" stroke-width="1.8"
|
|
stroke-linecap="round" stroke-linejoin="round"/>
|
|
</svg>
|
|
</button>
|
|
</template>
|
|
|
|
<style scoped>
|
|
/* ── Single source of truth for column widths ─────────────────────────────────── */
|
|
.stl-inner {
|
|
--stl-pad-x: 10px;
|
|
--stl-gap: 6px;
|
|
--stl-caret-w: 14px;
|
|
--stl-dot-w: 8px;
|
|
--stl-num-w: 44px;
|
|
--stl-x-w: 22px;
|
|
|
|
flex: 1; min-width: 0; min-height: 0; display: flex; flex-direction: column;
|
|
background: #161b22; border: 1px solid #30363d;
|
|
border-radius: 12px;
|
|
box-shadow: 0 6px 24px rgba(0,0,0,0.45);
|
|
overflow: hidden;
|
|
}
|
|
|
|
/* ── Panel wrapper — wider sidebar (no more right-edge ribbon eating space) ─── */
|
|
.stl-panel {
|
|
position: absolute; left: 8px; top: 60px; bottom: 12px; width: 380px; z-index: 4;
|
|
display: flex; flex-direction: column;
|
|
transition: transform 0.25s cubic-bezier(0.2, 0, 0, 1), opacity 0.2s;
|
|
pointer-events: auto;
|
|
}
|
|
.stl-panel.closed {
|
|
transform: translateX(-100%);
|
|
opacity: 0; pointer-events: none;
|
|
}
|
|
|
|
/* ── Title bar (~32px) ────────────────────────────────────────────────────────── */
|
|
.stl-titlebar {
|
|
display: flex; align-items: center; gap: 6px;
|
|
padding: 8px 12px; flex-shrink: 0;
|
|
border-bottom: 1px solid #21262d;
|
|
min-height: 32px;
|
|
}
|
|
.stl-tb-title,
|
|
.stl-tb-eyebrow {
|
|
font: 600 10px ui-monospace, monospace;
|
|
color: #73A17F; text-transform: uppercase; letter-spacing: 0.07em;
|
|
flex-shrink: 0;
|
|
}
|
|
.stl-tb-eyebrow { color: #6e7681; font-size: 9px; letter-spacing: 0.08em; }
|
|
.stl-tb-dot {
|
|
width: 9px; height: 9px; border-radius: 50%; flex-shrink: 0;
|
|
box-shadow: 0 0 0 1px rgba(0,0,0,0.4), 0 0 0 3px rgba(115,161,127,0.15);
|
|
}
|
|
.stl-tb-text {
|
|
flex: 1; min-width: 0;
|
|
font: 600 12px system-ui; color: #e6edf3;
|
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
}
|
|
|
|
/* ── Tabs (single row) ────────────────────────────────────────────────────────── */
|
|
.stl-tabs-wrap { flex-shrink: 0; }
|
|
.stl-tabs {
|
|
display: flex; flex-wrap: nowrap; gap: 4px;
|
|
padding: 6px 8px;
|
|
border-bottom: 1px solid #21262d;
|
|
overflow-x: auto;
|
|
scrollbar-width: none;
|
|
}
|
|
.stl-tabs::-webkit-scrollbar { display: none; }
|
|
.stl-tab-chip {
|
|
display: inline-flex; align-items: stretch; flex-shrink: 0;
|
|
background: #1c2333; border: 1px solid #30363d; border-radius: 6px;
|
|
overflow: hidden; transition: border-color 0.12s, box-shadow 0.12s;
|
|
}
|
|
.stl-tab-chip:hover { border-color: #484f58; }
|
|
.stl-tab-chip.active {
|
|
background: #3b463d; border-color: #73A17F;
|
|
box-shadow: 0 0 0 2px rgba(115,161,127,0.22), 0 1px 4px rgba(59,70,61,0.4);
|
|
}
|
|
.stl-tab {
|
|
display: inline-flex; align-items: center;
|
|
background: transparent; border: none;
|
|
color: #8b949e; font: 600 10px ui-monospace, monospace;
|
|
text-transform: uppercase; letter-spacing: 0.04em;
|
|
padding: 4px 8px; cursor: pointer; white-space: nowrap;
|
|
}
|
|
.stl-tab:hover { color: #c9d1d9; }
|
|
.stl-tab-chip.active .stl-tab { color: #fff; }
|
|
.stl-tab-info {
|
|
background: rgba(110,118,129,0.15); border: none; border-left: 1px solid #30363d;
|
|
color: #6e7681; cursor: pointer;
|
|
width: 18px; padding: 0; font: 700 9px ui-monospace, monospace;
|
|
}
|
|
.stl-tab-info:hover { background: rgba(59,70,61,0.2); color: #73A17F; }
|
|
.stl-tab-chip.active .stl-tab-info {
|
|
border-left-color: rgba(0,0,0,0.3); color: #fff;
|
|
background: rgba(0,0,0,0.2);
|
|
}
|
|
.stl-info-pop {
|
|
margin: 2px 8px 6px; padding: 7px 10px;
|
|
background: #0d1117; border: 1px solid #30363d; border-radius: 6px;
|
|
font: 11px system-ui; color: #c9d1d9; line-height: 1.45;
|
|
}
|
|
|
|
/* ── Shared row layout (header + data rows) ───────────────────────────────────── */
|
|
.stl-col-hdr,
|
|
.stl-row {
|
|
display: flex; align-items: center; gap: var(--stl-gap);
|
|
padding: 0 var(--stl-pad-x);
|
|
}
|
|
|
|
.stl-col-hdr {
|
|
height: 22px; flex-shrink: 0;
|
|
background: #0d1117; border-bottom: 1px solid #21262d;
|
|
}
|
|
.stl-hdr-name {
|
|
flex: 1; min-width: 0;
|
|
font: 600 9px ui-monospace, monospace;
|
|
color: #6e7681; text-transform: uppercase; letter-spacing: 0.05em;
|
|
}
|
|
.stl-hdr-num {
|
|
width: var(--stl-num-w); text-align: right; flex-shrink: 0;
|
|
font: 600 9px ui-monospace, monospace;
|
|
color: #6e7681; text-transform: uppercase; letter-spacing: 0.05em;
|
|
background: transparent; border: none; cursor: pointer;
|
|
padding: 0; transition: color 0.1s;
|
|
display: inline-flex; align-items: center; justify-content: flex-end; gap: 3px;
|
|
}
|
|
.stl-hdr-num:hover { color: #c9d1d9; }
|
|
.stl-hdr-num.active { color: #73A17F; }
|
|
.stl-sort-arrow { font-size: 8px; }
|
|
|
|
/* ── Rows container ───────────────────────────────────────────────────────────── */
|
|
.stl-rows { flex: 1; overflow-y: auto; padding-bottom: 6px; }
|
|
.stl-rows::-webkit-scrollbar { width: 6px; }
|
|
.stl-rows::-webkit-scrollbar-track { background: transparent; }
|
|
.stl-rows::-webkit-scrollbar-thumb { background: #21262d; border-radius: 3px; }
|
|
.stl-rows::-webkit-scrollbar-thumb:hover { background: #30363d; }
|
|
|
|
.stl-empty {
|
|
padding: 20px 12px; text-align: center;
|
|
font: 11px system-ui; color: #484f58;
|
|
}
|
|
.stl-loading {
|
|
display: inline-flex; align-items: center; gap: 8px;
|
|
width: 100%; justify-content: center;
|
|
color: #73A17F;
|
|
}
|
|
.stl-spinner {
|
|
width: 12px; height: 12px; border-radius: 50%;
|
|
border: 2px solid rgba(115,161,127,0.25);
|
|
border-top-color: #73A17F;
|
|
animation: stl-spin 0.7s linear infinite;
|
|
}
|
|
@keyframes stl-spin { to { transform: rotate(360deg); } }
|
|
.stl-overflow {
|
|
padding: 8px 10px; margin: 4px 0 0;
|
|
background: #0d1117; border-top: 1px dashed #30363d;
|
|
text-align: center; font: 10px system-ui; color: #6e7681;
|
|
}
|
|
.stl-overflow-hint { color: #73A17F; }
|
|
|
|
/* ── Data rows ────────────────────────────────────────────────────────────────── */
|
|
.stl-row {
|
|
min-height: 27px;
|
|
cursor: pointer; transition: opacity 0.12s, background 0.1s;
|
|
border-bottom: 1px solid rgba(33,38,45,0.45);
|
|
}
|
|
.stl-row:last-child { border-bottom: none; }
|
|
.stl-row:hover { background: rgba(255,255,255,0.04); }
|
|
.stl-row.child .stl-name { color: #adbac7; }
|
|
.stl-row.leaf .stl-name { font-style: italic; }
|
|
|
|
/* Dim non-selected rows EXCEPT descendants of the selected node (so the user can
|
|
drill down into a selected family without the children being grayed out). */
|
|
.stl-rows.has-sel .stl-row:not(.selected):not(.descendant) { opacity: 0.30; }
|
|
.stl-rows.has-sel .stl-row:not(.selected):not(.descendant):hover { opacity: 0.6; }
|
|
.stl-rows.has-sel .stl-row.descendant { opacity: 1; }
|
|
.stl-rows.has-sel .stl-row.selected {
|
|
opacity: 1;
|
|
/* Subtle neutral background tint + soft outer glow in the row's own color so
|
|
the selection pops without competing with the family color of the row. */
|
|
background: rgba(255,255,255,0.05);
|
|
box-shadow:
|
|
inset 3px 0 0 0 var(--row-color, #73A17F),
|
|
inset 0 0 0 1px rgba(255,255,255,0.05),
|
|
0 0 14px -2px var(--row-color, rgba(115,161,127,0.4));
|
|
}
|
|
.stl-panel[data-theme="light"] .stl-rows.has-sel .stl-row.selected {
|
|
background: rgba(255,255,255,0.7);
|
|
box-shadow:
|
|
inset 3px 0 0 0 var(--row-color, #3b463d),
|
|
inset 0 0 0 1px rgba(0,0,0,0.04),
|
|
0 0 14px -2px var(--row-color, rgba(59,70,61,0.35));
|
|
}
|
|
|
|
/* ── Reserved slots ───────────────────────────────────────────────────────────── */
|
|
.stl-caret-sp { width: var(--stl-caret-w); flex-shrink: 0; }
|
|
.stl-dot-sp { width: var(--stl-dot-w); flex-shrink: 0; }
|
|
.stl-x-slot {
|
|
width: var(--stl-x-w); flex-shrink: 0;
|
|
display: flex; align-items: center; justify-content: center;
|
|
}
|
|
|
|
/* ── ASCII-tree hairline trunks ───────────────────────────────────────────────
|
|
One span per indent level. Each span is 12px wide. Pseudo-elements draw
|
|
thin lines tinted by the row's parent color (--trunk-color):
|
|
- pipe = vertical line through full row height (ancestor at this level
|
|
still has more siblings below)
|
|
- tee = vertical line + right stub at row middle (this row has later siblings)
|
|
- elbow = vertical line stops at row middle + right stub (this row is the last child)
|
|
- blank = no line (ancestor's chain ended above this row)
|
|
*/
|
|
.stl-trunk {
|
|
position: relative; flex-shrink: 0;
|
|
width: 12px; align-self: stretch;
|
|
}
|
|
.stl-trunk-pipe::before {
|
|
content: ''; position: absolute;
|
|
left: 5px; top: 0; bottom: 0; width: 1px;
|
|
background: var(--trunk-color, #6e7681);
|
|
opacity: 0.45;
|
|
}
|
|
.stl-trunk-tee::before {
|
|
content: ''; position: absolute;
|
|
left: 5px; top: 0; bottom: 0; width: 1px;
|
|
background: var(--trunk-color, #6e7681);
|
|
opacity: 0.45;
|
|
}
|
|
.stl-trunk-tee::after {
|
|
content: ''; position: absolute;
|
|
left: 5px; top: 50%; height: 1px; width: 7px;
|
|
background: var(--trunk-color, #6e7681);
|
|
opacity: 0.55;
|
|
}
|
|
.stl-trunk-elbow::before {
|
|
content: ''; position: absolute;
|
|
left: 5px; top: 0; height: 50%; width: 1px;
|
|
background: var(--trunk-color, #6e7681);
|
|
opacity: 0.45;
|
|
}
|
|
.stl-trunk-elbow::after {
|
|
content: ''; position: absolute;
|
|
left: 5px; top: calc(50% - 0.5px); height: 1px; width: 7px;
|
|
background: var(--trunk-color, #6e7681);
|
|
opacity: 0.55;
|
|
}
|
|
/* selected/descendant rows: brighter trunks */
|
|
.stl-row.selected .stl-trunk-pipe::before,
|
|
.stl-row.selected .stl-trunk-tee::before,
|
|
.stl-row.selected .stl-trunk-tee::after,
|
|
.stl-row.selected .stl-trunk-elbow::before,
|
|
.stl-row.selected .stl-trunk-elbow::after,
|
|
.stl-row.descendant .stl-trunk-pipe::before,
|
|
.stl-row.descendant .stl-trunk-tee::before,
|
|
.stl-row.descendant .stl-trunk-tee::after,
|
|
.stl-row.descendant .stl-trunk-elbow::before,
|
|
.stl-row.descendant .stl-trunk-elbow::after {
|
|
opacity: 0.85;
|
|
}
|
|
|
|
/* ── Caret button ─────────────────────────────────────────────────────────────── */
|
|
.stl-caret {
|
|
background: none; border: none; padding: 0; cursor: pointer;
|
|
color: #6e7681; width: var(--stl-caret-w); height: var(--stl-caret-w); flex-shrink: 0;
|
|
display: flex; align-items: center; justify-content: center;
|
|
border-radius: 3px;
|
|
}
|
|
.stl-caret svg { transition: transform 0.15s; }
|
|
.stl-caret.open svg { transform: rotate(90deg); }
|
|
.stl-caret:hover { color: #c9d1d9; background: rgba(255,255,255,0.06); }
|
|
|
|
/* ── Color dot ────────────────────────────────────────────────────────────────── */
|
|
.stl-dot {
|
|
width: var(--stl-dot-w); height: var(--stl-dot-w); border-radius: 50%; flex-shrink: 0;
|
|
box-shadow: 0 0 0 1px rgba(0,0,0,0.4);
|
|
}
|
|
.stl-row.selected .stl-dot {
|
|
box-shadow: 0 0 0 1px rgba(0,0,0,0.4), 0 0 0 3px rgba(115,161,127,0.25);
|
|
}
|
|
|
|
/* ── Label ────────────────────────────────────────────────────────────────────── */
|
|
.stl-name {
|
|
flex: 1; min-width: 0;
|
|
font: 11.5px system-ui, sans-serif; color: #e6edf3;
|
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
|
}
|
|
|
|
/* ── Numeric columns ──────────────────────────────────────────────────────────── */
|
|
.stl-num {
|
|
width: var(--stl-num-w); text-align: right; flex-shrink: 0;
|
|
font: 10.5px ui-monospace, monospace; color: #8b949e;
|
|
font-variant-numeric: tabular-nums;
|
|
}
|
|
.stl-row.selected .stl-num { color: #e6edf3; }
|
|
|
|
/* ── Inline X chip — clearer "delete" affordance, no overlap with badges ─────── */
|
|
.stl-x-chip {
|
|
background: #ef4444; border: none; border-radius: 50%;
|
|
color: #fff; cursor: pointer;
|
|
width: 18px; height: 18px;
|
|
display: flex; align-items: center; justify-content: center;
|
|
box-shadow: 0 0 0 2px rgba(239,68,68,0.18);
|
|
transition: transform 0.12s, box-shadow 0.12s;
|
|
}
|
|
.stl-x-chip:hover { transform: scale(1.08); box-shadow: 0 0 0 3px rgba(239,68,68,0.3); }
|
|
|
|
/* ── Corner caret (in titlebar) — primary collapse affordance ────────────────── */
|
|
.stl-collapse-btn {
|
|
background: rgba(110,118,129,0.15); border: 1px solid #30363d; border-radius: 5px;
|
|
color: #8b949e; cursor: pointer;
|
|
width: 22px; height: 20px; flex-shrink: 0;
|
|
display: flex; align-items: center; justify-content: center;
|
|
transition: color 0.1s, background 0.1s, transform 0.18s;
|
|
}
|
|
.stl-collapse-btn:hover { color: #c9d1d9; background: rgba(59,70,61,0.18); border-color: #73A17F; }
|
|
.stl-collapse-btn svg { transition: transform 0.18s; transform: rotate(180deg); }
|
|
|
|
/* ── Collapsed-state pill — rounded "Legend" chip — matches the search-bar
|
|
height/radius/left-edge so the chrome reads as a vertical stack of pills.
|
|
z-index intentionally LOWER than the geocoder slot (5) so the search
|
|
dropdown can extend over the chip when the user is searching. */
|
|
.stl-reopen {
|
|
position: absolute; left: 8px; top: 60px; z-index: 3;
|
|
display: inline-flex; align-items: center; gap: 8px;
|
|
background: #0d1117; border: 1px solid #30363d;
|
|
border-radius: 999px;
|
|
color: #c9d1d9; cursor: pointer;
|
|
height: 36px; /* exact match to search-bar height */
|
|
padding: 0 14px 0 12px;
|
|
box-shadow: 0 4px 14px rgba(0,0,0,0.25);
|
|
font: 600 11px ui-monospace, monospace;
|
|
text-transform: uppercase; letter-spacing: 0.06em;
|
|
max-width: 320px;
|
|
transition: color 0.12s, background 0.12s, border-color 0.12s, transform 0.12s,
|
|
box-shadow 0.12s;
|
|
}
|
|
.stl-reopen:hover {
|
|
color: #fff; background: #1c2333; border-color: #73A17F;
|
|
box-shadow: 0 6px 20px rgba(59,70,61,0.25);
|
|
transform: translateY(-1px);
|
|
}
|
|
.stl-reopen-icon { color: #73A17F; flex-shrink: 0; }
|
|
.stl-reopen-label {
|
|
display: inline-flex; align-items: center; gap: 6px;
|
|
min-width: 0; max-width: 200px;
|
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
|
text-transform: none; letter-spacing: 0;
|
|
font: 600 12px system-ui, sans-serif;
|
|
}
|
|
.stl-reopen-dot {
|
|
width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0;
|
|
box-shadow: 0 0 0 1px rgba(0,0,0,0.4);
|
|
}
|
|
.stl-reopen-caret { color: #6e7681; flex-shrink: 0; }
|
|
.stl-reopen:hover .stl-reopen-caret { color: #c9d1d9; }
|
|
|
|
@media (max-width: 640px) {
|
|
.stl-panel { width: 92vw; max-width: 380px; top: 64px; bottom: 8px; }
|
|
.stl-reopen { top: 64px; }
|
|
}
|
|
|
|
/* ── Light-mode palette ──────────────────────────────────────────────────────
|
|
Overrides triggered by [data-theme="light"] on the panel root. Mirrors the
|
|
underlying Mapbox light-v11 basemap so chrome and map read as one surface. */
|
|
.stl-panel[data-theme="light"] .stl-inner {
|
|
background: #ffffff;
|
|
border-color: #d0d7de;
|
|
box-shadow: 0 6px 24px rgba(31, 35, 40, 0.12);
|
|
}
|
|
.stl-panel[data-theme="light"] .stl-titlebar { border-color: #d8dee4; }
|
|
.stl-panel[data-theme="light"] .stl-tb-title,
|
|
.stl-panel[data-theme="light"] .stl-tb-eyebrow { color: #3b463d; }
|
|
.stl-panel[data-theme="light"] .stl-tb-eyebrow { color: #57606a; }
|
|
.stl-panel[data-theme="light"] .stl-tb-text { color: #1f2328; }
|
|
.stl-panel[data-theme="light"] .stl-collapse-btn {
|
|
background: rgba(208,215,222,0.4); border-color: #d0d7de; color: #57606a;
|
|
}
|
|
.stl-panel[data-theme="light"] .stl-collapse-btn:hover {
|
|
background: rgba(59,70,61,0.12); border-color: #3b463d; color: #3b463d;
|
|
}
|
|
|
|
.stl-panel[data-theme="light"] .stl-tabs { border-color: #d8dee4; }
|
|
.stl-panel[data-theme="light"] .stl-tab-chip {
|
|
background: #f6f8fa; border-color: #d0d7de;
|
|
}
|
|
.stl-panel[data-theme="light"] .stl-tab { color: #57606a; }
|
|
.stl-panel[data-theme="light"] .stl-tab:hover { color: #1f2328; }
|
|
.stl-panel[data-theme="light"] .stl-tab-chip.active {
|
|
background: #3b463d; border-color: #3b463d;
|
|
box-shadow: 0 0 0 2px rgba(59,70,61,0.18);
|
|
}
|
|
.stl-panel[data-theme="light"] .stl-tab-chip.active .stl-tab { color: #fff; }
|
|
.stl-panel[data-theme="light"] .stl-tab-info {
|
|
background: rgba(208,215,222,0.4); border-color: #d0d7de; color: #57606a;
|
|
}
|
|
.stl-panel[data-theme="light"] .stl-info-pop {
|
|
background: #f6f8fa; border-color: #d0d7de; color: #1f2328;
|
|
}
|
|
|
|
.stl-panel[data-theme="light"] .stl-col-hdr {
|
|
background: #f6f8fa; border-color: #d8dee4;
|
|
}
|
|
.stl-panel[data-theme="light"] .stl-hdr-name,
|
|
.stl-panel[data-theme="light"] .stl-hdr-num { color: #57606a; }
|
|
.stl-panel[data-theme="light"] .stl-hdr-num:hover { color: #3b463d; }
|
|
.stl-panel[data-theme="light"] .stl-hdr-num.active { color: #3b463d; }
|
|
|
|
.stl-panel[data-theme="light"] .stl-rows::-webkit-scrollbar-thumb { background: #d0d7de; }
|
|
.stl-panel[data-theme="light"] .stl-rows::-webkit-scrollbar-thumb:hover { background: #afb8c1; }
|
|
|
|
.stl-panel[data-theme="light"] .stl-empty,
|
|
.stl-panel[data-theme="light"] .stl-overflow {
|
|
color: #57606a; background: #f6f8fa; border-color: #d8dee4;
|
|
}
|
|
.stl-panel[data-theme="light"] .stl-overflow-hint { color: #3b463d; }
|
|
|
|
.stl-panel[data-theme="light"] .stl-row {
|
|
border-color: rgba(208,215,222,0.5);
|
|
}
|
|
.stl-panel[data-theme="light"] .stl-row:hover { background: rgba(59,70,61,0.04); }
|
|
.stl-panel[data-theme="light"] .stl-row.child .stl-name { color: #57606a; }
|
|
.stl-panel[data-theme="light"] .stl-rows.has-sel .stl-row.selected {
|
|
background: rgba(59,70,61,0.12); box-shadow: inset 3px 0 0 0 #3b463d, 0 0 12px rgba(59,70,61,0.08);
|
|
}
|
|
|
|
.stl-panel[data-theme="light"] .stl-name { color: #1f2328; }
|
|
.stl-panel[data-theme="light"] .stl-num { color: #57606a; }
|
|
.stl-panel[data-theme="light"] .stl-row.selected .stl-num { color: #1f2328; }
|
|
.stl-panel[data-theme="light"] .stl-caret { color: #57606a; }
|
|
.stl-panel[data-theme="light"] .stl-caret:hover { background: rgba(59,70,61,0.08); color: #3b463d; }
|
|
|
|
/* Light-mode reopen pill */
|
|
.stl-reopen[data-theme="light"] {
|
|
background: #ffffff; border-color: #d0d7de; color: #1f2328;
|
|
box-shadow: 0 4px 16px rgba(31,35,40,0.18);
|
|
}
|
|
.stl-reopen[data-theme="light"]:hover {
|
|
background: #f6f8fa; border-color: #3b463d; color: #3b463d;
|
|
box-shadow: 0 6px 20px rgba(59,70,61,0.2);
|
|
}
|
|
.stl-reopen[data-theme="light"] .stl-reopen-icon { color: #3b463d; }
|
|
.stl-reopen[data-theme="light"] .stl-reopen-caret { color: #57606a; }
|
|
</style>
|