privateboard 0.1.7 → 0.1.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -588,6 +588,22 @@
588
588
  border-top-width: 0;
589
589
  }
590
590
 
591
+ /* ─── Hide mermaid's internal render scratchpad ──────────────
592
+ During `mermaid.render(id, src)`, mermaid 10 briefly appends a
593
+ `<div id="dmermaid-...">` to document.body to measure layout +
594
+ produce the SVG. The div is removed once render() resolves,
595
+ but in the meantime it can flash visibly. Force it offscreen
596
+ so the user never sees mermaid's pre-takeover render at all.
597
+ The element still has natural dimensions for mermaid's layout
598
+ measurement — only its position + visibility is constrained. */
599
+ body > [id^="dmermaid-"] {
600
+ position: absolute !important;
601
+ left: -99999px !important;
602
+ top: 0 !important;
603
+ visibility: hidden !important;
604
+ pointer-events: none !important;
605
+ }
606
+
591
607
  /* ─── Hide mermaid SVG until spine palette is applied ─────────
592
608
  Pattern from user reports: "first time wrong, refresh OK" /
593
609
  strict alternation (1st wrong, 2nd right, 3rd wrong, 4th
@@ -611,6 +627,129 @@
611
627
  visibility: visible;
612
628
  }
613
629
 
630
+ /* ─── CSS-variable mermaid palette · timing-race-immune ──────
631
+ Final defense layer for the recurring "first time wrong,
632
+ refresh OK" bug. JS takeover fights timing with mermaid's
633
+ deferred render work; CSS doesn't have that problem — as
634
+ soon as the SVG is in the DOM, these rules apply. The
635
+ spine-specific colours are injected as CSS variables on
636
+ `:root` from JS in the swapSpine flow (see swapSpine /
637
+ mermaid.initialize block). Variables update when the spine
638
+ changes; CSS rules are static.
639
+
640
+ Specificity: each rule uses a 3-class+ chain plus
641
+ !important, beating mermaid's `.quadrant-fill { fill: ... }`
642
+ and any `fill="..."` presentation attribute. The element
643
+ selectors target every mermaid 10 chart-type's known
644
+ visual elements. */
645
+
646
+ /* Quadrant chart · 4 quadrant rects + data points */
647
+ .body pre.mermaid svg .quadrants .quadrant rect,
648
+ .body pre.mermaid svg g.quadrant > rect,
649
+ .body pre.mermaid svg [aria-roledescription="quadrantChart"] g.quadrant rect {
650
+ fill: var(--mm-q-fill, #1A1A18) !important;
651
+ }
652
+ .body pre.mermaid svg .data-points circle,
653
+ .body pre.mermaid svg circle.data-point,
654
+ .body pre.mermaid svg [aria-roledescription="quadrantChart"] .data-points circle {
655
+ fill: var(--mm-q-point, #C9A46B) !important;
656
+ stroke: var(--mm-q-bg, #1A1A18) !important;
657
+ }
658
+ .body pre.mermaid svg .quadrants line,
659
+ .body pre.mermaid svg .axis-line,
660
+ .body pre.mermaid svg .axisl-line,
661
+ .body pre.mermaid svg g.border line,
662
+ .body pre.mermaid svg [aria-roledescription="quadrantChart"] line,
663
+ .body pre.mermaid svg [aria-roledescription="quadrantChart"] g.border line {
664
+ stroke: var(--mm-q-border, #3A3934) !important;
665
+ }
666
+ .body pre.mermaid svg [aria-roledescription="quadrantChart"] text {
667
+ fill: var(--mm-q-text, #C8C5BE) !important;
668
+ }
669
+ .body pre.mermaid svg [aria-roledescription="quadrantChart"] .bottom-axis text,
670
+ .body pre.mermaid svg [aria-roledescription="quadrantChart"] .left-axis text,
671
+ .body pre.mermaid svg [aria-roledescription="quadrantChart"] .top-axis text,
672
+ .body pre.mermaid svg [aria-roledescription="quadrantChart"] .right-axis text {
673
+ fill: var(--mm-q-axis-text, #5C5A4D) !important;
674
+ }
675
+ .body pre.mermaid svg [aria-roledescription="quadrantChart"] .quadrant text {
676
+ fill: var(--mm-q-quad-text, #8E8B83) !important;
677
+ }
678
+
679
+ /* xychart-beta · bars cycle through palette via :nth-child */
680
+ .body pre.mermaid svg [aria-roledescription="xychart"] g[class*="bar-plot"] rect,
681
+ .body pre.mermaid svg [aria-roledescription="xychart"] g.plot rect {
682
+ fill: var(--mm-pal-0, #C9A46B) !important;
683
+ stroke: none !important;
684
+ }
685
+ .body pre.mermaid svg [aria-roledescription="xychart"] g[class*="bar-plot"] rect:nth-child(2),
686
+ .body pre.mermaid svg [aria-roledescription="xychart"] g.plot rect:nth-child(2) {
687
+ fill: var(--mm-pal-1, #B6B0A2) !important;
688
+ }
689
+ .body pre.mermaid svg [aria-roledescription="xychart"] g[class*="bar-plot"] rect:nth-child(3),
690
+ .body pre.mermaid svg [aria-roledescription="xychart"] g.plot rect:nth-child(3) {
691
+ fill: var(--mm-pal-2, #8E8B83) !important;
692
+ }
693
+ .body pre.mermaid svg [aria-roledescription="xychart"] g[class*="bar-plot"] rect:nth-child(n+4),
694
+ .body pre.mermaid svg [aria-roledescription="xychart"] g.plot rect:nth-child(n+4) {
695
+ fill: var(--mm-pal-3, #5C5A52) !important;
696
+ }
697
+ .body pre.mermaid svg [aria-roledescription="xychart"] text {
698
+ fill: var(--mm-axis-text, #5C5A4D) !important;
699
+ }
700
+ .body pre.mermaid svg [aria-roledescription="xychart"] .background,
701
+ .body pre.mermaid svg [aria-roledescription="xychart"] rect.background {
702
+ fill: transparent !important;
703
+ }
704
+
705
+ /* Pie · slice palette */
706
+ .body pre.mermaid svg path.pieCircle:nth-of-type(1) { fill: var(--mm-pal-0, #C9A46B) !important; stroke: var(--mm-q-bg, #1A1A18) !important; }
707
+ .body pre.mermaid svg path.pieCircle:nth-of-type(2) { fill: var(--mm-pal-1, #B6B0A2) !important; }
708
+ .body pre.mermaid svg path.pieCircle:nth-of-type(3) { fill: var(--mm-pal-2, #8E8B83) !important; }
709
+ .body pre.mermaid svg path.pieCircle:nth-of-type(n+4) { fill: var(--mm-pal-3, #5C5A52) !important; }
710
+ .body pre.mermaid svg circle.pieOuterCircle {
711
+ stroke: var(--mm-q-border, #3A3934) !important;
712
+ fill: none !important;
713
+ }
714
+
715
+ /* Universal text safety net inside any mermaid SVG · titleFill
716
+ for anything that escaped the per-type rules above. Lower
717
+ specificity than the type-specific rules so they still win. */
718
+ .body pre.mermaid svg text {
719
+ fill: var(--mm-text, #C8C5BE); /* no !important · type-specific rules above WIN */
720
+ }
721
+
722
+ /* ─── Chart display sizes · CSS-enforced ─────────────────────
723
+ Mermaid 10.9.5's chartWidth/chartHeight config keys for
724
+ quadrantChart and xychart are silently ignored by the
725
+ bundled CDN build (verified via DOM dump showing default
726
+ 500×500 viewBox even when chartWidth: 460 is configured).
727
+ CSS-enforced sizes via `max-width` on the rendered SVG let
728
+ us get refined-compact dimensions regardless of what
729
+ mermaid's config validator does. The viewBox stays at
730
+ mermaid's internal coordinate space (500×500 etc.) but the
731
+ displayed SVG scales down to our target size. Centered with
732
+ margin: 0 auto so the chart sits flush in the article column
733
+ instead of stretching to full width. */
734
+ .body pre.mermaid svg[aria-roledescription="quadrantChart"] {
735
+ max-width: 460px !important;
736
+ height: auto !important;
737
+ margin: 0 auto !important;
738
+ display: block !important;
739
+ }
740
+ .body pre.mermaid svg[aria-roledescription="xychart"] {
741
+ max-width: 560px !important;
742
+ height: auto !important;
743
+ margin: 0 auto !important;
744
+ display: block !important;
745
+ }
746
+ .body pre.mermaid svg[aria-roledescription="pie"] {
747
+ max-width: 420px !important;
748
+ height: auto !important;
749
+ margin: 0 auto !important;
750
+ display: block !important;
751
+ }
752
+
614
753
  /* ─── Metric-strip · spine-agnostic baseline ─────────────────────
615
754
  Editorial / Swiss register: oversized thin numerals top-left, mute
616
755
  mono label bottom-left, hairline grid (no card backgrounds, no
@@ -4806,6 +4945,31 @@
4806
4945
  },
4807
4946
  };
4808
4947
  const t = themes[spineKey];
4948
+
4949
+ // Inject spine palette as CSS variables on :root so the
4950
+ // CSS rules in the global <style> block (mermaid spine
4951
+ // CSS) resolve to the right colours. This is the
4952
+ // PRIMARY mechanism — CSS applies on every paint, no JS
4953
+ // timing race possible. The JS takeover below stays as
4954
+ // belt-and-suspenders for chart elements not covered by
4955
+ // the CSS rules.
4956
+ try {
4957
+ const root = document.documentElement.style;
4958
+ root.setProperty('--mm-q-fill', t.vars.quadrantFill);
4959
+ root.setProperty('--mm-q-bg', t.vars.background);
4960
+ root.setProperty('--mm-q-point', t.vars.pointFill);
4961
+ root.setProperty('--mm-q-border', t.vars.border);
4962
+ root.setProperty('--mm-q-text', t.vars.titleFill);
4963
+ root.setProperty('--mm-q-axis-text', t.vars.axisText);
4964
+ root.setProperty('--mm-q-quad-text', t.vars.quadrantText);
4965
+ root.setProperty('--mm-text', t.vars.titleFill);
4966
+ root.setProperty('--mm-axis-text', t.vars.axisText);
4967
+ root.setProperty('--mm-pal-0', t.palette[0]);
4968
+ root.setProperty('--mm-pal-1', t.palette[1]);
4969
+ root.setProperty('--mm-pal-2', t.palette[2]);
4970
+ root.setProperty('--mm-pal-3', t.palette[3]);
4971
+ } catch (_) {}
4972
+
4809
4973
  // Build pie / journey / state / git slice-color overrides
4810
4974
  // by cycling through the spine's monochrome `palette`.
4811
4975
  // Without these, mermaid 11+ paints pie1..pie12 +
@@ -5418,47 +5582,74 @@
5418
5582
  useMaxWidth: true,
5419
5583
  },
5420
5584
  });
5421
- await mermaid.run({ querySelector: ".mermaid" });
5422
- // ── Post-render spine palette takeover ───────────────────
5423
- // Definitive fix for "mermaid charts don't match the spine
5424
- // palette." Mermaid's color system has 4 layers (theme
5425
- // preset, themeVariables, embedded SVG <style>, inline
5426
- // attributes) that all leak in different ways. Instead of
5427
- // playing whack-a-mole with mermaid's internals, we let
5428
- // mermaid render the structure, then:
5429
- // 1. WIPE mermaid's embedded <style> block in each SVG
5430
- // (eliminates the CSS-vs-attribute specificity gotcha
5431
- // where <style> rules silently beat setAttribute calls)
5432
- // 2. Force-set fill/stroke via inline `style` attribute on
5433
- // every visual element by class — inline style has the
5434
- // highest CSS specificity, so nothing in the cascade
5435
- // can override our spine palette.
5436
- // Result: every chart element renders in the spine's colors
5437
- // regardless of mermaid version, theme preset, or which
5438
- // themeVariables we pass.
5439
- // Define the takeover as a re-runnable function so we can
5440
- // schedule it multiple times to defeat first-load timing
5441
- // races. Pattern observed: on cold loads (CDN uncached, CSS
5442
- // uncached) mermaid does async post-render work via
5443
- // requestAnimationFrame / setTimeout AFTER the run() promise
5444
- // resolves — re-injecting <style>, recomputing layouts, etc.
5445
- // A single sync takeover then loses the race because mermaid
5446
- // overwrites our inline styles afterward. On refresh
5447
- // everything's cached, mermaid finishes faster, and a single
5448
- // takeover wins by accident.
5585
+ // ── Off-screen render + paint-then-insert ────────────────
5586
+ // DEFINITIVE fix for "first time wrong, refresh OK" /
5587
+ // strict alternation pattern. Earlier strategies all let
5588
+ // mermaid render in-place, then patched colors after
5589
+ // which gave the user a visible window of mermaid-default
5590
+ // colors before our takeover landed.
5449
5591
  //
5450
- // Fix: run the takeover at multiple frames + delays so any
5451
- // mermaid post-processing gets caught regardless of timing.
5452
- // Each call is idempotent (same selectors set the same
5453
- // inline styles) so re-running is cheap.
5454
- const setFill = (el, c) => { if (c) el.style.setProperty('fill', c, 'important'); };
5455
- const setStroke = (el, c) => { if (c !== undefined && c !== null) el.style.setProperty('stroke', c, 'important'); };
5592
+ // The only race-free approach: render OFFSCREEN, paint our
5593
+ // spine palette onto the SVG while it's still detached,
5594
+ // THEN insert into the visible DOM. The user's first paint
5595
+ // sees the spine-colored SVG; there is no intermediate
5596
+ // mermaid-default state to perceive.
5597
+ //
5598
+ // mermaid 10's `render(id, src)` returns the SVG as a
5599
+ // string (not in-DOM) — perfect for this flow. We loop
5600
+ // through every `pre.mermaid`, render its source via
5601
+ // `mermaid.render`, parse the resulting string, apply the
5602
+ // takeover to the parsed (still-detached) SVG, then swap
5603
+ // the corrected SVG into the visible container.
5604
+ // Belt-and-suspenders fill setter · sets BOTH the inline
5605
+ // `style="fill: X !important"` AND the SVG `fill="X"`
5606
+ // presentation attribute. With mermaid's embedded <style>
5607
+ // already wiped, either layer is sufficient — but setting
5608
+ // both means even if one is somehow stripped/overridden
5609
+ // (browser quirk, late mermaid post-processing, CSS
5610
+ // cascade edge case), the chart still renders in the
5611
+ // spine palette.
5612
+ const setFill = (el, c) => {
5613
+ if (!c) return;
5614
+ try { el.style.setProperty('fill', c, 'important'); } catch (_) {}
5615
+ try { if (el.setAttribute) el.setAttribute('fill', c); } catch (_) {}
5616
+ };
5617
+ const setStroke = (el, c) => {
5618
+ if (c === undefined || c === null) return;
5619
+ try { el.style.setProperty('stroke', c, 'important'); } catch (_) {}
5620
+ try { if (el.setAttribute) el.setAttribute('stroke', c); } catch (_) {}
5621
+ };
5456
5622
  const setColor = (el, c) => { if (c) el.style.setProperty('color', c, 'important'); };
5457
5623
  const cyclePalette = (i) => t.palette[i % t.palette.length];
5458
5624
 
5459
- const applySpinePaletteOnce = () => {
5625
+ // Apply the spine palette to ONE SVG element (offscreen or
5626
+ // onscreen — works either way since every fill/stroke is
5627
+ // forced via inline `!important`). Pure function: no
5628
+ // global queries, only operates on the passed element.
5629
+ const applySpineToSvg = (svg) => {
5460
5630
  try {
5461
- document.querySelectorAll('.mermaid svg').forEach((svg) => {
5631
+ // ── 0. Pin the SVG max-width per chart type ──
5632
+ // Mermaid 10's chartWidth/chartHeight config is silently
5633
+ // ignored by the bundled CDN build, so the SVG ships at
5634
+ // its default size with `style="max-width: 500px"`
5635
+ // inline. CSS in <style> overrides it at paint time, but
5636
+ // there's a one-frame race on cold loads where the
5637
+ // user can perceive the unconstrained size before CSS
5638
+ // applies. Pin via inline style with !important here
5639
+ // for refined-compact dimensions on every first paint.
5640
+ const role = svg.getAttribute('aria-roledescription') || '';
5641
+ const chartMaxWidth = (
5642
+ role === 'quadrantChart' ? '460px' :
5643
+ role === 'xychart' ? '560px' :
5644
+ role === 'pie' ? '420px' :
5645
+ ''
5646
+ );
5647
+ if (chartMaxWidth) {
5648
+ svg.style.setProperty('max-width', chartMaxWidth, 'important');
5649
+ svg.style.setProperty('height', 'auto', 'important');
5650
+ svg.style.setProperty('margin', '0 auto', 'important');
5651
+ svg.style.setProperty('display', 'block', 'important');
5652
+ }
5462
5653
  // ── 1. Wipe mermaid's embedded <style> block ──
5463
5654
  // Mermaid generates `<style>.quadrant-chart > .quadrant-
5464
5655
  // point > circle { fill: #...; }` etc. inside each SVG;
@@ -5478,16 +5669,39 @@
5478
5669
 
5479
5670
  // ── 3. Quadrant chart ──
5480
5671
  // Mermaid 10's actual class names (verified against rendered DOM):
5481
- // `.quadrant` — each of the 4 quadrant rects (NOT .quadrant-fill)
5482
- // `.quadrants` — parent group
5483
- // `.data-point` — each data circle (NOT .quadrant-point)
5672
+ // `<g class="quadrant">`group; each contains a child <rect>
5673
+ // `.quadrants` — outer parent group
5674
+ // `<circle class="data-point">` — each data circle
5484
5675
  // `.data-points` — parent group
5485
5676
  // `.axis-line` / `.axisl-line` — axis lines
5486
- // `.bottom-axis`, `.left-axis` — axis text
5487
- // Older / alternate class names from .quadrant-* family kept
5488
- // as a defensive fallback for version drift.
5489
- svg.querySelectorAll('rect.quadrant, .quadrants > .quadrant, rect.quadrant-fill, .quadrant-fill').forEach((el) => setFill(el, t.vars.quadrantFill));
5490
- svg.querySelectorAll('.axis-line, .axisl-line, .quadrants line').forEach((el) => setStroke(el, t.vars.border));
5677
+ // `.bottom-axis`, `.left-axis` — axis text containers
5678
+ // CRITICAL: the `.quadrant` class is on a <g>, but the
5679
+ // actual fillable rect is INSIDE the group. Selecting
5680
+ // `.quadrants > .quadrant` matches the <g> only;
5681
+ // setting style.fill on a <g> does NOT override the
5682
+ // child <rect>'s `fill="..."` attribute in SVG. We
5683
+ // must select the inner rect directly.
5684
+ //
5685
+ // Belt-and-suspenders: also match ALL <rect> inside
5686
+ // any quadrantChart SVG (excluding the canvas-bg rect
5687
+ // we already handled via dimensions). Catches any
5688
+ // mermaid version variation in class naming.
5689
+ svg.querySelectorAll('.quadrants .quadrant rect, g.quadrant > rect, rect.quadrant, rect.quadrant-fill, .quadrant-fill').forEach((el) => setFill(el, t.vars.quadrantFill));
5690
+ if (svg.getAttribute('aria-roledescription') === 'quadrantChart') {
5691
+ const qsw = parseFloat(svg.getAttribute('width') || '0');
5692
+ svg.querySelectorAll('rect').forEach((el) => {
5693
+ // Skip the canvas-bg rect (full-width).
5694
+ const w = parseFloat(el.getAttribute('width') || '0');
5695
+ if (qsw > 0 && w >= qsw * 0.95) return;
5696
+ setFill(el, t.vars.quadrantFill);
5697
+ });
5698
+ }
5699
+ // Quadrant border lines · mermaid 10 puts the outer
5700
+ // rectangle + cross-divider lines inside `<g class="border">`,
5701
+ // a sibling of `.quadrants` (NOT a descendant). Catch
5702
+ // both the inside-quadrants axis-line case AND the
5703
+ // border group case.
5704
+ svg.querySelectorAll('.axis-line, .axisl-line, .quadrants line, g.border line, .border line').forEach((el) => setStroke(el, t.vars.border));
5491
5705
  svg.querySelectorAll('.quadrant-internal-border, line.quadrant-internal-border').forEach((el) => setStroke(el, t.vars.inner));
5492
5706
  svg.querySelectorAll('.quadrant-external-border, rect.quadrant-external-border').forEach((el) => {
5493
5707
  setStroke(el, t.vars.border);
@@ -5695,20 +5909,120 @@
5695
5909
 
5696
5910
  // ── 16. Mark the parent pre.mermaid as painted ──
5697
5911
  // CSS hides `.body pre.mermaid svg` until `.is-painted`
5698
- // is added, so the user never sees mermaid's default
5699
- // colors during the brief window between mermaid.run()
5700
- // resolving and our takeover applying. Adding it here
5701
- // (last step of each takeover pass) reveals the SVG only
5702
- // after our spine fills are pinned.
5912
+ // is added on the parent. We add it here so that even
5913
+ // when the SVG is later inserted into the live DOM, the
5914
+ // CSS-based hide is already lifted (no flash).
5703
5915
  const host = svg.closest('pre.mermaid');
5704
5916
  if (host && !host.classList.contains('is-painted')) {
5705
5917
  host.classList.add('is-painted');
5706
5918
  }
5707
- }); // forEach svg
5708
5919
  } catch (forceColorErr) {
5709
- console.warn('[mermaid] post-render palette takeover failed:', forceColorErr);
5920
+ console.warn('[mermaid] palette takeover failed for svg:', forceColorErr);
5921
+ }
5922
+ }; // applySpineToSvg
5923
+
5924
+ // Wrapper · walks every onscreen SVG and applies the
5925
+ // takeover. Used by the safety polling + MutationObserver
5926
+ // (defense-in-depth) for any SVGs that were already
5927
+ // inserted before the offscreen-render loop took over.
5928
+ const applySpinePaletteOnce = () => {
5929
+ document.querySelectorAll('.mermaid svg').forEach(applySpineToSvg);
5930
+ };
5931
+
5932
+ // ── Offscreen render via mermaid.run + DOM relocation ─
5933
+ // DEFINITIVE fix for "first load wrong / strict
5934
+ // alternation." Keep mermaid.run (it handles lazy module
5935
+ // loading for diagram types correctly — mermaid.render's
5936
+ // first call races with that loading on cold cache),
5937
+ // but move every pre.mermaid INTO AN OFFSCREEN CONTAINER
5938
+ // before running. mermaid renders inside the offscreen
5939
+ // container (user can't see it), we apply the spine
5940
+ // takeover to every resulting SVG, then move each
5941
+ // pre.mermaid back to its original DOM position. The
5942
+ // user's first paint of each chart shows the
5943
+ // spine-coloured SVG; no race possible.
5944
+ //
5945
+ // Diagnostic logs (console.info) so the user can verify
5946
+ // which path was taken and at what timing — open DevTools
5947
+ // and refresh; the log narrates the flow.
5948
+ const _mLog = (msg, ...rest) => {
5949
+ try { console.info(`[mermaid-spine] ${msg}`, ...rest); } catch (_) {}
5950
+ };
5951
+ _mLog('flow start · spine =', spineKey);
5952
+ const charts = Array.from(document.querySelectorAll('pre.mermaid'));
5953
+ _mLog('charts found:', charts.length);
5954
+ if (charts.length > 0) {
5955
+ // Capture original DOM positions so we can restore each
5956
+ // pre.mermaid exactly where it was.
5957
+ const anchors = charts.map((el) => ({
5958
+ el,
5959
+ parent: el.parentNode,
5960
+ nextSibling: el.nextSibling,
5961
+ }));
5962
+ // Build the offscreen host. Width matches a typical
5963
+ // article column so mermaid sizes the SVG correctly
5964
+ // (useMaxWidth: true uses the offscreen container's
5965
+ // dimensions for layout). Height: 0 + overflow: visible
5966
+ // so mermaid can compute proper SVG sizes.
5967
+ const offscreen = document.createElement('div');
5968
+ offscreen.id = 'mermaid-offscreen-host';
5969
+ offscreen.style.cssText = [
5970
+ 'position: absolute',
5971
+ 'left: -99999px',
5972
+ 'top: 0',
5973
+ 'width: 880px',
5974
+ 'visibility: hidden',
5975
+ 'pointer-events: none',
5976
+ 'z-index: -1',
5977
+ ].join('; ');
5978
+ document.body.appendChild(offscreen);
5979
+ // Move each pre.mermaid into the offscreen host.
5980
+ anchors.forEach((a) => offscreen.appendChild(a.el));
5981
+ _mLog('moved offscreen, calling mermaid.run');
5982
+ // Run mermaid · scoped to the offscreen host so it only
5983
+ // touches elements we just moved. mermaid handles lazy
5984
+ // module registration internally.
5985
+ try {
5986
+ await mermaid.run({ querySelector: '#mermaid-offscreen-host pre.mermaid' });
5987
+ _mLog('mermaid.run resolved');
5988
+ } catch (runErr) {
5989
+ console.warn('[mermaid-spine] offscreen run failed:', runErr);
5710
5990
  }
5711
- }; // applySpinePaletteOnce
5991
+ // Wait for mermaid's deferred post-render work to land.
5992
+ // Quadrant chart (specifically) does d3-based label
5993
+ // positioning in a setTimeout(0) AFTER mermaid.run
5994
+ // resolves — if we apply takeover before that fires,
5995
+ // mermaid overwrites our fills. Two rAF + setTimeout(0)
5996
+ // cycles guarantee deferred work has settled. Cheap
5997
+ // (~32ms) and only runs once per page load.
5998
+ await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(() => setTimeout(r, 0))));
5999
+ // Apply takeover to all SVGs while still offscreen.
6000
+ const svgsOffscreen = offscreen.querySelectorAll('svg');
6001
+ _mLog('SVGs rendered offscreen:', svgsOffscreen.length);
6002
+ svgsOffscreen.forEach(applySpineToSvg);
6003
+ _mLog('takeover applied offscreen');
6004
+ // Move each pre.mermaid back to its original position
6005
+ // and mark as painted (CSS reveal rule). Single DOM
6006
+ // mutation per chart → single paint frame with the
6007
+ // corrected SVG already in place.
6008
+ anchors.forEach((a) => {
6009
+ a.el.classList.add('is-painted');
6010
+ if (a.parent) {
6011
+ if (a.nextSibling && a.nextSibling.parentNode === a.parent) {
6012
+ a.parent.insertBefore(a.el, a.nextSibling);
6013
+ } else {
6014
+ a.parent.appendChild(a.el);
6015
+ }
6016
+ }
6017
+ // Re-apply takeover immediately after move · catches
6018
+ // any post-attach mermaid behaviour (ResizeObserver
6019
+ // re-renders, deferred rAF) that might restyle.
6020
+ a.el.querySelectorAll('svg').forEach(applySpineToSvg);
6021
+ });
6022
+ _mLog('moved back onscreen + reapplied takeover');
6023
+ // Cleanup offscreen host.
6024
+ offscreen.remove();
6025
+ }
5712
6026
 
5713
6027
  // Safety net · if the takeover errors out for some reason
5714
6028
  // (script bug, exotic chart type), make sure every chart
@@ -120,6 +120,40 @@
120
120
  content: "▸ ";
121
121
  color: var(--lime, #6FB572);
122
122
  }
123
+
124
+ /* ─── Title clamp ──────────────────────────────────────────────
125
+ Long room titles get clamped to 2 lines. The Show more / less
126
+ toggle is rendered `hidden` and unhidden by room-settings.js's
127
+ post-mount measurement only when the title actually overflows. */
128
+ .rs-title-wrap {
129
+ display: flex;
130
+ flex-direction: column;
131
+ gap: 4px;
132
+ min-width: 0;
133
+ }
134
+ .rs-head .title.is-clamped {
135
+ display: -webkit-box;
136
+ -webkit-line-clamp: 2;
137
+ -webkit-box-orient: vertical;
138
+ overflow: hidden;
139
+ }
140
+ .rs-title-toggle {
141
+ align-self: flex-start;
142
+ appearance: none;
143
+ background: transparent;
144
+ border: none;
145
+ font-family: var(--mono);
146
+ font-size: 9.5px;
147
+ letter-spacing: 0.14em;
148
+ text-transform: uppercase;
149
+ color: var(--text-soft, #8E8B83);
150
+ cursor: pointer;
151
+ padding: 0;
152
+ transition: color 0.12s;
153
+ }
154
+ .rs-title-toggle:hover { color: var(--lime, #6FB572); }
155
+ .rs-title-toggle::before { content: "[ "; color: var(--text-faint, #3A382F); }
156
+ .rs-title-toggle::after { content: " ]"; color: var(--text-faint, #3A382F); }
123
157
  .rs-head .close-btn {
124
158
  width: 24px; height: 24px;
125
159
  background: transparent;
@@ -863,11 +897,13 @@
863
897
  .rs-action.danger { color: var(--red, #B5706A); border-color: rgba(181, 112, 106, 0.4); }
864
898
  .rs-action.danger:hover { background: var(--red, #B5706A); color: var(--bg, #0A0A0A); border-color: var(--red, #B5706A); }
865
899
 
866
- /* Icon-only buttons in the room-head action bar */
900
+ /* Icon-only buttons in the room-head action bar · sized to match
901
+ the avatar / cast-count tile (22px tall) so the whole right-side
902
+ toolbar lands on a single line at the new compact header height. */
867
903
  .head-icon-btn,
868
904
  .room-settings-trigger {
869
- width: 28px;
870
- height: 26px;
905
+ width: 24px;
906
+ height: 22px;
871
907
  display: inline-flex;
872
908
  align-items: center;
873
909
  justify-content: center;
@@ -876,7 +912,7 @@
876
912
  border: 0.5px solid var(--line-strong, #3A3A35);
877
913
  color: var(--text-soft, #8E8B83);
878
914
  font-family: var(--mono);
879
- font-size: 13px;
915
+ font-size: 12px;
880
916
  line-height: 1;
881
917
  cursor: pointer;
882
918
  text-decoration: none;
@@ -290,9 +290,12 @@
290
290
  </div>
291
291
 
292
292
  <header class="rs-head">
293
- <div>
293
+ <div class="rs-head-text">
294
294
  <div class="meta">// room #<span class="rs-number">${ROOM_STATE.number}</span> · <span class="live">${ROOM_STATE.status}</span> · <span class="rs-turns">${ROOM_STATE.turns}</span> turns</div>
295
- <div class="title rs-title">${escape(ROOM_STATE.title)}</div>
295
+ <div class="rs-title-wrap">
296
+ <div class="title rs-title is-clamped" data-rs-title>${escape(ROOM_STATE.title)}</div>
297
+ <button type="button" class="rs-title-toggle" data-rs-title-toggle hidden>Show more</button>
298
+ </div>
296
299
  </div>
297
300
  <button type="button" class="close-btn" aria-label="Close">✕</button>
298
301
  </header>
@@ -628,6 +631,29 @@
628
631
  overlay.classList.add("open");
629
632
  overlay.setAttribute("aria-hidden", "false");
630
633
  document.body.style.overflow = "hidden";
634
+ // Title clamp · run AFTER the overlay becomes visible so the
635
+ // title element has real dimensions to measure. Resets to clamped
636
+ // every open (a previously-expanded title shouldn't stick across
637
+ // close + reopen). Double-rAF gives layout + line-clamp a beat to
638
+ // settle on cold opens (Safari can return stale scrollHeight on a
639
+ // single rAF after a display switch).
640
+ applyTitleClamp();
641
+ }
642
+
643
+ function applyTitleClamp() {
644
+ const titleEl = modal.querySelector("[data-rs-title]");
645
+ const titleBtn = modal.querySelector("[data-rs-title-toggle]");
646
+ if (!titleEl || !titleBtn) return;
647
+ titleEl.classList.add("is-clamped");
648
+ titleBtn.textContent = "Show more";
649
+ titleBtn.hidden = true;
650
+ requestAnimationFrame(() => {
651
+ requestAnimationFrame(() => {
652
+ if (titleEl.scrollHeight > titleEl.clientHeight + 1) {
653
+ titleBtn.hidden = false;
654
+ }
655
+ });
656
+ });
631
657
  }
632
658
  function close() {
633
659
  if (!overlay) return;
@@ -880,6 +906,22 @@
880
906
 
881
907
  // Close (X) — discards staged changes via close().
882
908
  overlay.querySelector(".close-btn").addEventListener("click", close);
909
+
910
+ // Title clamp · click handler attaches once at init and persists
911
+ // across opens (the toggle button DOM node is reused). The actual
912
+ // overflow measurement happens inside open() via applyTitleClamp,
913
+ // because at init time the overlay is `display: none` so the
914
+ // title element has 0×0 dimensions and scrollHeight is meaningless.
915
+ const titleBtn = modal.querySelector("[data-rs-title-toggle]");
916
+ if (titleBtn) {
917
+ titleBtn.addEventListener("click", (e) => {
918
+ e.preventDefault();
919
+ const titleEl = modal.querySelector("[data-rs-title]");
920
+ if (!titleEl) return;
921
+ const expanded = titleEl.classList.toggle("is-clamped") === false;
922
+ titleBtn.textContent = expanded ? "Show less" : "Show more";
923
+ });
924
+ }
883
925
  // Confirm — push staged changes to the backend then close.
884
926
  modal.querySelector("[data-rs-confirm]").addEventListener("click", (e) => {
885
927
  e.preventDefault();