9b574d918c
- Background : shader Three.js remplacé par la constellation zetwal (DOM/SVG, zéro WebGL) - Carte : graphe 3D aléatoire remplacé par un SVG 2D au layout baké au build (scripts/build-layout.mjs, positions déterministes), nœuds focusables, pan/zoom, filtres sans re-layout - Timeline : 4 ères, reveal syncopé gwoka, liens événement→fiche catalogue (module software-modal), mois affichés, corrections factuelles (Twitter 2022, Mastodon 2016, PeerTube 2017, Bluesky 2024, 105 logiciels) - Charte OKI : tokens + bridge, Archivo/Inter self-hébergées, flag-bar, KineticText, footer fédéré - Dette : three retiré (JS initial ~828 Ko → 77 Ko), OG/Twitter/JSON-LD/robots/sitemap, licence CC BY-SA 4.0, fediverse.json mort supprimé, icônes PWA 121 Ko → 9 Ko - Lighthouse mobile : 91/97/100/100 — tests vitest 6/6, svelte-check 0/0
677 lines
18 KiB
Svelte
677 lines
18 KiB
Svelte
<script lang="ts">
|
||
/* Carte 2D de l'écosystème — SVG déterministe, zéro WebGL.
|
||
Positions pré-calculées au build par scripts/build-layout.mjs
|
||
(network-layout.json) : aucune simulation au runtime, aucun aléa.
|
||
Pan/zoom piloté par le viewBox (état local), filtres = atténuation
|
||
sans re-layout. Chaque nœud est un vrai lien focusable (navigation
|
||
clavier native Tab / Entrée) qui ouvre la fiche du catalogue via
|
||
l'état partagé $lib/software-modal.svelte (repli : ancre #catalog). */
|
||
|
||
import { onMount } from 'svelte';
|
||
import { categories, categoryById, networkData, softwares, stats } from '$lib/data';
|
||
import type { NetworkNode } from '$lib/data';
|
||
import layoutJson from '$lib/data/network-layout.json';
|
||
import { reveal } from '$lib/motion/reveal';
|
||
import KineticText from '$lib/components/motion/KineticText.svelte';
|
||
import { requestSoftwareDetails } from '$lib/software-modal.svelte';
|
||
|
||
interface Layout {
|
||
viewBox: { width: number; height: number };
|
||
nodes: Record<string, { x: number; y: number }>;
|
||
}
|
||
const layout = layoutJson as Layout;
|
||
|
||
const VB_W = layout.viewBox.width;
|
||
const VB_H = layout.viewBox.height;
|
||
const MIN_W = VB_W / 8; // zoom maximal (×8)
|
||
const MAX_W = VB_W; // zoom minimal = vue d'ensemble
|
||
|
||
/** Rayon visuel d'un nœud (repris par forceCollide dans build-layout). */
|
||
const radius = (node: NetworkNode) => 4 + (node.val ?? 5) * 0.6;
|
||
|
||
// Degré de chaque nœud (nombre de liens) — sert à choisir les labels permanents.
|
||
const degree = new Map<string, number>();
|
||
for (const link of networkData.links) {
|
||
const s = link.source as string;
|
||
const tgt = link.target as string;
|
||
degree.set(s, (degree.get(s) ?? 0) + 1);
|
||
degree.set(tgt, (degree.get(tgt) ?? 0) + 1);
|
||
}
|
||
const LABELED = new Set(
|
||
[...degree.entries()]
|
||
.sort((a, b) => b[1] - a[1])
|
||
.slice(0, 10)
|
||
.map(([id]) => id)
|
||
);
|
||
|
||
interface MapNode {
|
||
node: NetworkNode;
|
||
x: number;
|
||
y: number;
|
||
r: number;
|
||
labeled: boolean;
|
||
categoryName: string;
|
||
softwareId?: string;
|
||
}
|
||
// network.json indexe par nom (« Mastodon »), software.json par slug (« mastodon »).
|
||
const softwareIdByName = new Map(softwares.map((s) => [s.name, s.id]));
|
||
const mapNodes: MapNode[] = networkData.nodes.map((node) => {
|
||
const pos = layout.nodes[node.id] ?? { x: VB_W / 2, y: VB_H / 2 };
|
||
return {
|
||
node,
|
||
x: pos.x,
|
||
y: pos.y,
|
||
r: radius(node),
|
||
labeled: LABELED.has(node.id),
|
||
categoryName: categoryById.get(node.category)?.name ?? node.category,
|
||
softwareId: softwareIdByName.get(node.id)
|
||
};
|
||
});
|
||
const nodeById = new Map(mapNodes.map((n) => [n.node.id, n]));
|
||
|
||
interface MapLink {
|
||
x1: number;
|
||
y1: number;
|
||
x2: number;
|
||
y2: number;
|
||
strong: boolean;
|
||
sourceId: string;
|
||
targetId: string;
|
||
}
|
||
const mapLinks: MapLink[] = networkData.links.flatMap((link) => {
|
||
const a = nodeById.get(link.source as string);
|
||
const b = nodeById.get(link.target as string);
|
||
if (!a || !b) return [];
|
||
return [
|
||
{
|
||
x1: a.x,
|
||
y1: a.y,
|
||
x2: b.x,
|
||
y2: b.y,
|
||
strong: link.type === 'strong',
|
||
sourceId: a.node.id,
|
||
targetId: b.node.id
|
||
}
|
||
];
|
||
});
|
||
|
||
// ————— Filtres par catégorie (atténuation, jamais de re-layout) —————
|
||
let filters = $state<Record<string, boolean>>(
|
||
Object.fromEntries(categories.map((c) => [c.id, true]))
|
||
);
|
||
const activeCount = $derived(Object.values(filters).filter(Boolean).length);
|
||
const nodeDimmed = (n: MapNode) => !filters[n.node.category];
|
||
const linkDimmed = (l: MapLink) =>
|
||
!filters[nodeById.get(l.sourceId)!.node.category] ||
|
||
!filters[nodeById.get(l.targetId)!.node.category];
|
||
|
||
function toggleFilter(categoryId: string) {
|
||
filters = { ...filters, [categoryId]: !filters[categoryId] };
|
||
tooltip = null;
|
||
}
|
||
|
||
// ————— Pan / zoom piloté par le viewBox —————
|
||
let vb = $state({ x: 0, y: 0, w: VB_W, h: VB_H });
|
||
const round = (v: number) => Math.round(v * 100) / 100;
|
||
const vbAttr = $derived(
|
||
`${round(vb.x)} ${round(vb.y)} ${round(vb.w)} ${round(vb.h)}`
|
||
);
|
||
const canZoomIn = $derived(vb.w > MIN_W * 1.001);
|
||
const canZoomOut = $derived(vb.w < MAX_W * 0.999);
|
||
const zoomed = $derived(vb.w < MAX_W * 0.999);
|
||
|
||
function clampView(next: { x: number; y: number; w: number; h: number }) {
|
||
const padX = next.w * 0.25;
|
||
const padY = next.h * 0.25;
|
||
next.x = Math.min(Math.max(next.x, -padX), VB_W - next.w + padX);
|
||
next.y = Math.min(Math.max(next.y, -padY), VB_H - next.h + padY);
|
||
return next;
|
||
}
|
||
|
||
/** Zoom d'un facteur donné, centré sur (cx, cy) en coordonnées SVG. */
|
||
function zoomAt(factor: number, cx: number, cy: number) {
|
||
const w = Math.min(Math.max(vb.w * factor, MIN_W), MAX_W);
|
||
const h = (w * VB_H) / VB_W;
|
||
const k = w / vb.w;
|
||
vb = clampView({
|
||
x: cx - (cx - vb.x) * k,
|
||
y: cy - (cy - vb.y) * k,
|
||
w,
|
||
h
|
||
});
|
||
tooltip = null;
|
||
}
|
||
|
||
const zoomIn = () => zoomAt(1 / 1.4, vb.x + vb.w / 2, vb.y + vb.h / 2);
|
||
const zoomOut = () => zoomAt(1.4, vb.x + vb.w / 2, vb.y + vb.h / 2);
|
||
const resetView = () => {
|
||
vb = { x: 0, y: 0, w: VB_W, h: VB_H };
|
||
tooltip = null;
|
||
};
|
||
|
||
let svgEl = $state<SVGSVGElement | null>(null);
|
||
|
||
// Conversion client (px) → coordonnées SVG courantes.
|
||
function clientToSvg(clientX: number, clientY: number) {
|
||
const rect = svgEl!.getBoundingClientRect();
|
||
return {
|
||
x: vb.x + ((clientX - rect.left) / rect.width) * vb.w,
|
||
y: vb.y + ((clientY - rect.top) / rect.height) * vb.h
|
||
};
|
||
}
|
||
|
||
// Drag-to-pan : uniquement depuis le fond (pas depuis un nœud, qui reste cliquable).
|
||
let panning = $state(false);
|
||
let lastPointer = { x: 0, y: 0 };
|
||
|
||
function onPointerDown(event: PointerEvent) {
|
||
if ((event.target as Element).closest('.node')) return;
|
||
if (event.button !== 0 && event.pointerType === 'mouse') return;
|
||
panning = true;
|
||
lastPointer = { x: event.clientX, y: event.clientY };
|
||
svgEl?.setPointerCapture(event.pointerId);
|
||
}
|
||
|
||
function onPointerMove(event: PointerEvent) {
|
||
if (!panning || !svgEl) return;
|
||
const rect = svgEl.getBoundingClientRect();
|
||
const dx = ((event.clientX - lastPointer.x) / rect.width) * vb.w;
|
||
const dy = ((event.clientY - lastPointer.y) / rect.height) * vb.h;
|
||
lastPointer = { x: event.clientX, y: event.clientY };
|
||
vb = clampView({ x: vb.x - dx, y: vb.y - dy, w: vb.w, h: vb.h });
|
||
tooltip = null;
|
||
}
|
||
|
||
function onPointerUp(event: PointerEvent) {
|
||
panning = false;
|
||
svgEl?.releasePointerCapture(event.pointerId);
|
||
}
|
||
|
||
// Molette : zoom vers le curseur (listener natif non passif pour preventDefault).
|
||
onMount(() => {
|
||
const svg = svgEl;
|
||
if (!svg) return;
|
||
const onWheel = (event: WheelEvent) => {
|
||
event.preventDefault();
|
||
const factor = event.deltaY > 0 ? 1.15 : 1 / 1.15;
|
||
const p = clientToSvg(event.clientX, event.clientY);
|
||
zoomAt(factor, p.x, p.y);
|
||
};
|
||
svg.addEventListener('wheel', onWheel, { passive: false });
|
||
return () => svg.removeEventListener('wheel', onWheel);
|
||
});
|
||
|
||
// ————— Tooltip (hover souris / focus clavier) —————
|
||
let tooltip = $state<{ n: MapNode; left: number; top: number } | null>(null);
|
||
|
||
function showTooltip(n: MapNode) {
|
||
if (!svgEl) return;
|
||
const rect = svgEl.getBoundingClientRect();
|
||
const px = ((n.x - vb.x) / vb.w) * rect.width;
|
||
const py = ((n.y - vb.y) / vb.h) * rect.height;
|
||
tooltip = {
|
||
n,
|
||
left: Math.min(Math.max(px + 14, 8), Math.max(rect.width - 280, 8)),
|
||
top: Math.min(Math.max(py + 14, 8), Math.max(rect.height - 160, 8))
|
||
};
|
||
}
|
||
|
||
function onWindowKeydown(event: KeyboardEvent) {
|
||
if (event.key === 'Escape') tooltip = null;
|
||
}
|
||
|
||
/** Clic/Entrée sur un nœud : ouvre la fiche du catalogue (modale fixe,
|
||
sans effet de bord si le logiciel n'est pas référencé). Le href
|
||
« #catalog » reste le repli sans JS. */
|
||
function openSoftware(n: MapNode, event: MouseEvent) {
|
||
if (!n.softwareId) return;
|
||
event.preventDefault();
|
||
tooltip = null;
|
||
requestSoftwareDetails(n.softwareId);
|
||
}
|
||
</script>
|
||
|
||
<svelte:window onkeydown={onWindowKeydown} />
|
||
|
||
<section id="map" aria-labelledby="map-title">
|
||
<div class="center section-head stack" use:reveal={3}>
|
||
<p class="kicker">Cartographie</p>
|
||
<KineticText id="map-title" text="L'Écosystème" />
|
||
<p class="subtitle">
|
||
{activeCount} catégories actives · Survolez ou touchez un nœud pour plus d'infos
|
||
</p>
|
||
</div>
|
||
|
||
<div class="graph-wrap center">
|
||
<div class="viewport">
|
||
<svg
|
||
bind:this={svgEl}
|
||
class="map"
|
||
class:panning
|
||
viewBox={vbAttr}
|
||
role="img"
|
||
aria-label="Carte des {stats.totalSoftwares} logiciels du Fédiverse, colorés par catégorie et reliés par compatibilité. Utilisez les boutons pour zoomer, glissez pour déplacer la vue. Le détail de chaque logiciel est aussi disponible dans le catalogue."
|
||
onpointerdown={onPointerDown}
|
||
onpointermove={onPointerMove}
|
||
onpointerup={onPointerUp}
|
||
onpointercancel={onPointerUp}
|
||
>
|
||
<g class="links" aria-hidden="true">
|
||
{#each mapLinks as link, i (i)}
|
||
<line
|
||
x1={link.x1}
|
||
y1={link.y1}
|
||
x2={link.x2}
|
||
y2={link.y2}
|
||
class="link"
|
||
class:strong={link.strong}
|
||
class:dimmed={linkDimmed(link)}
|
||
/>
|
||
{/each}
|
||
</g>
|
||
<g class="nodes">
|
||
{#each mapNodes as n, i (n.node.id)}
|
||
<a
|
||
href="#catalog"
|
||
class="node"
|
||
class:dimmed={nodeDimmed(n)}
|
||
aria-label="{n.node.id}, {n.categoryName} — ouvrir la fiche dans le catalogue"
|
||
onclick={(e) => openSoftware(n, e)}
|
||
onfocus={() => showTooltip(n)}
|
||
onblur={() => (tooltip = null)}
|
||
onpointerenter={(e) => e.pointerType === 'mouse' && showTooltip(n)}
|
||
onpointerleave={(e) => e.pointerType === 'mouse' && (tooltip = null)}
|
||
>
|
||
<g class="enter" style:--d="{(i % 8) * 60}ms">
|
||
<rect
|
||
class="halo"
|
||
x={n.x - n.r - 4}
|
||
y={n.y - n.r - 4}
|
||
width={(n.r + 4) * 2}
|
||
height={(n.r + 4) * 2}
|
||
aria-hidden="true"
|
||
/>
|
||
<rect
|
||
class="shape"
|
||
x={n.x - n.r}
|
||
y={n.y - n.r}
|
||
width={n.r * 2}
|
||
height={n.r * 2}
|
||
fill={n.node.color}
|
||
/>
|
||
<text
|
||
class="label"
|
||
class:persistent={n.labeled}
|
||
x={n.x}
|
||
y={n.y - n.r - 7}
|
||
text-anchor="middle"
|
||
aria-hidden="true">{n.node.id}</text
|
||
>
|
||
</g>
|
||
</a>
|
||
{/each}
|
||
</g>
|
||
</svg>
|
||
|
||
<div class="zoom-controls" role="group" aria-label="Contrôles de la vue">
|
||
<button type="button" onclick={zoomIn} disabled={!canZoomIn} aria-label="Zoom avant">
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||
<path d="M12 5v14M5 12h14" stroke="currentColor" stroke-width="2" stroke-linecap="square" />
|
||
</svg>
|
||
</button>
|
||
<button type="button" onclick={zoomOut} disabled={!canZoomOut} aria-label="Zoom arrière">
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||
<path d="M5 12h14" stroke="currentColor" stroke-width="2" stroke-linecap="square" />
|
||
</svg>
|
||
</button>
|
||
<button type="button" onclick={resetView} disabled={!zoomed} aria-label="Réinitialiser la vue">
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||
<path
|
||
d="M4 10a8 8 0 1 1 2 6.9M4 10V4m0 6h6"
|
||
stroke="currentColor"
|
||
stroke-width="2"
|
||
stroke-linecap="square"
|
||
/>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
|
||
<noscript>
|
||
<p class="noscript-note">
|
||
La carte est statique sans JavaScript.
|
||
<a href="#catalog">Consulter le catalogue des logiciels</a>
|
||
</p>
|
||
</noscript>
|
||
</div>
|
||
|
||
<aside class="filters" aria-label="Filtres par catégorie">
|
||
<h3>Filtres</h3>
|
||
<ul class="stack" role="list">
|
||
{#each categories as cat (cat.id)}
|
||
<li>
|
||
<button
|
||
type="button"
|
||
class="filter-btn"
|
||
class:inactive={!filters[cat.id]}
|
||
aria-pressed={filters[cat.id]}
|
||
style:--cat-color={cat.color}
|
||
onclick={() => toggleFilter(cat.id)}
|
||
>
|
||
<span class="dot" aria-hidden="true"></span>
|
||
<span class="cat-name">{cat.name}</span>
|
||
</button>
|
||
</li>
|
||
{/each}
|
||
</ul>
|
||
</aside>
|
||
|
||
{#if tooltip}
|
||
<div class="tooltip" style:left="{tooltip.left}px" style:top="{tooltip.top}px" aria-hidden="true">
|
||
<div class="tooltip-head">
|
||
<span class="dot" style:--cat-color={tooltip.n.node.color} aria-hidden="true"></span>
|
||
<h4>{tooltip.n.node.id}</h4>
|
||
</div>
|
||
<p class="tooltip-cat">{tooltip.n.categoryName}</p>
|
||
<p class="tooltip-desc">{tooltip.n.node.description}</p>
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
|
||
<!-- Miroir sémantique de la carte : liste accessible des logiciels. -->
|
||
<ul class="visually-hidden">
|
||
{#each mapNodes as n (n.node.id)}
|
||
<li>
|
||
<a href="#catalog">{n.node.id}</a> — {n.categoryName}. {n.node.description}
|
||
</li>
|
||
{/each}
|
||
</ul>
|
||
</section>
|
||
|
||
<style>
|
||
section {
|
||
background: var(--noir-oki, var(--surface));
|
||
padding-block: var(--space-6);
|
||
scroll-margin-top: var(--header-h);
|
||
}
|
||
|
||
.section-head {
|
||
--stack-gap: var(--space-2);
|
||
margin-block-end: var(--space-4);
|
||
}
|
||
|
||
.kicker {
|
||
font-family: var(--font-mono);
|
||
font-size: var(--text-small);
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.12em;
|
||
color: var(--or-oki, var(--accent-text));
|
||
}
|
||
|
||
.subtitle {
|
||
color: var(--muted);
|
||
}
|
||
|
||
.graph-wrap {
|
||
position: relative;
|
||
container-type: inline-size;
|
||
}
|
||
|
||
/* ————— Carte SVG ————— */
|
||
.viewport {
|
||
position: relative;
|
||
border: 1px solid var(--line, var(--border));
|
||
border-radius: var(--radius-md, var(--radius-2));
|
||
background: var(--noir-profond, var(--surface-2));
|
||
overflow: hidden;
|
||
}
|
||
|
||
.map {
|
||
display: block;
|
||
inline-size: 100%;
|
||
block-size: clamp(30rem, 85vh, 56rem);
|
||
cursor: grab;
|
||
touch-action: none;
|
||
}
|
||
|
||
.map.panning {
|
||
cursor: grabbing;
|
||
}
|
||
|
||
.link {
|
||
stroke: var(--line, var(--border-strong));
|
||
stroke-width: 0.6;
|
||
opacity: 0.35;
|
||
transition: opacity var(--dur-tanbou, 120ms) ease;
|
||
}
|
||
|
||
.link.strong {
|
||
stroke-width: 1.1;
|
||
opacity: 0.55;
|
||
}
|
||
|
||
.link.dimmed {
|
||
opacity: 0.05;
|
||
}
|
||
|
||
.node {
|
||
transition: opacity var(--dur-tanbou, 120ms) ease;
|
||
}
|
||
|
||
.node.dimmed {
|
||
opacity: 0.15;
|
||
}
|
||
|
||
.node .halo {
|
||
fill: none;
|
||
stroke: var(--or-oki, #fdb813);
|
||
stroke-width: 2;
|
||
opacity: 0;
|
||
transition: opacity var(--dur-tanbou, 120ms) ease;
|
||
}
|
||
|
||
.node:hover .halo,
|
||
.node:focus-visible .halo {
|
||
opacity: 1;
|
||
}
|
||
|
||
.node:focus-visible {
|
||
outline: none;
|
||
}
|
||
|
||
.node .label {
|
||
font-family: var(--font-mono);
|
||
font-size: 11px;
|
||
fill: var(--blanc-creme, var(--ink));
|
||
paint-order: stroke;
|
||
stroke: var(--noir-profond, #0d0d0d);
|
||
stroke-width: 3px;
|
||
opacity: 0;
|
||
pointer-events: none;
|
||
transition: opacity var(--dur-tanbou, 120ms) ease;
|
||
}
|
||
|
||
.node .label.persistent,
|
||
.node:hover .label,
|
||
.node:focus-visible .label {
|
||
opacity: 0.95;
|
||
}
|
||
|
||
/* Apparition syncopée des nœuds (gwoka) : délai --d par index modulo 8.
|
||
Portée par le <g class="enter"> interne pour ne pas verrouiller
|
||
l'opacité du <a> (fill-mode forwards), qui reste pilotée par les
|
||
filtres. Désactivée si l'utilisateur préfère réduire les animations. */
|
||
@media (prefers-reduced-motion: no-preference) {
|
||
.node .enter {
|
||
opacity: 0;
|
||
animation: node-in var(--dur-mesure, 480ms) var(--ease-ka, ease-out) forwards;
|
||
animation-delay: var(--d);
|
||
}
|
||
|
||
@keyframes node-in {
|
||
from {
|
||
opacity: 0;
|
||
}
|
||
to {
|
||
opacity: 1;
|
||
}
|
||
}
|
||
}
|
||
|
||
/* ————— Contrôles zoom ————— */
|
||
.zoom-controls {
|
||
position: absolute;
|
||
inset-block-end: var(--space-3);
|
||
inset-inline-end: var(--space-3);
|
||
display: flex;
|
||
gap: var(--space-1);
|
||
}
|
||
|
||
.zoom-controls button {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
inline-size: var(--tap-target);
|
||
block-size: var(--tap-target);
|
||
border: 1px solid var(--line, var(--border));
|
||
border-radius: var(--radius-sm, var(--radius-1));
|
||
background: var(--card-bg, var(--glass));
|
||
color: var(--blanc-creme, var(--ink));
|
||
}
|
||
|
||
.zoom-controls button:hover:not(:disabled) {
|
||
border-color: var(--or-oki, #fdb813);
|
||
color: var(--or-oki, #fdb813);
|
||
}
|
||
|
||
.zoom-controls button:disabled {
|
||
opacity: 0.35;
|
||
cursor: default;
|
||
}
|
||
|
||
.noscript-note {
|
||
padding: var(--space-3);
|
||
text-align: center;
|
||
color: var(--muted);
|
||
}
|
||
|
||
/* ————— Panneau de filtres ————— */
|
||
.filters {
|
||
margin-block-start: var(--space-3);
|
||
background: var(--card-bg, var(--glass));
|
||
border: 1px solid var(--line, var(--glass-border));
|
||
border-radius: var(--radius-md, var(--radius-2));
|
||
padding: var(--space-3);
|
||
}
|
||
|
||
.filters h3 {
|
||
font-size: var(--text-small);
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.08em;
|
||
color: var(--muted);
|
||
margin-block-end: var(--space-2);
|
||
}
|
||
|
||
.filters ul {
|
||
--stack-gap: var(--space-1);
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fill, minmax(min(100%, 11rem), 1fr));
|
||
gap: var(--space-1);
|
||
}
|
||
|
||
.filter-btn {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: var(--space-2);
|
||
inline-size: 100%;
|
||
min-block-size: var(--tap-target);
|
||
padding-inline: var(--space-2);
|
||
border-radius: var(--radius-sm, var(--radius-1));
|
||
color: var(--blanc-creme, var(--ink));
|
||
text-align: start;
|
||
}
|
||
|
||
.filter-btn:hover {
|
||
background: var(--noir-profond, var(--surface-2));
|
||
}
|
||
|
||
.filter-btn .dot {
|
||
inline-size: 0.75rem;
|
||
block-size: 0.75rem;
|
||
background: var(--cat-color);
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.filter-btn.inactive .dot {
|
||
background: var(--muted);
|
||
}
|
||
|
||
.filter-btn.inactive .cat-name {
|
||
color: var(--muted);
|
||
text-decoration: line-through;
|
||
}
|
||
|
||
.cat-name {
|
||
font-size: var(--text-small);
|
||
}
|
||
|
||
@container (min-width: 56rem) {
|
||
.filters {
|
||
position: absolute;
|
||
inset-block-start: var(--space-3);
|
||
inset-inline-end: var(--space-3);
|
||
margin-block-start: 0;
|
||
max-inline-size: 15rem;
|
||
}
|
||
|
||
.filters ul {
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
}
|
||
|
||
/* ————— Tooltip ————— */
|
||
.tooltip {
|
||
position: absolute;
|
||
z-index: 20;
|
||
max-inline-size: 17rem;
|
||
background: var(--card-bg, var(--glass));
|
||
border: 1px solid var(--or-oki, var(--glass-border));
|
||
border-radius: var(--radius-md, var(--radius-2));
|
||
padding: var(--space-3);
|
||
box-shadow: var(--shadow-2);
|
||
pointer-events: none;
|
||
}
|
||
|
||
.tooltip-head {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--space-2);
|
||
}
|
||
|
||
.tooltip-head h4 {
|
||
font-size: var(--text-1);
|
||
flex: 1;
|
||
}
|
||
|
||
.tooltip .dot {
|
||
inline-size: 0.75rem;
|
||
block-size: 0.75rem;
|
||
background: var(--cat-color);
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.tooltip-cat {
|
||
font-family: var(--font-mono);
|
||
font-size: var(--text-small);
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.08em;
|
||
color: var(--or-clair, var(--muted));
|
||
margin-block: var(--space-1);
|
||
}
|
||
|
||
.tooltip-desc {
|
||
font-size: var(--text-small);
|
||
color: var(--blanc-creme, var(--ink-soft));
|
||
}
|
||
</style>
|