#graph-viewport {
  position: relative;
  overflow: hidden;
  height: calc(100vh - 68px);
  cursor: grab;
  background:
    radial-gradient(circle at 1px 1px, rgba(237, 230, 218, 0.055) 1px, transparent 0) 0 0 / 32px 32px,
    var(--bg);
}
#graph-viewport.dragging { cursor: grabbing; }

.loading, .error {
  font-family: var(--font-ui);
  color: var(--text-dim);
  padding: 2rem;
  position: relative;
  z-index: 1;
}

#graph-world {
  position: absolute;
  top: 0;
  left: 0;
  width: 0;
  height: 0;
  transform-origin: 0 0;
}

/* --- anteprima grande al passaggio del mouse (vedi attachHoverPreview in
   graph.js) ---------------------------------------------------------------
   Occupa la fascia vuota sopra alla mappa come "schermo" per una versione
   grande e sfumata dell'immagine su cui il mouse si e' fermato 2s. Nessun
   z-index qui apposta: inserito PRIMA di #graph-world nel markup, tra
   fratelli senza z-index dichiarato vince l'ordine nel documento - "world"
   dipinge sempre sopra, quindi questa resta sempre "in ultimo piano" e non
   puo' mai coprire nodi/immagini veri. pointer-events:none per lo stesso
   motivo: non deve mai intercettare click o trascinamento della mappa. */
#graph-hover-preview {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  display: flex;
  align-items: flex-start;
  justify-content: flex-start;
  padding: 0.5rem 0 0 1.5rem;
  pointer-events: none;
  opacity: 0;
  transition: opacity 0.5s ease;
}
#graph-hover-preview.visible { opacity: 1; }
/* Confermato con DevTools (Computed/Styles) su un caso reale: un
   box-shadow, anche inset, applicato a un <img> risultava presente e non
   sovrascritto ma comunque INVISIBILE - per specifica CSS un'ombra inset
   si disegna sopra allo sfondo ma SOTTO al contenuto della casella, e per
   un <img> i pixel della foto SONO il contenuto: finiscono quindi sempre
   sopra all'ombra, coprendola del tutto. Un <div> con la foto come
   background-image invece no: sfondo e ombra stanno sullo stesso
   "strato" di disegno e si compongono come ci si aspetta - tecnica
   comune per le vignette su foto, per questo motivo specifico. La
   dimensione (qui non c'e' piu' un <img> con le sue proporzioni naturali
   a occuparsene da solo) e' calcolata a mano in graph.js
   (attachHoverPreview), equivalente a object-fit:contain. */
.graph-hover-preview-photo {
  background-repeat: no-repeat;
  background-size: 100% 100%;
  opacity: 0.75;
  /* piu' marcata (richiesto dal vivo, la prima versione sfumava solo
     l'ultimissimo bordo e sembrava "incollata") - blur e spread molto
     piu' ampi, cosi' la dissolvenza comincia ben dentro l'immagine, non
     solo negli ultimi pixel */
  box-shadow: inset 0 0 160px 70px var(--bg);
}

#graph-edges, #graph-edges-major {
  position: absolute;
  top: 0;
  left: 0;
  overflow: visible;
  pointer-events: none;
  /* stesso bug del reset globale gia' visto per le immagini (vedi
     .graph-image): "svg { max-width:100% }" vale 0 perche' #graph-world ha
     width:0 di proposito, e schiacciava la larghezza dell'svg a 0 - qui la
     dimensione e' sempre esplicita via JS (width/height attributi svg). */
  max-width: none;
  max-height: none;
}

/* --- nodi -------------------------------------------------------- */
/* Il colore/bordo/sfondo NON stanno piu' sull'elemento stesso ma su uno
   SVG vero (figlio reale, non ::before - vedi buildNodeShapeSvg in
   graph.js), una sagoma diversa per categoria (pergamena, cartello,
   banner, ...). L'elemento .graph-node resta solo un contenitore di
   posizionamento/flex; il testo (.graph-node-text, sotto) e' un
   elemento a parte, sempre sopra alla sagoma (z-index), mai tagliato o
   deformato da quella - stesso principio gia' collaudato per i riquadri
   "luogo" con mappa (.graph-node--loc-bg, che infatti non usa questo
   sistema e resta con il proprio ::before). */
.graph-node {
  position: absolute;
  z-index: 2; /* sempre sopra le immagini, il testo non deve mai restare coperto */
  box-sizing: border-box;
  display: flex;
  align-items: center;
  justify-content: center;
  /* il margine dal testo alla sagoma sta QUI (non su .graph-node-text,
     vedi il commento li' sotto per il perche') */
  padding: 0.45rem 0.6rem;
  overflow: visible;
  cursor: pointer;
  transition: z-index 0s;
  user-select: none;
}
/* lo strato decorativo: un solo <svg viewBox="0 0 300 108"
   preserveAspectRatio="none">, stirato alla dimensione reale del nodo
   (che varia parecchio, da 90x72 a 795x177) - dietro al testo (z-index
   negativo, stesso trucco di .graph-node--loc-bg::before) */
.graph-node-shape {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  z-index: -1;
  overflow: visible;
  filter: drop-shadow(0 1px 3px rgba(0, 0, 0, 0.35));
  transition: filter 0.15s ease;
}
.graph-node-shape .shape-outline {
  fill: color-mix(in srgb, var(--node-color, var(--cat-nota)) 16%, var(--bg-elevated));
  stroke: color-mix(in srgb, var(--node-color, var(--cat-nota)) 65%, var(--border));
  stroke-width: 2;
  transition: fill 0.15s ease, stroke 0.15s ease;
}
/* angolo piegato di "nota" */
.graph-node-shape .shape-fold { fill: var(--node-color, var(--cat-nota)); fill-opacity: 0.5; }
/* rulli laterali della "pergamena" di evento - leggermente piu' saturi
   del centro, per leggersi come un pezzo a parte (il rullo), non solo
   come un bordo arrotondato */
.graph-node-shape .shape-cap {
  fill: color-mix(in srgb, var(--node-color, var(--cat-nota)) 30%, var(--bg-elevated));
}
/* bozzi della nuvola di ricordo: stessa tinta del corpo principale */
.graph-node-shape .shape-puff {
  fill: color-mix(in srgb, var(--node-color, var(--cat-nota)) 16%, var(--bg-elevated));
}
/* emblema di morte: teschio/tibie piene in tinta, i "buchi" (occhi,
   naso, denti) nel colore di sfondo della card - non un altro colore a
   caso, cosi' regge qualunque palette */
.graph-node-shape .shape-bone { stroke: var(--node-color, var(--cat-nota)); stroke-width: 5; stroke-linecap: round; }
.graph-node-shape .shape-bone-fill { fill: var(--node-color, var(--cat-nota)); }
.graph-node-shape .shape-hole { fill: var(--bg-elevated); }

/* sopra a tutto il resto mentre e' sotto al mouse/a fuoco - vale per
   OGNI nodo, sagoma nuova o .graph-node--loc-bg (che non ne ha una) */
.graph-node:hover,
.graph-node:focus-visible {
  z-index: 5;
}
.graph-node:hover .graph-node-shape,
.graph-node:focus-visible .graph-node-shape {
  filter: drop-shadow(0 3px 10px rgba(0, 0, 0, 0.4));
}
.graph-node:hover .shape-outline,
.graph-node:focus-visible .shape-outline {
  fill: color-mix(in srgb, var(--node-color, var(--cat-nota)) 28%, var(--bg-elevated));
  stroke: var(--node-color, var(--accent-soft));
}
.graph-node:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
/* seconda campagna: bordo tratteggiato, prima su .graph-node stesso, ora
   sulla sagoma (l'unica cosa con un vero contorno da tratteggiare) */
.graph-node.campaign-seconda .shape-outline { stroke-dasharray: 5 4; }

/* il testo vero e proprio - sempre sopra alla sagoma, mai deformato.
   NIENTE "width" qui: un figlio flex con width:100% dentro a un
   contenitore "width:fit-content" (vedi .graph-node, dimensione decisa
   da JS) crea una dipendenza circolare - il browser finiva per
   ignorare il padding nel calcolo di "quanto deve essere largo il
   contenitore", il testo restava attaccato ai bordi (segnalato dal
   vivo). Il margine ora sta tutto sul CONTENITORE (.graph-node, sotto),
   che ha una larghezza vera decisa da JS, non "fit-content" del figlio -
   li' il padding, anche in percentuale, si calcola in modo prevedibile. */
.graph-node-text {
  min-width: 0; /* permette al testo di andare a capo invece di forzare il contenitore a slargarsi */
  /* rete di sicurezza vera contro il testo che esce dal riquadro: una
     parola singola piu' larga dello spazio rimasto (frequente sulle
     sagome coi margini piu' larghi, es. "morte" 34%+12%, su nodi piccoli)
     andrebbe altrimenti in overflow orizzontale invece di andare a capo -
     "anywhere" (a differenza di "break-word") si applica anche quando non
     c'e' nessun punto di rottura "naturale", garanzia assoluta */
  overflow-wrap: anywhere;
  font-family: var(--font-ui);
  font-size: 11px;
  line-height: 1.4;
  color: var(--text);
  text-align: center;
  /* bug reale trovato dal vivo: il testo di draw.io puo' avere veri "\n"
     (da <br>/</p>/</div>/<hr>, vedi stripHtml in drawio-sync.js) ma senza
     questa riga il default (white-space:normal) li appiattisce tutti in
     un semplice spazio - gli "a capo"/paragrafi dell'originale non si
     vedevano mai, "pre-line" li rispetta (e continua comunque ad andare
     a capo da solo dove serve, non e' "pre": non blocca il wrap) */
  white-space: pre-line;
}
/* segnavia (luogo) e banner (arco-narrativo): punte a freccia sui lati,
   serve piu' margine orizzontale perche' il testo non le tocchi mai.
   ":not(.graph-node--loc-bg)" e' necessario: un luogo CON mappa collegata
   ha GIA' la classe "graph-node--luogo" (categoria) ma NON la sagoma a
   cartello (saltata apposta per non intralciare lo sfondo mappa, vedi
   graph.js) - senza l'esclusione avrebbe preso lo stesso padding extra
   senza averne il motivo, restringendo il testo per niente */
.graph-node--luogo:not(.graph-node--loc-bg),
.graph-node--arco-narrativo { padding-left: 22%; padding-right: 22%; }
/* esagono (oggetti) e scudo (associazione): stesso motivo, margine un
   filo minore */
.graph-node--oggetti,
.graph-node--associazione { padding-left: 20%; padding-right: 20%; }
/* nuvola di ricordo: i bozzi occupano la fascia superiore, il testo si
   appoggia in basso */
.graph-node--ricordo { align-items: flex-end; padding-bottom: 0.6rem; }
/* bandiera di morte: l'emblema (teschio+tibie) occupa la fascia
   sinistra, il testo si appoggia a destra */
.graph-node--morte {
  justify-content: flex-end;
  padding-left: 34%;
  padding-right: 12%;
}
.graph-node--morte .graph-node-text { text-align: right; }

/* nodo "luogo" collegato a un luogo gia' schedato CON una mappa (vedi
   locationByNormName/graph.js). Lo sfondo NON sta piu' sull'elemento
   stesso ma su uno pseudo-elemento a parte (::before, sotto) apposta per
   due motivi, scoperti dal vivo:
   1) "background-size: <un-solo-valore>" imposta SOLO la larghezza
      (l'altezza diventa "auto", proporzionale) - su un riquadro con un
      rapporto larghezza/altezza diverso da quello della mappa lascia
      vuoti sopra/sotto (una percentuale che valga "cover" su ENTRAMBI gli
      assi, qualunque sia la forma del box, richiede il vero cover: non
      esiste un equivalente numerico animabile).
   2) .graph-node:hover (regola generica, vedi sotto) imposta "background"
      come proprieta' ABBREVIATA - quando vince la cascata (stessa
      specificita', ordine nel file) azzera anche le sotto-proprieta' non
      nominate esplicitamente sull'ELEMENTO STESSO.
   Uno pseudo-elemento dedicato risolve entrambi in un colpo solo: usa
   sempre "cover" vero (mai vuoti, mai distorsioni, qualunque forma del
   box) e non e' mai toccato dalla regola generica (quella riguarda solo
   il "background" dell'elemento .graph-node, non del suo ::before) -
   niente piu' inseguire quali sotto-proprieta' ridichiarare dove. Lo zoom
   all'hover (transform, non background-size) si applica SOLO a questo
   pseudo-elemento: il contenitore vero e proprio (bordo, testo) non si
   muove mai, "overflow:hidden" sul contenitore ritaglia lo pseudo-elemento
   zoomato dentro ai suoi angoli arrotondati. */
.graph-node--loc-bg {
  /* NIENTE "position" qui: .graph-node e' gia' position:absolute (il
     posizionamento vero, left/top via JS) - basta gia' quello a fare da
     contenitore per lo pseudo-elemento assoluto qui sotto. Ridichiararla
     (anche a "relative") la sovrascriverebbe e romperebbe il
     posizionamento sulla mappa. */
  overflow: hidden;
  display: flex;
  align-items: center;
  /* TENTATIVO ABBANDONATO: stessa sagoma "cartello" degli altri luoghi via
     clip-path (riuso di NODE_SHAPES.luogo) - rotto vistosamente dal vivo
     ("qualcosa di catastrofico", screenshot alla mano), probabilmente per
     le proporzioni di questi riquadri molto diverse dal cartello standard
     300x108. Tolto, sia qui che in graph.js (ensureLocBgClipPath) -
     resta il rettangolo arrotondato di prima. */
  border: 2px solid color-mix(in srgb, var(--node-color, var(--cat-nota)) 65%, var(--border));
  border-left: 4px solid var(--node-color, var(--cat-nota));
  border-radius: 6px;
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.35);
  transition: border-color 0.15s ease, box-shadow 0.15s ease;
  /* il testo resta allineato a sinistra (provato centrato, non andava
     bene) - va ridichiarato esplicitamente: la base .graph-node ora centra
     tutti gli altri, e senza queste due righe quella regola vincerebbe
     anche qui (nessun'altra dichiarazione la sovrascriverebbe) */
  justify-content: flex-start;
  color: #fff;
  /* prima ereditava l'11px generico di .graph-node - troppo piccolo per
     leggersi comodo sopra a una foto invece che su un semplice sfondo
     tinta unita */
  font-size: 15px;
  font-weight: 700;
  /* "falso contorno nero" ai caratteri (4 ombre corte in croce, offset
     minimo) invece di -webkit-text-stroke: quest'ultimo e' supportato
     solo su motori WebKit/Blink, le ombre funzionano ovunque - la quarta
     ombra, piu' sfocata, aggiunge un filo di profondita' sotto al testo */
  text-shadow:
    -1px -1px 0 #000, 1px -1px 0 #000, -1px 1px 0 #000, 1px 1px 0 #000,
    0 2px 5px rgba(0, 0, 0, 0.8);
  /* NIENTE "z-index" qui: .graph-node ha gia' z-index:2 (l'ordine giusto
     rispetto alle immagini/altri nodi sulla mappa) - ridichiararlo (anche
     a 0) lo abbasserebbe sotto agli altri elementi. Il contesto di
     impilamento che serve allo pseudo-elemento qui sotto (z-index:-1, per
     stare dietro al testo) esiste gia' grazie a position:absolute +
     quello stesso z-index:2 ereditato, non serve ridichiarare nulla qui. */
}
/* stesso discorso di justify-content sopra, ma per l'allineamento del
   testo dentro a .graph-node-text: senza questa riga erediterebbe il
   nuovo "text-align:center" della base */
.graph-node--loc-bg .graph-node-text { text-align: left; }
/* rinforzo del bordo/ombra all'hover - equivalente di
   .graph-node:hover/.shape-outline per chi non ha una sagoma SVG */
.graph-node--loc-bg:hover,
.graph-node--loc-bg:focus-visible {
  border-color: var(--node-color, var(--accent-soft));
  box-shadow: 0 0 0 1px var(--node-color, var(--accent-soft)), 0 3px 10px rgba(0, 0, 0, 0.4);
}
/* seconda campagna: qui non c'e' una sagoma SVG da tratteggiare (vedi
   .campaign-seconda .shape-outline piu' sopra), il bordo e' quello vero
   di questo riquadro */
.graph-node--loc-bg.campaign-seconda { border-left-style: dashed; }
/* forma del contenitore scelta a mano (vedi applyLocBgShape in graph.js):
   qui la mappa (lo pseudo-elemento ::before sopra) resta SEMPRE visibile
   qualunque forma si scelga - a differenza del tentativo abbandonato col
   clip-path (vedi nota piu' sopra), border-radius si adatta da solo a
   QUALUNQUE proporzione del riquadro reale (niente piu' rotture di
   proporzione). "Predefinito" (nessuna di queste classi) resta il
   ritaglio a due angoli via clip-path, invariato. overflow:hidden gia'
   presente su .graph-node--loc-bg ritaglia anche lo pseudo-elemento. */
.graph-node--loc-bg.node-shape-rect { border-radius: 20px; }
.graph-node--loc-bg.node-shape-ellipse,
.graph-node--loc-bg.node-shape-circle { border-radius: 50%; }
/* NOTA: era stata aggiunta qui anche una regola .loc-bg-tip-border (due
   segmenti SVG per "completare" il bordo sulla punta tagliata a 45 gradi)
   - tolta di nuovo, il risultato dal vivo non convinceva (segnalato con
   screenshot). La punta resta senza bordo proprio, il resto del
   contorno (3 lati dritti) e' il border CSS qui sopra, invariato. */
.graph-node--loc-bg::before {
  content: '';
  position: absolute;
  inset: 0;
  z-index: -1; /* dietro al testo (in-flow), sopra al bordo/sfondo del contenitore */
  background-image: linear-gradient(rgba(10, 8, 6, 0.6), rgba(10, 8, 6, 0.6)), var(--loc-bg-image);
  background-size: cover, cover;
  background-position: center, var(--loc-bg-position, center);
  background-repeat: no-repeat, no-repeat;
  transform: scale(1);
  transform-origin: 50% 50%;
  transition: transform 0.4s ease-out, background-image 0.15s ease;
}
.graph-node--loc-bg:hover::before,
.graph-node--loc-bg:focus-visible::before {
  background-image: linear-gradient(rgba(10, 8, 6, 0.42), rgba(10, 8, 6, 0.42)), var(--loc-bg-image);
  transform: scale(1.15);
}

/* nodo "assorbito" da un'immagine identificata li' accanto (vedi
   suppressNodeForImage in graph.js) - resta nel DOM (cosi' si puo'
   rimostrare togliendo il collegamento) ma non occupa spazio */
.graph-node--hidden { display: none; }

.graph-image {
  position: absolute;
  z-index: 1; /* sotto i nodi di testo (vedi .graph-node) */
  box-sizing: border-box;
  border-radius: 8px;
  border: 1px solid var(--border);
  object-fit: cover;
  /* pointer-events:auto (a differenza del resto della mappa) serve per
     poter agganciare l'hover qui sotto; il drag del pan resta comunque
     intatto perche' il mousedown continua a risalire fino a #graph-viewport
     (dove sta il listener) - basta pero' disattivare il drag nativo
     dell'immagine via JS (img.draggable = false), altrimenti il browser
     avvierebbe il suo drag-and-drop invece di far scorrere la mappa. */
  pointer-events: auto;
  cursor: zoom-in;
  transform: scale(1);
  transform-origin: 50% 50%;
  transition: transform 0.25s ease, border-color 0.2s ease, box-shadow 0.25s ease;
  /* il reset globale mette "max-width:100%" su ogni img (utile altrove per
     non sforare il contenitore) - ma qui #graph-world ha width:0 (serve
     solo come origine delle coordinate per i figli posizionati in
     absolute), quindi quel 100% varrebbe 0 e schiaccerebbe ogni immagine a
     una fessura. Le dimensioni qui sono sempre esplicite via JS. */
  max-width: none;
  max-height: none;
}
.graph-image:hover {
  /* z-index alto: deve superare anche i nodi di testo (z-index:2) per non
     restare tagliata dalla card vicina mentre si ingrandisce */
  z-index: 50;
  border-color: var(--accent-soft);
  box-shadow: 0 10px 28px rgba(0, 0, 0, 0.55);
  transform: scale(1.7);
}
/* durante un trascinamento della mappa il cursore resta "grabbing" anche
   sopra un'immagine, invece di passare a "zoom-in" */
#graph-viewport.dragging .graph-image { cursor: grabbing; }

/* forma della cornice scelta a mano (richiesto dal vivo: "una volta
   selezionato un item, permetti di cambiare la forma del contenitore" -
   vedi buildImageShapeChanger in graph.js) - "rect" resta nel riquadro
   originale con angoli piu' arrotondati del solito 8px di default;
   "ellipse"/"circle" condividono la STESSA regola (border-radius:50% su
   un riquadro non quadrato produce gia' da solo un'ellisse, nessuna
   regola in piu' da scrivere per quella) - la differenza fra le due la
   fa JS, non il CSS: per "circle" buildTimelineImageEl forza il
   riquadro a un quadrato (il lato piu' corto, centrato) PRIMA che questa
   regola lo arrotondi, altrimenti sarebbe la stessa identica ellisse */
.graph-image--shape-rect { border-radius: 20px; }
.graph-image--shape-ellipse, .graph-image--shape-circle { border-radius: 50%; }

/* immagini collegate a una scheda di Personaggi (vedi
   data/timeline-character-links.json): un anello dorato sempre visibile,
   non solo in hover, cosi' si vede subito quali ritratti si possono
   aprire senza doverli passare in rassegna uno per uno */
.graph-image.has-link {
  cursor: pointer;
  box-shadow: 0 0 0 2px var(--accent-soft);
}
.graph-image.has-link:hover,
.graph-image.has-link:focus-visible {
  box-shadow: 0 0 0 2px var(--accent), 0 10px 28px rgba(0, 0, 0, 0.55);
}
.graph-image.has-link:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }

/* segnaposto per un'immagine a link esterno non scaricata all'ultimo
   salvataggio (vedi openBrokenImageDetail in graph.js) - stesso box di un
   ritratto vero (stessa posizione/dimensione), ma un grosso "?" al posto
   della foto mancante, cosi' si nota subito invece di sparire in silenzio */
.graph-image--broken {
  display: flex;
  align-items: center;
  justify-content: center;
  background: repeating-linear-gradient(
    45deg,
    rgba(204, 0, 0, 0.12), rgba(204, 0, 0, 0.12) 8px,
    rgba(204, 0, 0, 0.22) 8px, rgba(204, 0, 0, 0.22) 16px
  );
  border: 2px dashed var(--cat-morte);
  color: var(--cat-morte);
  font-family: var(--font-body);
  font-weight: 700;
  /* font-size impostato via JS (graph.js), proporzionale al box di questa
     immagine specifica - "%"/clamp non basterebbero, qui serve una
     frazione della LARGHEZZA/ALTEZZA proprie dell'elemento, non del
     genitore */
  user-select: none;
}
.graph-image--broken:hover,
.graph-image--broken:focus-visible {
  z-index: 50;
  box-shadow: 0 10px 28px rgba(0, 0, 0, 0.55);
  outline: none;
}

/* nome attaccato al ritratto quando la card col solo nome e' stata
   nascosta (vedi skipNodeIds in graph.js) - il ritratto stesso e' "il
   nodo", questa e' solo l'etichetta */
.graph-image-label {
  position: absolute;
  z-index: 2;
  transform: translateX(-50%);
  pointer-events: none;
  font-family: var(--font-ui);
  font-size: 0.68rem;
  letter-spacing: 0.02em;
  padding: 0.15rem 0.55rem;
  border-radius: 999px;
  background: rgba(15, 12, 10, 0.85);
  border: 1px solid var(--accent-soft);
  color: var(--text);
  white-space: nowrap;
}

/* il ritratto dentro al pannello di dettaglio (riuso di .profile-portrait,
   pensato per stare affiancato al testo nella pagina Personaggi) qui sta
   da solo in cima a un pannello stretto: prima era un cerchio piccolo e
   fisso (120px) - ora e' largo quanto il pannello stesso (il margine e'
   gia' il padding di .detail-panel, non serve ripeterlo qui), cosi' si
   vede il personaggio/luogo molto meglio. aspect-ratio (non height fissa)
   tiene il cerchio perfetto qualunque sia la larghezza reale del pannello
   (varia con 90vw sotto i 400px, vedi .detail-panel) - qui .profile-portrait
   e' solo la CORNICE (overflow:hidden, ereditato dalla classe base in
   registry.css); l'<img> vero e proprio dentro (.img-position-inner) ha
   object-fit:cover e ritaglia senza MAI distorcere (stessa logica di
   .graph-image sulla mappa), e riceve object-position/transform per
   l'inquadratura scelta con l'editor di posizione (vedi buildMainImageEl
   in graph.js) senza mai far crescere la cornice stessa */
#detail-content .profile-portrait {
  display: block;
  width: 100%;
  height: auto;
  aspect-ratio: 1 / 1;
  margin: 0 auto 0.75rem;
  border-width: 2px;
}

/* --- icone delle associazioni a fianco del ritratto di un personaggio -
   richiesto dal vivo: solo se ce n'e' almeno una (vedi
   buildAffiliationIconsEl/openCharacterDetail in graph.js), il ritratto
   si restringe per fare posto invece di restare a piena larghezza -
   dimensione fissa, impilate dall'alto verso il basso */
.detail-portrait-row {
  display: flex;
  align-items: flex-start;
  gap: 0.6rem;
  margin-bottom: 0.75rem;
}
.detail-portrait-row .profile-portrait {
  flex: 1 1 auto;
  min-width: 0;
  margin: 0; /* il margine sotto lo porta gia' la row */
}
.detail-affiliation-icons {
  display: flex;
  flex-direction: column;
  gap: 0.4rem;
  flex: 0 0 auto;
}
.detail-affiliation-icon {
  display: block;
  width: 46px;
  height: 46px;
  border-radius: 6px;
  overflow: hidden;
  border: 1px solid var(--border);
  background: var(--bg-elevated);
  transition: border-color 0.15s ease;
}
.detail-affiliation-icon:hover { border-color: var(--accent-soft); }
.detail-affiliation-icon img {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
}

/* --- riga di panoramiche dei luoghi (dal profilo, campo Affiliazioni) in
   fondo al pannello di un personaggio - richiesto dal vivo, "sulla
   stessa scia" delle icone associazioni sopra, ma qui e' l'ALTEZZA a
   essere fissa (non la larghezza): una panoramica e' pensata larga,
   rimpicciolirla in un quadrato la snaturerebbe - scala mantenendo le
   proporzioni originali, in riga con le altre (a capo se non entrano) */
.detail-affiliation-locations {
  display: flex;
  flex-wrap: wrap;
  gap: 0.5rem;
  margin: 0.5rem 0;
}
.detail-affiliation-location {
  display: block;
  height: 64px;
  max-width: 100%;
  border-radius: 6px;
  overflow: hidden;
  border: 1px solid var(--border);
  background: var(--bg-elevated);
  transition: border-color 0.15s ease;
}
.detail-affiliation-location:hover { border-color: var(--accent-soft); }
.detail-affiliation-location img {
  display: block;
  height: 100%;
  width: auto;
  max-width: 100%;
  object-fit: cover;
}

/* la mappa di un luogo (location.mapImage), mostrata SOTTO al cerchio
   (panoramica/ritratto) - rettangolare con angoli arrotondati, a
   differenza del cerchio qui sopra (vedi openLocationDetail in graph.js) */
.detail-map-image {
  display: block;
  width: 100%;
  height: auto;
  aspect-ratio: 16 / 10;
  object-fit: cover;
  border-radius: 10px;
  border: 1px solid var(--border);
  margin-bottom: 0.75rem;
}

/* al posto del ritratto/immagine principale quando non ce n'e' nessuna
   (es. dopo aver slegato tutte le immagini dalla galleria - vedi
   buildMainImageEl in graph.js) - eredita comunque forma/misura dalla
   stessa classe base (.profile-portrait, usata ora sia per personaggi
   che per luoghi), qui solo l'aspetto "vuoto" */
.detail-image-placeholder {
  display: flex;
  align-items: center;
  justify-content: center;
  background: var(--bg-elevated);
  border-style: dashed;
  color: var(--text-dim);
  font-family: var(--font-ui);
  /* prima 2rem fisso, andava bene sul cerchio piccolo da 120px - ora che
     .profile-portrait e' largo quanto il pannello (fino a ~300px) un "?"
     cosi' piccolo si perderebbe al centro */
  font-size: 4rem;
  font-weight: 700;
}

/* --- modulo per identificare a mano un'immagine non riconosciuta ------- */
.assign-form { margin-top: 1rem; }
.assign-type-buttons {
  display: flex;
  flex-wrap: wrap;
  gap: 0.4rem;
  margin-bottom: 0.9rem;
}
.assign-type-btn {
  background: var(--bg-elevated);
  border: 1px solid var(--border);
  color: var(--text-dim);
  border-radius: 999px;
  padding: 0.35rem 0.8rem;
  font-family: var(--font-ui);
  font-size: 0.8rem;
  cursor: pointer;
}
.assign-type-btn:hover { border-color: var(--accent-soft); color: var(--text); }
.assign-type-btn.active {
  background: var(--accent-soft);
  border-color: var(--accent);
  color: var(--bg);
  font-weight: 600;
}
.assign-picker { margin-bottom: 0.9rem; }
.assign-select,
.assign-new-input {
  display: block;
  width: 100%;
  box-sizing: border-box;
  background: var(--bg-elevated);
  border: 1px solid var(--border);
  color: var(--text);
  border-radius: 6px;
  padding: 0.5rem 0.6rem;
  font-family: var(--font-ui);
  font-size: 0.85rem;
  margin-bottom: 0.5rem;
}
.assign-new-input.hidden { display: none; }
.assign-save-btn {
  background: var(--bg-elevated);
  border: 1px solid var(--accent-soft);
  color: var(--accent);
  border-radius: 6px;
  padding: 0.5rem 1.1rem;
  font-family: var(--font-ui);
  font-size: 0.85rem;
  cursor: pointer;
}
.assign-save-btn:hover:not(:disabled) { background: var(--accent-soft); color: var(--bg); }
.assign-save-btn:disabled { opacity: 0.4; cursor: default; }

/* barra di accesso nella testata: la regola era qui, specifica per
   #graph-token-bar - generalizzata in ".site-header .token-bar" (vedi
   theme.css), ora condivisa da OGNI pagina che tiene la barra in
   testata, non solo dalla Timeline. Nulla da ridichiarare qui. */

/* --- legenda ------------------------------------------------------- */
#graph-legend {
  position: fixed;
  left: 1.25rem;
  bottom: 1.25rem;
  background: var(--bg-elevated);
  border: 1px solid var(--border);
  border-radius: 8px;
  padding: 0.65rem 0.85rem;
  font-family: var(--font-ui);
  font-size: 0.72rem;
  color: var(--text-dim);
  z-index: 10;
  display: grid;
  grid-template-columns: repeat(2, auto);
  gap: 0.25rem 0.9rem;
}
.legend-item { display: flex; align-items: center; gap: 0.4rem; white-space: nowrap; }
.legend-swatch { width: 10px; height: 10px; border-radius: 3px; flex: 0 0 auto; }
.legend-item.major .legend-swatch { width: 16px; height: 3px; border-radius: 2px; }

/* --- controlli zoom -------------------------------------------------- */
#graph-zoom-controls {
  position: fixed;
  right: 1.25rem;
  bottom: 1.25rem;
  display: flex;
  flex-direction: column;
  gap: 0.4rem;
  z-index: 10;
}
#graph-zoom-controls button {
  width: 34px;
  height: 34px;
  border-radius: 6px;
  background: var(--bg-elevated);
  border: 1px solid var(--border);
  color: var(--text);
  font-size: 1.1rem;
  cursor: pointer;
  line-height: 1;
}
#graph-zoom-controls button:hover { border-color: var(--accent-soft); color: var(--accent); }

/* --- pannello di dettaglio ------------------------------------------- */
/* base condivisa con Relazioni in theme.css - qui solo cio' che
   differenzia il pannello della Timeline: e' sopra alla mappa che si puo'
   trascinare, quindi serve un z-index esplicito e un'ombra piu' marcata */
.detail-panel {
  width: 360px;
  max-width: 90vw;
  z-index: 20;
  box-shadow: -8px 0 24px rgba(0, 0, 0, 0.35);
}
.detail-category {
  display: inline-block;
  font-size: 0.68rem;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  padding: 0.15rem 0.55rem;
  border-radius: 999px;
  border: 1px solid var(--node-color, var(--cat-nota));
  color: var(--node-color, var(--cat-nota));
  margin-bottom: 0.75rem;
}
/* cambio tipologia del nodo (richiesto dal vivo), solo per chi ha fatto
   accesso - vedi buildCategoryChanger/saveNodeCategory in graph.js.
   Riga separata sotto alla pillola di sola lettura qui sopra (che resta
   com'e' per chi non e' connesso), non ci si prova a fondere le due -
   piu' semplice e coerente con gli altri controlli di modifica del
   pannello (es. "Rimuovi collegamento", sempre sotto a cio' che tocca). */
.detail-category-changer {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  margin: 0 0 0.9rem;
}
/* il margine negativo (per stare incollato sotto alla pillola qui sopra)
   va SOLO quando la pillola e' davvero il fratello immediato che precede
   - bug reale visto dal vivo (screenshot): buildImageShapeChanger/
   buildShapeChanger vengono appesi anche altrove (dopo la galleria
   immagini in openCharacterDetail/openLocationDetail/
   openSimpleEntryDetail, mai subito dopo .detail-category li'), e il
   margine negativo indiscriminato faceva sovrapporre il menu' a tendina
   al testo/pulsante appena sopra quando quello andava a capo su due
   righe */
.detail-category + .detail-category-changer { margin-top: -0.4rem; }
.detail-category-select {
  font-family: var(--font-ui);
  font-size: 0.75rem;
  background: var(--bg-elevated);
  color: var(--text);
  border: 1px solid var(--border);
  border-radius: 5px;
  padding: 0.2rem 0.4rem;
  cursor: pointer;
}
.detail-category-select:disabled { opacity: 0.6; cursor: default; }
.detail-campaign {
  font-size: 0.75rem;
  color: var(--text-dim);
  margin-bottom: 0.9rem;
}
.detail-text {
  font-family: var(--font-body);
  font-size: 1rem;
  line-height: 1.6;
  color: var(--text);
  white-space: pre-wrap;
}
/* testo libero del profilo (riassunto/storia/note, scritti nelle schede
   Personaggi/Luoghi/Oggetti/...): senza questo gli "a capo" scritti
   dall'autore venivano appiattiti in un solo blocco - "pre-line" li
   rispetta e continua ad andare a capo da solo dove serve */
.profile-summary,
.profile-background,
.profile-notes { white-space: pre-line; }
/* affiliazioni del profilo, come "pillole" (vedi buildAffiliationsEl in
   graph.js) - mai state stilate finora: uscivano come testo semplice,
   tutte attaccate senza nessuno spazio. Stesso linguaggio grafico di
   .detail-category qui sopra (pillola vuota, solo bordo+testo colorati),
   cosi' risultano subito chiaramente identificabili E' un elenco, non
   un'unica etichetta. Il colore segue la categoria del riscontro trovato
   in un registro (Luoghi/Associazioni/Oggetti/Altro, vedi
   resolveAffiliationCategory) - nessun riscontro = colore neutro di
   default (--border/--text-dim), non e' un errore, solo un nome libero
   mai schedato altrove. */
.profile-affiliations {
  display: flex;
  flex-wrap: wrap;
  gap: 0.4rem;
  margin: 0 0 0.9rem;
}
.profile-tag {
  display: inline-block;
  font-family: var(--font-ui);
  font-size: 0.74rem;
  letter-spacing: 0.02em;
  padding: 0.2rem 0.65rem;
  border-radius: 999px;
  border: 1px solid var(--border);
  color: var(--text-dim);
}
.profile-tag--luogo { border-color: var(--cat-luogo); color: var(--cat-luogo); }
.profile-tag--associazione { border-color: var(--cat-associazione); color: var(--cat-associazione); }
.profile-tag--oggetti { border-color: var(--cat-oggetti); color: var(--cat-oggetti); }
.profile-tag--nota { border-color: var(--cat-nota); color: var(--cat-nota); }
/* riga "Apri in X →"/"Vai alla sessione..." + l'eventuale "×" per slegare
   (vedi buildRemovableRow in graph.js) - il link/l'etichetta occupa tutto
   lo spazio libero, la "×" resta della sua dimensione naturale accanto */
.detail-link-row {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  margin-top: 1.25rem;
}
.detail-session-link, .detail-remove-label {
  flex: 1 1 auto;
  min-width: 0;
  padding: 0.6rem 0.9rem;
  border: 1px solid var(--accent-soft);
  border-radius: 6px;
  color: var(--accent);
  font-family: var(--font-ui);
  font-size: 0.85rem;
  text-align: center;
  text-decoration: none;
}
a.detail-session-link:hover {
  background: var(--accent-soft);
  color: var(--bg);
}
/* variante "etichetta" (nessuna scheda su cui linkare, vedi
   openSimpleTagDetail) - stesso ingombro del link ma non cliccabile,
   toni piu' spenti per non sembrare un altro pulsante */
.detail-remove-label {
  color: var(--text-dim);
  border-color: var(--border);
}

/* "×" condivisa da ogni collegamento removibile del pannello (vedi
   buildRemoveIconBtn in graph.js) - stesso cerchio piccolo gia' usato
   altrove nel sito per la stessa azione (es. .detail-rels-remove nella
   pagina Relazioni), qui sostituisce i vecchi grossi pulsanti "Rimuovi
   collegamento" a tutta larghezza, uno stile diverso per vista - uniforme
   ovunque, richiesto dal vivo. ".detail-remove-wrap" tiene insieme il
   bottone e il suo messaggio di stato (vuoto finche' non serve). */
.detail-remove-wrap { flex: 0 0 auto; display: flex; align-items: center; }
.detail-remove-icon {
  width: 24px;
  height: 24px;
  line-height: 22px;
  padding: 0;
  flex: 0 0 auto;
  background: none;
  border: 1px solid var(--border);
  border-radius: 50%;
  color: var(--text-dim);
  font-size: 0.9rem;
  cursor: pointer;
}
.detail-remove-icon:hover:not(:disabled) { border-color: var(--cat-morte); color: var(--cat-morte); background: none; }
.detail-remove-icon:disabled { opacity: 0.4; cursor: default; }
.detail-remove-wrap .row-status { margin-left: 0.4rem; }

/* --- "Collega sessione" (vedi buildSessionLinkControl in graph.js, solo
   sul pannello generico dei nodi di testo/openDetail) - due stati: gia'
   collegato (link+"×" di .detail-link-row qui sopra), oppure SEMPRE la
   tendina con l'elenco delle sessioni quando manca un collegamento
   (richiesto dal vivo: niente piu' un bottone di mezzo da cliccare prima
   di vederla - vedi anche il commento su renderPicker in graph.js) */
.detail-session-field { margin-top: 0.6rem; }
.detail-session-field .detail-link-row { margin-top: 0; }
.detail-session-select {
  display: block;
  width: 100%;
  box-sizing: border-box;
  padding: 0.5rem 0.6rem;
  background: var(--bg-elevated);
  border: 1px solid var(--accent-soft);
  border-radius: 6px;
  color: var(--text);
  font-family: var(--font-body);
  font-size: 0.85rem;
}
.detail-session-select:focus { outline: none; border-color: var(--accent); }

/* pulsante per slegare un'immagine da un tag assegnato per errore (vedi
   clearImageTag) - stile piu' defilato del pulsante di
   salvataggio, non e' l'azione principale della vista ma resta accessibile.
   Resta usato solo da "No, lascialo" nel prompt dei quasi-omonimi (vedi
   buildSimilarNodePrompt) - le "Rimuovi collegamento" vere e proprie sono
   diventate la "×" qui sopra. */
.detail-clear-btn {
  display: block;
  width: 100%;
  box-sizing: border-box;
  margin-top: 0.6rem;
  padding: 0.5rem 0.9rem;
  background: none;
  border: 1px solid var(--border);
  border-radius: 6px;
  color: var(--text-dim);
  font-family: var(--font-ui);
  font-size: 0.8rem;
  cursor: pointer;
}
.detail-clear-btn:hover:not(:disabled) { border-color: var(--cat-morte); color: var(--cat-morte); }
.detail-clear-btn:disabled { opacity: 0.4; cursor: default; }

/* variante piu' contrastata di .detail-clear-btn, per un'azione PRINCIPALE
   del pannello (es. "Carica un'immagine per sostituirla" nel segnaposto
   "?" - vedi openBrokenImageDetail in graph.js): bordo/testo/sfondo pieni
   invece che spenti, cosi' non si confonde con un pulsante secondario
   solo perche' condivide la forma "blocco a tutta larghezza" */
.detail-upload-btn {
  display: block;
  width: 100%;
  box-sizing: border-box;
  margin-top: 0.6rem;
  padding: 0.55rem 0.9rem;
  background: rgba(201, 162, 75, 0.14);
  border: 1px solid var(--accent-soft);
  border-radius: 6px;
  color: var(--accent);
  font-family: var(--font-ui);
  font-size: 0.85rem;
  font-weight: 600;
  cursor: pointer;
}
.detail-upload-btn:hover:not(:disabled) { background: var(--accent-soft); border-color: var(--accent); color: var(--bg); }
.detail-upload-btn:disabled { opacity: 0.5; cursor: default; }

/* galleria delle immagini di uno stesso personaggio/luogo gia' collegate
   nella timeline, piu' un riquadro per caricarne una nuova (vedi
   buildImageGallery in graph.js) */
.detail-gallery-wrap {
  margin-top: 1rem;
  padding-top: 0.9rem;
  border-top: 1px solid var(--border);
}
/* etichetta/pulsante che apre e chiude la galleria qui sotto - parte
   chiusa di default (vedi .detail-gallery-body/buildImageGallery in
   graph.js), stesso schema "▾/▸" gia' usato per "▾ Profilo" */
.detail-gallery-toggle {
  display: block;
  width: 100%;
  text-align: left;
  background: none;
  border: none;
  padding: 0;
  font-family: var(--font-ui);
  font-size: 0.74rem;
  color: var(--text-dim);
  cursor: pointer;
}
.detail-gallery-toggle:hover { color: var(--text); }
.detail-gallery-body { margin-top: 0.5rem; }
.detail-gallery-body.hidden { display: none; }
.detail-gallery {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(56px, 1fr));
  gap: 0.5rem;
}
.detail-gallery-item { position: relative; }
.detail-gallery-thumb {
  display: block;
  width: 100%;
  aspect-ratio: 1;
  object-fit: cover;
  border-radius: 6px;
  border: 2px solid var(--border);
  cursor: pointer;
  transition: border-color 0.15s ease, transform 0.15s ease;
}
.detail-gallery-thumb:hover { border-color: var(--accent-soft); transform: scale(1.05); }
.detail-gallery-thumb.active { border-color: var(--accent); }
/* la "x" per slegare una singola immagine - sempre visibile (non solo in
   hover): il pannello si usa anche da mobile/touch, dove l'hover non c'e' */
.detail-gallery-remove {
  position: absolute;
  top: -6px;
  right: -6px;
  width: 18px;
  height: 18px;
  line-height: 16px;
  padding: 0;
  background: var(--bg);
  border: 1px solid var(--cat-morte);
  border-radius: 50%;
  color: var(--cat-morte);
  font-size: 0.75rem;
  cursor: pointer;
}
.detail-gallery-remove:hover { background: var(--cat-morte); color: var(--bg); }
/* il riquadro "+" per caricare un'immagine nuova, stessa taglia delle
   miniature cosi' la griglia resta ordinata */
.detail-gallery-add {
  width: 100%;
  aspect-ratio: 1;
  display: flex;
  align-items: center;
  justify-content: center;
  border-radius: 6px;
  border: 2px dashed var(--border);
  color: var(--text-dim);
  font-size: 1.4rem;
  cursor: pointer;
}
.detail-gallery-add:hover,
.detail-gallery-add:focus-visible { border-color: var(--accent-soft); color: var(--accent); }
.detail-gallery-file-input { display: none; }

/* proposta di conferma per un nodo dal nome simile ma non identico (vedi
   buildSimilarNodePrompt in graph.js) - palette diversa dal resto del
   pannello per segnalare che e' una domanda, non un'informazione */
.detail-similar-wrap {
  margin-top: 1rem;
  padding: 0.8rem 0.9rem;
  border-radius: 8px;
  background: var(--tooltip-bg);
  border: 1px solid var(--tooltip-border);
}
.detail-similar-text {
  font-family: var(--font-ui);
  font-size: 0.82rem;
  color: var(--text);
  margin: 0 0 0.6rem;
}
.detail-similar-buttons { display: flex; gap: 0.5rem; }
.detail-similar-buttons .assign-save-btn,
.detail-similar-buttons .detail-clear-btn { flex: 1 1 0; margin-top: 0; }

/* popup di caricamento immagine condiviso da tutte le gallerie (drag&drop,
   incolla, o scegli da disco) - vedi il "+" in buildImageGallery/graph.js */
#image-upload-dialog {
  position: relative;
  width: min(420px, 92vw);
  background: var(--bg-elevated);
  color: var(--text);
  border: 1px solid var(--border);
  border-radius: 10px;
  padding: 1.4rem 1.2rem 1.2rem;
}
#image-upload-dialog::backdrop { background: rgba(0,0,0,0.6); }
#image-upload-close {
  position: absolute;
  top: 0.6rem;
  right: 0.7rem;
  background: none;
  border: none;
  color: var(--text-dim);
  font-size: 1.2rem;
  cursor: pointer;
  line-height: 1;
}
#image-upload-dropzone {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 0.7rem;
  min-height: 160px;
  padding: 1.2rem;
  border: 2px dashed var(--border);
  border-radius: 8px;
  text-align: center;
  transition: border-color 0.15s ease, background 0.15s ease;
}
#image-upload-dropzone:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
#image-upload-dropzone.drag-over {
  border-color: var(--accent);
  background: rgba(255,255,255,0.04);
}
#image-upload-hint {
  font-family: var(--font-ui);
  font-size: 0.82rem;
  color: var(--text-dim);
  margin: 0;
}
#image-upload-browse {
  font-family: var(--font-ui);
  font-size: 0.82rem;
  background: var(--accent);
  color: var(--bg);
  border: none;
  border-radius: 6px;
  padding: 0.45rem 0.9rem;
  cursor: pointer;
}
#image-upload-browse:hover { filter: brightness(1.1); }
#image-upload-status {
  font-family: var(--font-ui);
  font-size: 0.78rem;
  color: var(--text-dim);
  margin: 0.6rem 0 0;
  text-align: center;
  min-height: 1em;
}

/* --- comparsa animata scorrendo la mappa (in prova, vedi il commento
   nel .js) - solo una dissolvenza+piccola scivolata verso l'alto, tolta
   da sola dopo la comparsa (vedi il setTimeout li') cosi' non lascia una
   "transition" in piu' che vincerebbe per specificita' su quella gia'
   definita per l'hover (es. lo zoom di .graph-image poco sopra) --------- */
.reveal-pending {
  opacity: 0;
  transform: translateY(10px) scale(0.94);
  transition: opacity 0.45s ease-out, transform 0.45s ease-out;
}
.reveal-pending.reveal-instant { transition: none; }
.reveal-pending.reveal-visible {
  opacity: 1;
  transform: none;
}
@media (prefers-reduced-motion: reduce) {
  .reveal-pending { transition: none; }
}
