privateboard 0.1.5 → 0.1.7

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,29 @@
588
588
  border-top-width: 0;
589
589
  }
590
590
 
591
+ /* ─── Hide mermaid SVG until spine palette is applied ─────────
592
+ Pattern from user reports: "first time wrong, refresh OK" /
593
+ strict alternation (1st wrong, 2nd right, 3rd wrong, 4th
594
+ right). Cause: even with our continuous post-render takeover
595
+ (see report.html post-mermaid.run block), mermaid paints at
596
+ least once with its default palette BEFORE our takeover lands
597
+ — and the user sees that first paint. The takeover then
598
+ corrects it, but the user's already perceived "wrong colors."
599
+ On refresh, cached rendering may finish faster and the first
600
+ paint happens AFTER our takeover, which they perceive as
601
+ correct.
602
+ Defense: hide the SVG inside each pre.mermaid until we add
603
+ the `is-painted` class. The post-render takeover adds the
604
+ class as its last step. Until then, the user sees the frame
605
+ (matching the spine) with no SVG inside — no off-brand
606
+ colors flash at any point. */
607
+ .body pre.mermaid svg {
608
+ visibility: hidden;
609
+ }
610
+ .body pre.mermaid.is-painted svg {
611
+ visibility: visible;
612
+ }
613
+
591
614
  /* ─── Metric-strip · spine-agnostic baseline ─────────────────────
592
615
  Editorial / Swiss register: oversized thin numerals top-left, mute
593
616
  mono label bottom-left, hairline grid (no card backgrounds, no
@@ -5396,73 +5419,347 @@
5396
5419
  },
5397
5420
  });
5398
5421
  await mermaid.run({ querySelector: ".mermaid" });
5399
- // ── Post-render colour forcing ───────────────────────────
5400
- // Mermaid 10's themeVariables don't fully replace built-in
5401
- // colour defaults on every chart type quadrant fills,
5402
- // pie slices, journey faces and similar can leak the
5403
- // theme preset's primary colour (purple/violet on
5404
- // theme:"dark"). Walk every rendered SVG and force-set
5405
- // fills directly on the elements we know about. Bulletproof
5406
- // because it runs LAST, after mermaid's own paint.
5407
- try {
5408
- document.querySelectorAll('.mermaid svg').forEach((svg) => {
5409
- // ── Quadrant chart ──
5410
- // The four quadrant rects + internal/external borders +
5411
- // point dots. Mermaid 10 uses class `.quadrant-fill` on
5412
- // each filled rect inside the chart group.
5413
- svg.querySelectorAll('rect.quadrant-fill, .quadrant-fill').forEach((el) => {
5414
- el.setAttribute('fill', t.vars.quadrantFill);
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.
5449
+ //
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'); };
5456
+ const setColor = (el, c) => { if (c) el.style.setProperty('color', c, 'important'); };
5457
+ const cyclePalette = (i) => t.palette[i % t.palette.length];
5458
+
5459
+ const applySpinePaletteOnce = () => {
5460
+ try {
5461
+ document.querySelectorAll('.mermaid svg').forEach((svg) => {
5462
+ // ── 1. Wipe mermaid's embedded <style> block ──
5463
+ // Mermaid generates `<style>.quadrant-chart > .quadrant-
5464
+ // point > circle { fill: #...; }` etc. inside each SVG;
5465
+ // those CSS rules beat our setAttribute-set fills. After
5466
+ // removal, our inline-style fills below have no rivals.
5467
+ svg.querySelectorAll(':scope > style, defs > style').forEach((s) => s.remove());
5468
+
5469
+ // ── 2. SVG canvas / background ──
5470
+ // Any rect that fills the whole SVG is the chart canvas;
5471
+ // pin to t.vars.background so the chart blends with its
5472
+ // spine frame instead of showing mermaid's default white.
5473
+ const sw = parseFloat(svg.getAttribute('width') || '0');
5474
+ svg.querySelectorAll(':scope > rect, :scope > g > rect.background, rect.main-bkg').forEach((el) => {
5475
+ const w = parseFloat(el.getAttribute('width') || '0');
5476
+ if (w > 0 && sw > 0 && w >= sw * 0.95) setFill(el, t.vars.background);
5415
5477
  });
5416
- // Defense for any rect inside .quadrant-chart that wasn't
5417
- // class-tagged (mermaid version drift). Skip the chart's
5418
- // outer container rect (full-svg-width).
5419
- const chartG = svg.querySelector('g.quadrant-chart, .quadrant-chart');
5420
- if (chartG) {
5421
- const svgWidth = parseFloat(svg.getAttribute('width') || '0');
5422
- chartG.querySelectorAll(':scope > g > rect, :scope > rect').forEach((el) => {
5423
- const w = parseFloat(el.getAttribute('width') || '0');
5424
- if (w > 0 && (svgWidth === 0 || w < svgWidth * 0.95)) {
5425
- el.setAttribute('fill', t.vars.quadrantFill);
5426
- }
5427
- });
5428
- }
5429
- // Quadrant data points · pointFill (the spine's
5430
- // strongest brand colour for marker-style data) with a
5431
- // frame-bg stroke for separation from the quadrant fill.
5432
- svg.querySelectorAll('circle.quadrant-point, .quadrant-point circle, g.quadrant-point > circle').forEach((el) => {
5433
- el.setAttribute('fill', t.vars.pointFill);
5434
- el.setAttribute('stroke', t.vars.background);
5478
+
5479
+ // ── 3. Quadrant chart ──
5480
+ // 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)
5484
+ // `.data-points` parent group
5485
+ // `.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));
5491
+ svg.querySelectorAll('.quadrant-internal-border, line.quadrant-internal-border').forEach((el) => setStroke(el, t.vars.inner));
5492
+ svg.querySelectorAll('.quadrant-external-border, rect.quadrant-external-border').forEach((el) => {
5493
+ setStroke(el, t.vars.border);
5494
+ setFill(el, 'none');
5435
5495
  });
5436
- // ── Pie chart slices · cycle palette, force stroke ──
5437
- svg.querySelectorAll('path.pieCircle').forEach((el, i) => {
5438
- el.setAttribute('fill', t.palette[i % t.palette.length]);
5439
- el.setAttribute('stroke', t.vars.background);
5496
+ svg.querySelectorAll('text.quadrant-title, .quadrant-title, .chart-title').forEach((el) => setFill(el, t.vars.titleFill));
5497
+ // Quadrant labels (the 4 corner labels). Mermaid 10
5498
+ // doesn't put them in a class; they're plain `<text>` inside
5499
+ // each `.quadrant` group. Walk children of `.quadrant` text.
5500
+ svg.querySelectorAll('.quadrant text, .quadrants text:not(.chart-title)').forEach((el) => setFill(el, t.vars.quadrantText));
5501
+ svg.querySelectorAll('text.quadrant-text, text.quadrant-quadrant-1-text-fill, text.quadrant-quadrant-2-text-fill, text.quadrant-quadrant-3-text-fill, text.quadrant-quadrant-4-text-fill').forEach((el) => setFill(el, t.vars.quadrantText));
5502
+ // Axis labels · "Low likelihood / High likelihood" etc.
5503
+ svg.querySelectorAll('.bottom-axis text, .left-axis text, .top-axis text, .right-axis text, text.x-axis-text-label, text.y-axis-text-label, .quadrant-x-axis-label, .quadrant-y-axis-label').forEach((el) => setFill(el, t.vars.axisText));
5504
+ // Data points · the load-bearing accent.
5505
+ svg.querySelectorAll('.data-point, .data-points circle, circle.data-point, .quadrant-point > circle, g.quadrant-point > circle, circle.quadrant-point').forEach((el) => {
5506
+ setFill(el, t.vars.pointFill);
5507
+ setStroke(el, t.vars.background);
5508
+ });
5509
+ svg.querySelectorAll('.data-point text, .data-points text, text.point-text, .quadrant-point text').forEach((el) => setFill(el, t.vars.titleFill));
5510
+
5511
+ // ── 4. Pie chart ──
5512
+ svg.querySelectorAll('path.pieCircle, g.pieCircle path, .slice').forEach((el, i) => {
5513
+ setFill(el, cyclePalette(i));
5514
+ setStroke(el, t.vars.background);
5440
5515
  });
5441
- // Pie outer ring border.
5442
5516
  svg.querySelectorAll('.pieOuterCircle, circle.pieOuterCircle').forEach((el) => {
5443
- el.setAttribute('stroke', t.vars.border);
5444
- el.setAttribute('fill', 'none');
5517
+ setStroke(el, t.vars.border);
5518
+ setFill(el, 'none');
5519
+ });
5520
+ svg.querySelectorAll('text.slice, text.pieLegendText, .legend text, g.legend text').forEach((el) => setFill(el, t.vars.titleFill));
5521
+ svg.querySelectorAll('.pieTitleText').forEach((el) => setFill(el, t.vars.titleFill));
5522
+
5523
+ // ── 5. xychart-beta ──
5524
+ // Bars cycle through palette; axes + grid + labels match
5525
+ // the spine's neutrals.
5526
+ svg.querySelectorAll('g.plot rect, g.bar-plot rect, g[class*="-plot"] rect, rect.bar').forEach((el, i) => {
5527
+ // Skip the plot-background rect (full plot width).
5528
+ const w = parseFloat(el.getAttribute('width') || '0');
5529
+ if (w >= 200) return;
5530
+ setFill(el, cyclePalette(i));
5531
+ setStroke(el, 'none');
5532
+ });
5533
+ svg.querySelectorAll('.xy-chart .background, rect.background').forEach((el) => setFill(el, 'transparent'));
5534
+ svg.querySelectorAll('.xy-chart text, g.xy-chart text, .xy-chart .x-axis text, .xy-chart .y-axis text').forEach((el) => setFill(el, t.vars.axisText));
5535
+ svg.querySelectorAll('.xy-chart .x-axis path.domain, .xy-chart .y-axis path.domain, .xy-chart .x-axis line, .xy-chart .y-axis line').forEach((el) => setStroke(el, t.vars.titleFill));
5536
+ svg.querySelectorAll('.xy-chart .grid line, .xy-chart line.grid').forEach((el) => setStroke(el, t.vars.inner));
5537
+
5538
+ // ── 6. Flowchart ──
5539
+ svg.querySelectorAll('.flowchart .node rect, .flowchart .node circle, .flowchart .node ellipse, .flowchart .node polygon, .node rect, .node circle, .node polygon, .node ellipse').forEach((el) => {
5540
+ setFill(el, t.vars.quadrantFill);
5541
+ setStroke(el, t.vars.border);
5542
+ });
5543
+ svg.querySelectorAll('.flowchart-link, .edgePath path, .edgePath .path, path.flowchart-link').forEach((el) => {
5544
+ setStroke(el, t.vars.border);
5545
+ setFill(el, 'none');
5546
+ });
5547
+ svg.querySelectorAll('marker path, #arrowhead path, marker#arrowhead path').forEach((el) => {
5548
+ setFill(el, t.vars.border);
5549
+ setStroke(el, t.vars.border);
5445
5550
  });
5446
- // ── Journey · face circles cycle palette ──
5447
- svg.querySelectorAll('circle.face-circle, .face-circle').forEach((el, i) => {
5448
- el.setAttribute('fill', t.palette[i % t.palette.length]);
5551
+ svg.querySelectorAll('.cluster rect').forEach((el) => {
5552
+ setFill(el, t.vars.background);
5553
+ setStroke(el, t.vars.border);
5449
5554
  });
5450
- // ── Generic SVG canvas guard · any rect that fills the
5451
- // entire SVG and was painted with mermaid's default
5452
- // primaryColor (could be off-brand on dark spines). Cap
5453
- // its fill to t.vars.background.
5454
- const root = svg.querySelector(':scope > rect, :scope > g > rect.background');
5455
- if (root) {
5456
- const w = parseFloat(root.getAttribute('width') || '0');
5457
- const sw = parseFloat(svg.getAttribute('width') || '0');
5458
- if (w > 0 && sw > 0 && w >= sw * 0.95) {
5459
- root.setAttribute('fill', t.vars.background);
5460
- }
5555
+ svg.querySelectorAll('.cluster text, .nodeLabel, .label foreignObject *').forEach((el) => {
5556
+ setFill(el, t.vars.titleFill);
5557
+ setColor(el, t.vars.titleFill);
5558
+ });
5559
+ svg.querySelectorAll('.edgeLabel, .edgeLabel foreignObject *').forEach((el) => {
5560
+ setFill(el, t.vars.axisText);
5561
+ setColor(el, t.vars.axisText);
5562
+ el.style.setProperty('background-color', t.vars.background, 'important');
5563
+ });
5564
+
5565
+ // ── 7. Mindmap ──
5566
+ svg.querySelectorAll('.mindmap g.section circle, .mindmap-node circle, g.mindmap-node circle').forEach((el, i) => {
5567
+ setFill(el, cyclePalette(i));
5568
+ setStroke(el, t.vars.border);
5569
+ });
5570
+ svg.querySelectorAll('.mindmap g.section rect, .mindmap-node rect, g.mindmap-node rect').forEach((el, i) => {
5571
+ setFill(el, cyclePalette(i));
5572
+ setStroke(el, t.vars.border);
5573
+ });
5574
+ svg.querySelectorAll('.mindmap g.section polygon, .mindmap-node polygon, g.mindmap-node polygon').forEach((el, i) => {
5575
+ setFill(el, cyclePalette(i));
5576
+ setStroke(el, t.vars.border);
5577
+ });
5578
+ svg.querySelectorAll('.mindmap-edges path, .mindmap path.edge').forEach((el) => {
5579
+ setStroke(el, t.vars.border);
5580
+ setFill(el, 'none');
5581
+ });
5582
+ svg.querySelectorAll('.mindmap text, .mindmap-node text, g.mindmap-node text, .mindmap foreignObject *').forEach((el) => {
5583
+ setFill(el, t.vars.titleFill);
5584
+ setColor(el, t.vars.titleFill);
5585
+ });
5586
+
5587
+ // ── 8. Sequence diagram ──
5588
+ svg.querySelectorAll('rect.actor, .actor').forEach((el) => {
5589
+ setFill(el, t.vars.quadrantFill);
5590
+ setStroke(el, t.vars.border);
5591
+ });
5592
+ svg.querySelectorAll('line.actor-line, .actor-line').forEach((el) => setStroke(el, t.vars.border));
5593
+ svg.querySelectorAll('text.actor, text.actor tspan, .actor-box-text, .messageText, text.messageText, .noteText, text.noteText, .note text').forEach((el) => setFill(el, t.vars.titleFill));
5594
+ svg.querySelectorAll('.messageLine0, .messageLine1, line.messageLine0, line.messageLine1').forEach((el) => setStroke(el, t.vars.titleFill));
5595
+ svg.querySelectorAll('rect.note, .note').forEach((el) => {
5596
+ setFill(el, t.vars.accentSoft);
5597
+ setStroke(el, t.vars.border);
5598
+ });
5599
+ svg.querySelectorAll('.activation0, .activation1, .activation2, rect.activation0, rect.activation1, rect.activation2').forEach((el) => {
5600
+ setFill(el, t.vars.inner);
5601
+ setStroke(el, t.vars.border);
5602
+ });
5603
+ svg.querySelectorAll('.labelBox, rect.labelBox').forEach((el) => {
5604
+ setFill(el, t.vars.quadrantFill);
5605
+ setStroke(el, t.vars.border);
5606
+ });
5607
+
5608
+ // ── 9. State diagram ──
5609
+ svg.querySelectorAll('.stateGroup rect, g.stateGroup rect, rect.state').forEach((el) => {
5610
+ setFill(el, t.vars.quadrantFill);
5611
+ setStroke(el, t.vars.border);
5612
+ });
5613
+ svg.querySelectorAll('.stateGroup circle, g.stateGroup circle').forEach((el) => {
5614
+ setFill(el, t.vars.titleFill);
5615
+ setStroke(el, t.vars.titleFill);
5616
+ });
5617
+ svg.querySelectorAll('.stateGroup line, line.transition, path.transition').forEach((el) => {
5618
+ setStroke(el, t.vars.border);
5619
+ setFill(el, 'none');
5620
+ });
5621
+ svg.querySelectorAll('.stateLabel, .state-description, .stateGroup text').forEach((el) => setFill(el, t.vars.titleFill));
5622
+ svg.querySelectorAll('.compositeBackground, rect.compositeBackground').forEach((el) => {
5623
+ setFill(el, t.vars.background);
5624
+ setStroke(el, t.vars.border);
5625
+ });
5626
+
5627
+ // ── 10. Journey ──
5628
+ svg.querySelectorAll('.face-circle, circle.face-circle, .journey-section .face').forEach((el, i) => {
5629
+ setFill(el, cyclePalette(i));
5630
+ setStroke(el, t.vars.background);
5631
+ });
5632
+ svg.querySelectorAll('.journey-section text, .section text, .journey-section foreignObject *').forEach((el) => {
5633
+ setFill(el, t.vars.titleFill);
5634
+ setColor(el, t.vars.titleFill);
5635
+ });
5636
+ svg.querySelectorAll('rect.journey, .journey-section rect').forEach((el) => {
5637
+ setFill(el, t.vars.quadrantFill);
5638
+ setStroke(el, t.vars.border);
5639
+ });
5640
+
5641
+ // ── 11. Gantt ──
5642
+ svg.querySelectorAll('rect.task, rect.task0, rect.task1, rect.task2, rect.task3, rect.activeTask0, rect.activeTask1, rect.activeTask2, rect.activeTask3').forEach((el, i) => {
5643
+ setFill(el, cyclePalette(i));
5644
+ setStroke(el, 'none');
5645
+ });
5646
+ svg.querySelectorAll('rect.done, rect.done0, rect.done1, rect.done2, rect.done3, rect.doneTask').forEach((el) => {
5647
+ setFill(el, t.vars.inner);
5648
+ setStroke(el, t.vars.border);
5649
+ });
5650
+ svg.querySelectorAll('rect.crit, rect.crit0, rect.crit1, rect.crit2, rect.crit3').forEach((el) => {
5651
+ setFill(el, t.vars.accent);
5652
+ setStroke(el, t.vars.accent);
5653
+ });
5654
+ svg.querySelectorAll('.taskText, .taskTextOutsideRight, .taskTextOutsideLeft, text.taskText').forEach((el) => setFill(el, t.vars.titleFill));
5655
+ svg.querySelectorAll('.tick text, g.tick text').forEach((el) => setFill(el, t.vars.axisText));
5656
+ svg.querySelectorAll('.tick line, .grid line, g.tick line, g.grid line').forEach((el) => setStroke(el, t.vars.inner));
5657
+ svg.querySelectorAll('.section0 text, .section1 text, .section2 text, .section3 text, .sectionTitle0, .sectionTitle1, .sectionTitle2, .sectionTitle3').forEach((el) => setFill(el, t.vars.axisText));
5658
+ svg.querySelectorAll('line.today, .today').forEach((el) => setStroke(el, t.vars.accent));
5659
+
5660
+ // ── 12. Timeline ──
5661
+ svg.querySelectorAll('.timeline rect, .timeline-section rect').forEach((el, i) => {
5662
+ setFill(el, cyclePalette(i));
5663
+ setStroke(el, t.vars.border);
5664
+ });
5665
+ svg.querySelectorAll('.timeline text, .timeline foreignObject *').forEach((el) => {
5666
+ setFill(el, t.vars.titleFill);
5667
+ setColor(el, t.vars.titleFill);
5668
+ });
5669
+ svg.querySelectorAll('.timeline path, .timeline-line').forEach((el) => {
5670
+ setStroke(el, t.vars.border);
5671
+ setFill(el, 'none');
5672
+ });
5673
+
5674
+ // ── 13. Git graph ──
5675
+ svg.querySelectorAll('.commit-bullets circle, circle.commit, .commit').forEach((el, i) => {
5676
+ setFill(el, cyclePalette(i));
5677
+ setStroke(el, t.vars.border);
5678
+ });
5679
+ svg.querySelectorAll('.commit-arrows path, path.commit-arrow, .commit-arrow').forEach((el) => {
5680
+ setStroke(el, t.vars.border);
5681
+ setFill(el, 'none');
5682
+ });
5683
+ svg.querySelectorAll('text.commit-label, .commit-label, .branch-label').forEach((el) => setFill(el, t.vars.titleFill));
5684
+
5685
+ // ── 14. Generic title (every chart type) ──
5686
+ svg.querySelectorAll('text.titleText, .titleText, text.chart-title').forEach((el) => setFill(el, t.vars.titleFill));
5687
+
5688
+ // ── 15. Universal text safety net ──
5689
+ // Any text element that survived the per-type passes
5690
+ // without an inline style fill — pin to titleFill so no
5691
+ // text leaks the mermaid-default purple/blue.
5692
+ svg.querySelectorAll('text').forEach((el) => {
5693
+ if (!el.style.fill) setFill(el, t.vars.titleFill);
5694
+ });
5695
+
5696
+ // ── 16. Mark the parent pre.mermaid as painted ──
5697
+ // 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.
5703
+ const host = svg.closest('pre.mermaid');
5704
+ if (host && !host.classList.contains('is-painted')) {
5705
+ host.classList.add('is-painted');
5461
5706
  }
5707
+ }); // forEach svg
5708
+ } catch (forceColorErr) {
5709
+ console.warn('[mermaid] post-render palette takeover failed:', forceColorErr);
5710
+ }
5711
+ }; // applySpinePaletteOnce
5712
+
5713
+ // Safety net · if the takeover errors out for some reason
5714
+ // (script bug, exotic chart type), make sure every chart
5715
+ // becomes visible within 6s so the user at least sees the
5716
+ // chart (even if colours leak) instead of an empty frame.
5717
+ // Normal flow: each successful takeover pass marks its
5718
+ // pre.mermaid as `.is-painted` immediately, so the chart
5719
+ // appears as soon as the first pass succeeds (~16ms).
5720
+ setTimeout(() => {
5721
+ document.querySelectorAll('pre.mermaid:not(.is-painted)').forEach((el) => {
5722
+ el.classList.add('is-painted');
5462
5723
  });
5463
- } catch (forceColorErr) {
5464
- console.warn('[mermaid] post-render colour force failed:', forceColorErr);
5465
- }
5724
+ }, 6000);
5725
+
5726
+ // ── Continuous re-apply for the first 5 seconds ──
5727
+ // The user reported strict alternation ("1st wrong, 2nd
5728
+ // right, 3rd wrong, 4th right…") which means a single
5729
+ // setTimeout chain wasn't enough — mermaid's deferred
5730
+ // work was landing in different timing windows on
5731
+ // alternate loads, and my chain was missing odd-numbered
5732
+ // ones.
5733
+ //
5734
+ // Defense in depth: re-apply EVERY 60ms for the first 5
5735
+ // seconds. By the time the user perceives the chart, our
5736
+ // styles have been pinned 80+ times across every possible
5737
+ // mermaid post-render window. After 5s, switch to a pure
5738
+ // MutationObserver (only fires on real DOM changes) so we
5739
+ // keep up with future mutations without the polling cost.
5740
+ //
5741
+ // Each call is idempotent — same selectors set the same
5742
+ // inline-style values, so re-running is a cheap no-op
5743
+ // when nothing changed.
5744
+ applySpinePaletteOnce(); // sync, before next paint
5745
+ let pollCount = 0;
5746
+ const pollInterval = setInterval(() => {
5747
+ applySpinePaletteOnce();
5748
+ if (++pollCount > 80) clearInterval(pollInterval); // 80 × 60ms ≈ 4.8s
5749
+ }, 60);
5750
+
5751
+ // Persistent MutationObserver · catches any post-poll
5752
+ // mutations (container resize, font swap, late SSE
5753
+ // re-render). Watches structural changes only — our own
5754
+ // setProperty calls modify the `style` attribute, which
5755
+ // is excluded by `attributes: false` (default), so this
5756
+ // can't loop on itself.
5757
+ document.querySelectorAll('.mermaid svg').forEach((svg) => {
5758
+ const obs = new MutationObserver(() => {
5759
+ requestAnimationFrame(applySpinePaletteOnce);
5760
+ });
5761
+ obs.observe(svg, { childList: true, subtree: true });
5762
+ });
5466
5763
  // ── Post-render xychart bar slimming ─────────────────────
5467
5764
  // mermaid xychart-beta renders bars at near-full slot width
5468
5765
  // regardless of `plotReservedSpacePercent` in some 11.x
@@ -0,0 +1,158 @@
1
+ /* ────────────────────────────────────────────────────────────────
2
+ typing-sfx.js · Subtle keyboard-click sound effect played as
3
+ directors / chair / brief writer stream their text into the
4
+ chat. Pure synthesised audio · no asset to ship, no autoplay
5
+ policy violations until the user has clicked something.
6
+
7
+ Wire-up:
8
+ · public/index.html loads this before public/app.js.
9
+ · app.js calls window.boardroomTypingSfx.tick() on every
10
+ `message-token` and `brief-token` SSE chunk.
11
+ · The user-settings overlay's User pane carries an enable /
12
+ disable toggle that persists in localStorage.
13
+
14
+ Design choices the comments below justify:
15
+ · Synthesised, not file-based · removes a network round-trip
16
+ and keeps the install slim. The synth is a 40ms decaying
17
+ white-noise burst routed through a bandpass at ~1.8kHz with
18
+ slight per-tick frequency jitter so the cadence doesn't
19
+ fatigue the ear like a metronome.
20
+ · Lazily-created AudioContext · Chrome / Safari refuse to
21
+ resume a context until the page has received a real user
22
+ gesture. We hold off creation until tick() is first called
23
+ AFTER the first interaction; otherwise the first audible
24
+ tick would be a console warning instead of a sound.
25
+ · Throttled to ~12 ticks/sec · token rates of 30-60/s would
26
+ otherwise produce a continuous hiss instead of a typewriter
27
+ cadence. The throttle stays steady regardless of token rate,
28
+ which is the right behaviour: the sound is a presence cue,
29
+ not a literal mapping to byte arrival.
30
+ · Muted while the tab is backgrounded · browsers throttle audio
31
+ there anyway, but explicit muting avoids the rare case where
32
+ a queued AudioBufferSourceNode plays right when the tab
33
+ refocuses.
34
+ ──────────────────────────────────────────────────────────────── */
35
+
36
+ (function () {
37
+ const STORAGE_KEY = "boardroom.sfx.typing";
38
+ // Default ON · the user explicitly asked for this feature. They
39
+ // can disable it via the Preference → User pane toggle.
40
+ const DEFAULT_ENABLED = true;
41
+ // Minimum gap between ticks. Set the tick rate; lower = busier.
42
+ // 80ms ≈ 12.5 Hz — fast typist cadence, not a chattering buzz.
43
+ const MIN_TICK_INTERVAL_MS = 80;
44
+
45
+ let _ctx = null;
46
+ let _ctxFailed = false;
47
+ let _hadGesture = false;
48
+ let _enabled = readEnabled();
49
+ let _lastTickAt = 0;
50
+
51
+ function readEnabled() {
52
+ try {
53
+ const raw = localStorage.getItem(STORAGE_KEY);
54
+ if (raw === "on") return true;
55
+ if (raw === "off") return false;
56
+ } catch { /* private mode etc · default-on still applies */ }
57
+ return DEFAULT_ENABLED;
58
+ }
59
+
60
+ function writeEnabled(on) {
61
+ try { localStorage.setItem(STORAGE_KEY, on ? "on" : "off"); }
62
+ catch { /* swallow · localStorage may be locked */ }
63
+ }
64
+
65
+ /** Mark that the user has interacted with the page · only after
66
+ * this fires can we safely create / resume an AudioContext.
67
+ * Listeners are passive + once-per-event-type for cheapness. */
68
+ function markGesture() {
69
+ _hadGesture = true;
70
+ // If a context was created pre-gesture (some browsers allow it
71
+ // but leave it suspended), nudge it now.
72
+ if (_ctx && _ctx.state === "suspended") {
73
+ _ctx.resume().catch(() => { /* swallow · keep silent rather than throw */ });
74
+ }
75
+ }
76
+ ["pointerdown", "keydown", "touchstart"].forEach((ev) => {
77
+ window.addEventListener(ev, markGesture, { passive: true, capture: true });
78
+ });
79
+
80
+ function ensureContext() {
81
+ if (_ctxFailed) return null;
82
+ if (_ctx) return _ctx;
83
+ if (!_hadGesture) return null; // refuse to create until gestured
84
+ try {
85
+ const Ctx = window.AudioContext || window.webkitAudioContext;
86
+ if (!Ctx) { _ctxFailed = true; return null; }
87
+ _ctx = new Ctx();
88
+ // If autoplay policy left it suspended even after gesture,
89
+ // resume explicitly · safe to call multiple times.
90
+ if (_ctx.state === "suspended") {
91
+ _ctx.resume().catch(() => { /* swallow */ });
92
+ }
93
+ return _ctx;
94
+ } catch {
95
+ _ctxFailed = true;
96
+ return null;
97
+ }
98
+ }
99
+
100
+ function tick() {
101
+ if (!_enabled) return;
102
+ if (document.visibilityState !== "visible") return; // background tab
103
+ const now = (typeof performance !== "undefined" ? performance.now() : Date.now());
104
+ if (now - _lastTickAt < MIN_TICK_INTERVAL_MS) return;
105
+ const ctx = ensureContext();
106
+ if (!ctx) return;
107
+ _lastTickAt = now;
108
+
109
+ const t0 = ctx.currentTime;
110
+ // 40ms of decaying white noise · the click texture comes from
111
+ // the noise burst itself, the bandpass below shapes its colour.
112
+ const dur = 0.04;
113
+ const buf = ctx.createBuffer(1, Math.max(1, Math.floor(ctx.sampleRate * dur)), ctx.sampleRate);
114
+ const data = buf.getChannelData(0);
115
+ for (let i = 0; i < data.length; i++) {
116
+ // Linear amplitude decay across the buffer · gives the click
117
+ // a natural "tap then fade" envelope before the gain node's
118
+ // own envelope fine-tunes the start.
119
+ data[i] = (Math.random() * 2 - 1) * (1 - i / data.length);
120
+ }
121
+ const noise = ctx.createBufferSource();
122
+ noise.buffer = buf;
123
+
124
+ // Bandpass · keyboard-tap colour is mostly 1.5-3kHz. Jitter the
125
+ // centre frequency a little per tick so consecutive clicks
126
+ // don't sound mechanically identical.
127
+ const bp = ctx.createBiquadFilter();
128
+ bp.type = "bandpass";
129
+ bp.frequency.value = 1700 + Math.random() * 800;
130
+ bp.Q.value = 1.4;
131
+
132
+ // Master envelope · 2ms attack, exponential 50ms decay. Quiet
133
+ // peak (gain ~0.06) so the cue stays under the user's voice
134
+ // floor instead of becoming the loudest thing on screen.
135
+ const gain = ctx.createGain();
136
+ gain.gain.setValueAtTime(0.0001, t0);
137
+ gain.gain.exponentialRampToValueAtTime(0.06, t0 + 0.002);
138
+ gain.gain.exponentialRampToValueAtTime(0.0001, t0 + 0.05);
139
+
140
+ noise.connect(bp).connect(gain).connect(ctx.destination);
141
+ noise.start(t0);
142
+ noise.stop(t0 + 0.06);
143
+ }
144
+
145
+ function setEnabled(on) {
146
+ _enabled = !!on;
147
+ writeEnabled(_enabled);
148
+ // No-op when disabling · live AudioContext stays alive cheaply
149
+ // (a few KB) and an outright close() leaves us re-paying the
150
+ // creation cost if the user toggles back on within the session.
151
+ }
152
+
153
+ function isEnabled() { return _enabled; }
154
+
155
+ // Public surface · attached to window so app.js (and the
156
+ // user-settings toggle) can reach it without an import.
157
+ window.boardroomTypingSfx = { tick, setEnabled, isEnabled };
158
+ })();