privateboard 0.1.7 → 0.1.8

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
@@ -261,50 +261,76 @@
261
261
  margin-top: 5px;
262
262
  }
263
263
 
264
- /* ─── Toggle row · pill button + supporting deck text ───
265
- Matches the visual vocabulary of the day-picker pills (mono
266
- uppercase, hairline border, lime accent when on) so the User pane
267
- doesn't introduce a third button style for one toggle. */
264
+ /* ─── Toggle row · proper switch + supporting deck text ───
265
+ Switch shape (track + sliding thumb) instead of the prior pill
266
+ button: the affordance reads "on/off state" at a glance, not
267
+ "press me to do something." Track is a 32×18 rounded rect, thumb
268
+ is a 14×14 disc that slides 14px between rest positions. Lime
269
+ fill when on; panel-3 fill + hairline border when off. */
268
270
  .us-toggle-row {
269
271
  display: flex;
270
272
  align-items: center;
271
273
  gap: 12px;
272
274
  flex-wrap: wrap;
273
275
  }
274
- .us-toggle-pill {
276
+ .us-switch {
275
277
  appearance: none;
276
278
  background: transparent;
277
- border: 0.5px solid var(--line-bright, #2A2A26);
278
- color: var(--text-soft, #8E8B83);
279
- font-family: var(--mono);
280
- font-size: 10.5px;
281
- letter-spacing: 0.18em;
282
- text-transform: uppercase;
283
- font-weight: 700;
284
- padding: 6px 14px;
279
+ border: none;
280
+ padding: 0;
285
281
  display: inline-flex;
286
282
  align-items: center;
287
- gap: 8px;
283
+ gap: 10px;
288
284
  cursor: pointer;
289
- transition: color 0.12s, border-color 0.12s, background 0.12s;
290
- }
291
- .us-toggle-pill:hover {
292
- color: var(--text, #C8C5BE);
293
- border-color: var(--text-faint, #3A382F);
285
+ font-family: var(--mono);
286
+ color: inherit;
294
287
  }
295
- .us-toggle-pill.on {
296
- color: var(--lime, #6FB572);
297
- border-color: var(--lime, #6FB572);
298
- background: var(--panel-2, #1A1A18);
288
+ .us-switch-track {
289
+ position: relative;
290
+ display: inline-block;
291
+ width: 32px;
292
+ height: 18px;
293
+ background: var(--panel-3, #21211E);
294
+ border: 0.5px solid var(--line-bright, #2A2A26);
295
+ border-radius: 9px;
296
+ flex-shrink: 0;
297
+ transition: background 0.18s, border-color 0.18s;
299
298
  }
300
- .us-toggle-dot {
301
- width: 6px;
302
- height: 6px;
299
+ .us-switch-thumb {
300
+ position: absolute;
301
+ top: 1px;
302
+ left: 1px;
303
+ width: 14px;
304
+ height: 14px;
303
305
  border-radius: 50%;
304
306
  background: var(--text-faint, #3A382F);
305
- transition: background 0.12s;
307
+ transition: transform 0.18s ease, background 0.18s;
308
+ }
309
+ .us-switch.on .us-switch-track {
310
+ background: var(--lime, #6FB572);
311
+ border-color: var(--lime, #6FB572);
306
312
  }
307
- .us-toggle-pill.on .us-toggle-dot { background: var(--lime, #6FB572); }
313
+ .us-switch.on .us-switch-thumb {
314
+ transform: translateX(14px);
315
+ background: var(--bg, #0A0A0A);
316
+ }
317
+ .us-switch:hover .us-switch-track { border-color: var(--text-faint, #3A382F); }
318
+ .us-switch.on:hover .us-switch-track { border-color: var(--lime, #6FB572); }
319
+ .us-switch:focus-visible { outline: none; }
320
+ .us-switch:focus-visible .us-switch-track {
321
+ outline: 1.5px solid var(--lime, #6FB572);
322
+ outline-offset: 2px;
323
+ }
324
+ .us-switch-label {
325
+ font-size: 10.5px;
326
+ letter-spacing: 0.18em;
327
+ text-transform: uppercase;
328
+ font-weight: 700;
329
+ color: var(--text-soft, #8E8B83);
330
+ transition: color 0.12s;
331
+ }
332
+ .us-switch.on .us-switch-label { color: var(--lime, #6FB572); }
333
+
308
334
  .us-toggle-deck {
309
335
  font-size: 11px;
310
336
  color: var(--text-faint, #3A382F);
@@ -895,9 +921,11 @@
895
921
  display: flex;
896
922
  align-items: flex-end;
897
923
  gap: 4px;
898
- height: 96px;
924
+ height: 180px;
899
925
  /* The stack within each bar lives at the bottom (flex-end) so
900
- bar height grows upward like a real chart axis. */
926
+ bar height grows upward like a real chart axis. Bumped from
927
+ 96px → 180px so the chart reads as a real exhibit instead of
928
+ a squashed sparkline. */
901
929
  }
902
930
  .us-chart-bar {
903
931
  appearance: none;
@@ -915,27 +943,46 @@
915
943
  position: relative;
916
944
  }
917
945
  .us-chart-bar:hover .us-chart-stack { filter: brightness(1.15); }
918
- .us-chart-bar.empty .us-chart-stack {
919
- height: 1px !important;
920
- background: var(--line-bright, #2A2A26);
921
- }
922
- .us-chart-bar.today::before {
923
- /* Outline marker for today's bar a hairline ring around the
924
- full bar slot (including the empty space above the stack)
925
- so the present is locatable even on a zero-token day. */
926
- content: "";
946
+ /* Hover tooltip · two-line custom tip rendered via ::before from the
947
+ bar's data-tip-day / data-tip-num attributes. Floats above the
948
+ column so it doesn't overlap the stack or get clipped by the bar's
949
+ contents. Native browser title= is replaced by aria-label= on the
950
+ button to keep a11y without the 500ms native-tooltip delay.
951
+ Suppressed on `.empty` bars · zero-usage days show no tooltip
952
+ (the row is already empty; a tooltip saying "no usage" is noise). */
953
+ .us-chart-bar:not(.empty)::before {
954
+ content: attr(data-tip-day) "\A" attr(data-tip-num);
955
+ white-space: pre;
956
+ text-align: center;
927
957
  position: absolute;
928
- inset: 0 -1px 14px -1px;
929
- border: 0.5px solid var(--text-faint, #3A382F);
958
+ bottom: calc(100% + 6px);
959
+ left: 50%;
960
+ transform: translateX(-50%);
961
+ background: var(--bg, #0A0A0A);
962
+ border: 1px solid var(--line-bright, #2A2A26);
963
+ padding: 6px 10px;
964
+ font-family: var(--mono, "Inter", system-ui, sans-serif);
965
+ font-size: 10px;
966
+ line-height: 1.5;
967
+ letter-spacing: 0.04em;
968
+ color: var(--text, #C8C5BE);
969
+ opacity: 0;
930
970
  pointer-events: none;
971
+ transition: opacity 0.12s ease;
972
+ z-index: 10;
931
973
  }
932
- .us-chart-bar.active::before {
933
- content: "";
934
- position: absolute;
935
- inset: 0 -1px 14px -1px;
936
- border: 1px solid var(--lime, #6FB572);
937
- pointer-events: none;
974
+ .us-chart-bar:not(.empty):hover::before { opacity: 1; }
975
+ .us-chart-bar.empty .us-chart-stack {
976
+ height: 1px !important;
977
+ background: var(--line-bright, #2A2A26);
938
978
  }
979
+ /* Today / active markers carry only on the tick label below
980
+ (color shift) — no perimeter border around the bar slot.
981
+ Earlier the today/active states drew an outline ring via
982
+ `::before` which read as an ugly box around an otherwise
983
+ clean stacked-bar; the tick-colour change carries enough
984
+ signal. See `.us-chart-bar.today .us-chart-tick` and
985
+ `.us-chart-bar.active .us-chart-tick` below. */
939
986
  .us-chart-stack {
940
987
  display: flex;
941
988
  flex-direction: column-reverse; /* first segment at the bottom */