@bongos/core 1.19.512 → 1.19.513

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.
Files changed (35) hide show
  1. package/.bongos-core.json +62 -37
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +2 -0
  4. package/clients/bongos-client/index.cjs +2 -0
  5. package/clients/bongos-client/index.d.ts +3 -0
  6. package/clients/bongos-client/index.mjs +2 -0
  7. package/docs/adr/0239-a-recommendation-is-a-table-that-carries-its-own-reason.md +177 -0
  8. package/docs/adr/README.md +1 -0
  9. package/docs/api/openapi.json +40 -3
  10. package/docs/api-reference.md +3 -2
  11. package/docs/copy-inventory.md +36 -35
  12. package/docs/copy-registry.json +93 -74
  13. package/docs/module-api-changelog.md +2 -0
  14. package/modules/dev-box/app/src/vendor/bongos-client.cjs +2 -0
  15. package/modules/hall-ui/public/hall-kit.js +309 -0
  16. package/modules/hall-ui/public/hall-render.js +10 -38
  17. package/modules/hall-ui/public/profile-charts.js +58 -65
  18. package/modules/hall-ui/public/profile.css +9 -18
  19. package/modules/hall-ui/public/style.css +101 -15
  20. package/modules/hall-ui/public/watch.css +6 -14
  21. package/modules/hall-ui/public/watch.html +1 -1
  22. package/modules/hall-ui/public/watch.js +139 -54
  23. package/modules/provisioning/recommendations.js +260 -0
  24. package/modules/provisioning/routes/provisioning.js +29 -0
  25. package/package-lock.json +2 -2
  26. package/package.json +1 -1
  27. package/src/module-api.js +1 -1
  28. package/tests/hall_audit.mjs +84 -0
  29. package/tests/hall_kit_charts.mjs +328 -0
  30. package/tests/hall_profile_world.mjs +27 -6
  31. package/tests/helpers.mjs +161 -0
  32. package/tests/joinability_invite_only.mjs +10 -2
  33. package/tests/provisioning_recommendations.mjs +448 -0
  34. package/tests/task_detail_ui.mjs +1 -153
  35. package/tests/watch_applications_queue.mjs +381 -0
@@ -607,6 +607,306 @@
607
607
  `<img class="ship-visual-thumb" src="${esc(url)}" alt="${esc(alt)}" loading="lazy"></a>`;
608
608
  }
609
609
 
610
+ // ---- charts (task 1003445) ------------------------------------------------
611
+ //
612
+ // Charts were the one primitive the kit never got, which is exactly why the
613
+ // hall grew FOUR drawing sites that did not know about each other. Three of
614
+ // them draw the SAME horizontal bar: the Curve's "kinds of work shipped"
615
+ // (hall-render.js), Watch's Review-quality trend (watch.js), and the
616
+ // profile's craft mix (profile-charts.js `mixChart`). The fourth is the
617
+ // Curve's line chart, `sparkline()`. The module's hard rule names
618
+ // pager/filter/tab/rail and stops, so nothing failed when a fifth copy was
619
+ // one paste away.
620
+ //
621
+ // Two shapes cover every charted surface the hall has: a LINE (with an
622
+ // optional running total, which is what a treasury or "works by now" series
623
+ // wants) and a horizontal BAR SERIES. Pure string builders like the rest of
624
+ // the kit — no state, no render loop, no measuring pass.
625
+ //
626
+ // Colour is the caller's on a line, because the Curve spends four different
627
+ // accents across its four charts; `color` therefore stays a CSS value rather
628
+ // than becoming an enum. Bars take a `tone` instead, since the three call
629
+ // sites differed only in tone (accent vs ink) and geometry — and geometry
630
+ // rides component custom properties, so a fifth chart needs no new CSS and
631
+ // no new drawing code.
632
+
633
+ const CHART_W = 260;
634
+ const CHART_H = 46;
635
+ const CHART_PAD = 3;
636
+
637
+ // A CSS length, for a value that lands in a style attribute rather than in
638
+ // text — so it is WHITELISTED, not escaped: escaping would leave a ';' free
639
+ // to end the declaration and start another.
640
+ //
641
+ // Both halves are load-bearing. The positive charset admits letters and
642
+ // parentheses (it has to: `minmax(72px, 110px)`, `calc(100% - 4px)`), which
643
+ // means `url(...)` is spellable within it — so the negative test is what
644
+ // actually stops a track pulling a remote asset, and is not the redundancy
645
+ // it looks like. Hoisted to module scope: these ran per option, per render.
646
+ const CSS_LEN_OK = /^[0-9a-z%.,()\s+*/-]+$/i;
647
+ const CSS_LEN_BAD = /url|var\s*\(|[;{}:]/i;
648
+ const CSS_LEN = (v) => {
649
+ const s = String(v);
650
+ return CSS_LEN_OK.test(s) && !CSS_LEN_BAD.test(s) ? s : '';
651
+ };
652
+
653
+ // Coerce to finite numbers. A gap-filled feed sends nulls, and a null must
654
+ // read as zero rather than poisoning a min/max or a running sum.
655
+ function chartNums(values) {
656
+ return (values || []).map((v) => (Number.isFinite(Number(v)) ? Number(v) : 0));
657
+ }
658
+
659
+ // Running total, for a series whose question is "how much by now" rather
660
+ // than "how much that day".
661
+ function cumulate(values) {
662
+ let run = 0;
663
+ return chartNums(values).map((v) => (run += v));
664
+ }
665
+
666
+ // The line geometry, separated from the markup so a chart that needs the
667
+ // maths without this SVG can take just this. Returns { points, x, y, nums }.
668
+ //
669
+ // There is deliberately no `cumulative` option: `cumulate` is exported, so a
670
+ // running-total line is `sparklineHtml(OTBKit.cumulate(values))` — one way to
671
+ // do it rather than two, and no option whose only exercise is its own test.
672
+ function linePoints(values, opts = {}) {
673
+ const W = opts.width || CHART_W;
674
+ const H = opts.height || CHART_H;
675
+ const pad = opts.pad === undefined ? CHART_PAD : opts.pad;
676
+ const nums = chartNums(values);
677
+ const n = nums.length;
678
+ // A flat series has span 0 and would divide by zero; 1 puts it mid-box.
679
+ const min = n ? Math.min(...nums) : 0;
680
+ const span = (n ? Math.max(...nums) : 0) - min || 1;
681
+ const x = (i) => (n === 1 ? W / 2 : pad + (i * (W - 2 * pad)) / (n - 1));
682
+ const y = (v) => H - pad - ((v - min) / span) * (H - 2 * pad);
683
+ return {
684
+ nums, x, y, width: W, height: H, pad,
685
+ points: nums.map((v, i) => `${x(i).toFixed(1)},${y(v).toFixed(1)}`).join(' '),
686
+ };
687
+ }
688
+
689
+ // A line + filled area. opts: { width, height, color, ariaLabel }
690
+ function sparklineHtml(values, opts = {}) {
691
+ const g = linePoints(values, opts);
692
+ const color = opts.color || 'var(--accent)';
693
+ if (!g.nums.length) {
694
+ return `<svg class="kit-spark" viewBox="0 0 ${g.width} ${g.height}" preserveAspectRatio="none"`
695
+ + ' role="img" aria-label="no data"></svg>';
696
+ }
697
+ const last = g.nums[g.nums.length - 1];
698
+ const area = `${g.pad},${g.height - g.pad} ${g.points} ${g.width - g.pad},${g.height - g.pad}`;
699
+ const label = opts.ariaLabel || `trend, latest ${last}`;
700
+ return `<svg class="kit-spark" viewBox="0 0 ${g.width} ${g.height}" preserveAspectRatio="none"`
701
+ + ` role="img" aria-label="${esc(label)}">`
702
+ + `<polyline class="kit-spark__area" points="${area}" fill="${esc(color)}" />`
703
+ + `<polyline class="kit-spark__line" points="${g.points}" fill="none" stroke="${esc(color)}" />`
704
+ // One point draws no line, so it gets a dot or the chart reads as empty.
705
+ + (g.nums.length === 1
706
+ ? `<circle cx="${g.x(0).toFixed(1)}" cy="${g.y(last).toFixed(1)}" r="2.4" fill="${esc(color)}" />`
707
+ : '')
708
+ + '</svg>';
709
+ }
710
+
711
+ // Horizontal labelled bars. rows: [{ label, value, valueLabel }].
712
+ // opts: { max, tone: 'accent'|'neutral', labelWidth, valueWidth, barHeight,
713
+ // pill, ariaLabel, list }
714
+ //
715
+ // `max` is the caller's when several series must share one scale; otherwise
716
+ // the largest value here. It floors at 1 so an all-zero set draws empty
717
+ // tracks instead of dividing by zero.
718
+ function barSeriesHtml(rows, opts = {}) {
719
+ const list = (rows || []).filter((r) => r && r.label !== undefined && r.label !== null);
720
+ if (!list.length) return '';
721
+ const vals = list.map((r) => (Number.isFinite(Number(r.value)) ? Number(r.value) : 0));
722
+ const givenMax = Number(opts.max);
723
+ const max = Number.isFinite(givenMax) && givenMax > 0 ? givenMax : Math.max(1, ...vals);
724
+ // Geometry and tone ride custom properties, so a caller differs from the
725
+ // others by an OPTION and never by a second class family. Values are
726
+ // CSS-literal, so they are whitelisted rather than escaped — an arbitrary
727
+ // string in a style attribute is how a caller smuggles in a declaration.
728
+ const cssLen = CSS_LEN;
729
+ const radius = opts.pill ? 'var(--radius-pill)' : cssLen(opts.radius);
730
+ const style = [
731
+ opts.labelWidth ? `--kit-bars-label-w:${cssLen(opts.labelWidth)}` : '',
732
+ opts.valueWidth ? `--kit-bars-val-w:${cssLen(opts.valueWidth)}` : '',
733
+ opts.barHeight ? `--kit-bars-h:${cssLen(opts.barHeight)}` : '',
734
+ radius ? `--kit-bars-radius:${radius}` : '',
735
+ opts.track === 'soft' ? '--kit-bars-track:var(--rule-soft)' : '',
736
+ ].filter((d) => d && !d.endsWith(':')).join(';');
737
+ // `list` opts in to list semantics — the profile's mix is announced as a
738
+ // list; the Curve's kinds are decoration beside a figure that already
739
+ // names them. The track is aria-hidden either way: it carries no
740
+ // information the value cell does not already say in words.
741
+ const roleAttr = opts.list ? ' role="list"' : '';
742
+ const rowRole = opts.list ? ' role="listitem"' : '';
743
+ return `<div class="kit-bars${opts.tone === 'neutral' ? ' kit-bars--neutral' : ''}"`
744
+ + (style ? ` style="${esc(style)}"` : '')
745
+ + roleAttr
746
+ + (opts.ariaLabel ? ` aria-label="${esc(opts.ariaLabel)}"` : '')
747
+ + '>'
748
+ + list.map((r, i) => {
749
+ const w = Math.round((vals[i] / max) * 100);
750
+ const val = r.valueLabel === undefined || r.valueLabel === null
751
+ ? String(vals[i])
752
+ : String(r.valueLabel);
753
+ return `<div class="kit-bars__row"${rowRole}>`
754
+ + `<span class="kit-bars__label">${esc(r.label)}</span>`
755
+ + `<span class="kit-bars__track" aria-hidden="true">`
756
+ + `<span class="kit-bars__fill" style="width:${w}%"></span></span>`
757
+ + `<span class="kit-bars__val">${esc(val)}</span>`
758
+ + '</div>';
759
+ }).join('')
760
+ + '</div>';
761
+ }
762
+
763
+ // ---- plotted charts: the axis frame both of them shared ------------------
764
+ //
765
+ // A step LINE over a time axis and a COLUMN series are different shapes from
766
+ // an index-scaled sparkline, so they are not sparklineHtml with an option.
767
+ // What they ARE is the same plot: identical viewBox, identical axis rule,
768
+ // identical hidden crosshair, identical pair of end ticks, identical
769
+ // role/tabindex/aria contract. That frame was written twice in
770
+ // profile-charts.js, and the goal's next three charts (the treasury line on
771
+ // /drachmae, repo-health's trend on /watch) each want it again — which is
772
+ // what makes it a primitive rather than a shared local.
773
+ //
774
+ // The seam is the same one the bars use: the kit draws the SERIES and the
775
+ // plot, the page owns the SURFACE (figure, caption, live readout, the
776
+ // <details> table twin) and the wiring. Date semantics stay with the caller
777
+ // too — it passes x0/x1/yMax already resolved, so the kit never learns what
778
+ // a week is.
779
+
780
+ const PLOT_W = 320;
781
+ const PLOT_H = 132;
782
+ // t: the end label's room. b: the axis band.
783
+ const PLOT_PAD = { l: 6, r: 8, t: 20, b: 24 };
784
+
785
+ function plotBox(opts = {}) {
786
+ const width = opts.width || PLOT_W;
787
+ const height = opts.height || PLOT_H;
788
+ const pad = { ...PLOT_PAD, ...(opts.pad || {}) };
789
+ return { width, height, pad, base: height - pad.b };
790
+ }
791
+
792
+ // A STEP line over a time axis: the value holds until the next recorded
793
+ // point, then rises — which is what a cumulative "how much by now" series
794
+ // means, and why this is not a straight polyline between samples.
795
+ // points: [{ x, value }] in data space, sorted. Returns { d, area, xs }.
796
+ function timeStepGeometry(points, opts = {}) {
797
+ const { width, height, pad, base } = plotBox(opts);
798
+ const list = points || [];
799
+ if (!list.length) return { d: '', area: '', xs: [], base };
800
+ const x0 = Number(opts.x0 ?? list[0].x);
801
+ const x1 = Number(opts.x1 ?? list[list.length - 1].x);
802
+ const last = list[list.length - 1];
803
+ // A span of 0 (one point, no window) would divide by zero; yMax 0 likewise.
804
+ const spanX = (x1 - x0) || 1;
805
+ const yMax = Number(opts.yMax ?? last.value) || 1;
806
+ const px = (t) => pad.l + ((t - x0) / spanX) * (width - pad.l - pad.r);
807
+ const py = (v) => height - pad.b - (v / yMax) * (height - pad.t - pad.b);
808
+ let d = `M${px(list[0].x).toFixed(1)},${py(list[0].value).toFixed(1)}`;
809
+ list.forEach((p, i) => {
810
+ if (i === 0) return;
811
+ // across at the PREVIOUS height, then up: the step.
812
+ d += ` L${px(p.x).toFixed(1)},${py(list[i - 1].value).toFixed(1)}`
813
+ + ` L${px(p.x).toFixed(1)},${py(p.value).toFixed(1)}`;
814
+ });
815
+ d += ` L${px(x1).toFixed(1)},${py(last.value).toFixed(1)}`;
816
+ const area = `${d} L${px(x1).toFixed(1)},${base} L${px(list[0].x).toFixed(1)},${base} Z`;
817
+ return {
818
+ d, area, base, px, py,
819
+ xs: list.map((p) => px(p.x)),
820
+ endX: px(x1),
821
+ endY: py(last.value),
822
+ };
823
+ }
824
+
825
+ // The line + its area + the end dot, ready to drop into plotSvgHtml.
826
+ function timeStepSeriesHtml(points, opts = {}) {
827
+ const g = timeStepGeometry(points, opts);
828
+ if (!g.d) return { body: '', xs: [], geometry: g };
829
+ return {
830
+ geometry: g,
831
+ xs: g.xs,
832
+ body: `<path class="kit-plot__area" d="${g.area}"></path>`
833
+ + `<path class="kit-plot__line" d="${g.d}"></path>`
834
+ + `<circle class="kit-plot__dot" cx="${g.endX.toFixed(1)}" cy="${g.endY.toFixed(1)}" r="4"></circle>`,
835
+ };
836
+ }
837
+
838
+ // Columns: at most 24px thick, a rounded cap on a square base, a 2px gap
839
+ // between neighbours. A zero bucket draws a 1px stub on the axis rather than
840
+ // nothing, so a gap in the series still reads as a week that happened.
841
+ function columnSeriesHtml(values, opts = {}) {
842
+ const { width, height, pad, base } = plotBox(opts);
843
+ const nums = chartNums(values);
844
+ const n = nums.length;
845
+ if (!n) return { body: '', xs: [], base, py: () => base };
846
+ const max = Math.max(1, ...nums);
847
+ const slot = (width - pad.l - pad.r) / n;
848
+ const bw = Math.min(24, Math.max(2, slot - 2));
849
+ const py = (v) => base - (v / max) * (height - pad.t - pad.b);
850
+ const xs = nums.map((_, i) => pad.l + i * slot + slot / 2);
851
+ const body = nums.map((v, i) => {
852
+ const x = xs[i] - bw / 2;
853
+ const y = py(v);
854
+ const h = base - y;
855
+ if (!v) {
856
+ return `<rect class="kit-plot__col kit-plot__col--empty" x="${x.toFixed(1)}"`
857
+ + ` y="${base - 1}" width="${bw.toFixed(1)}" height="1"></rect>`;
858
+ }
859
+ const r = Math.min(4, h / 2, bw / 2);
860
+ const d = `M${x.toFixed(1)},${base} V${(y + r).toFixed(1)}`
861
+ + ` Q${x.toFixed(1)},${y.toFixed(1)} ${(x + r).toFixed(1)},${y.toFixed(1)}`
862
+ + ` H${(x + bw - r).toFixed(1)}`
863
+ + ` Q${(x + bw).toFixed(1)},${y.toFixed(1)} ${(x + bw).toFixed(1)},${(y + r).toFixed(1)}`
864
+ + ` V${base} Z`;
865
+ return `<path class="kit-plot__col" d="${d}"></path>`;
866
+ }).join('');
867
+ return { body, xs, base, py, max };
868
+ }
869
+
870
+ // The plot itself: the series a geometry built, on the axis, with the hidden
871
+ // crosshair a caller's readout moves and the two end ticks. `tabindex="0"` is
872
+ // deliberate and paired with the readout — the chart is walkable by arrow
873
+ // key, so it has to be reachable by tab.
874
+ function plotSvgHtml(opts = {}) {
875
+ const { width, height, pad, base } = plotBox(opts);
876
+ const tick = (x, text, anchor) => (text
877
+ ? `<text class="kit-plot__tick" x="${x}" y="${height - 6}"`
878
+ + `${anchor ? ` text-anchor="${anchor}"` : ''}>${esc(text)}</text>`
879
+ : '');
880
+ return `<svg class="kit-plot" viewBox="0 0 ${width} ${height}" role="img" tabindex="0"`
881
+ + ` aria-label="${esc(opts.ariaLabel || '')}">`
882
+ + (opts.series || '')
883
+ + `<line class="kit-plot__axis" x1="${pad.l}" y1="${base}" x2="${width - pad.r}" y2="${base}"></line>`
884
+ + `<line class="kit-plot__hair" x1="0" y1="${pad.t}" x2="0" y2="${base}" style="visibility:hidden"></line>`
885
+ + (opts.label || '')
886
+ + tick(pad.l, opts.leftTick)
887
+ + tick(width - pad.r, opts.rightTick, 'end')
888
+ + '</svg>';
889
+ }
890
+
891
+ // The one value a plot labels — the endpoint on a line, the peak on columns.
892
+ // Only ever one: labelling every point is the thing the table twin is for.
893
+ function plotValueLabelHtml(x, y, text, anchor) {
894
+ return `<text class="kit-plot__end" x="${Number(x).toFixed(1)}" y="${Number(y).toFixed(1)}"`
895
+ + `${anchor ? ` text-anchor="${anchor}"` : ''}>${esc(text)}</text>`;
896
+ }
897
+
898
+ // Title + chart + optional foot — the frame all four sites drew by hand.
899
+ // `inner` is already-built chart HTML, so the frame never needs to know
900
+ // which shape it holds. That is what makes the fifth chart free.
901
+ function chartCardHtml(title, inner, foot, emptyMsg) {
902
+ const body = inner || `<div class="kit-chart__empty">${esc(emptyMsg || 'no data in window')}</div>`;
903
+ return '<div class="kit-chart">'
904
+ + (title ? `<div class="kit-chart__title">${esc(title)}</div>` : '')
905
+ + body
906
+ + (foot ? `<div class="kit-chart__foot">${esc(foot)}</div>` : '')
907
+ + '</div>';
908
+ }
909
+
610
910
  const api = {
611
911
  esc,
612
912
  // pager (low-level exports so pages can adopt incrementally)
@@ -615,6 +915,15 @@
615
915
  pageOffset, renderServerPage,
616
916
  // composed primitives
617
917
  makeLedger, makeFilterBar, makeTabs, makeSectionRail,
918
+ // charts (task 1003445) — the ONE place the hall draws a series.
919
+ // linePoints/cumulate are the maths on their own, for a chart that wants
920
+ // the geometry without this SVG (the profile's timeChart).
921
+ sparklineHtml, barSeriesHtml, chartCardHtml, linePoints, cumulate,
922
+ // plotted charts: the axis frame + the two series shapes that ride it
923
+ plotSvgHtml, plotValueLabelHtml, timeStepSeriesHtml, timeStepGeometry, columnSeriesHtml,
924
+ // plotBox is exported so a caller mapping pointer x back into viewBox
925
+ // space reads the plot's width instead of re-declaring the number.
926
+ plotBox,
618
927
  // states
619
928
  emptyStateHtml, skeletonRows, collapsibleCard,
620
929
  // ship-time task visual (task 1003109)
@@ -1073,30 +1073,12 @@
1073
1073
  `).join('');
1074
1074
  }
1075
1075
  const analyticsState = { builderId: null, isArchon: false, scope: 'self', self: null, eco: null };
1076
+ // The line drawing moved to OTBKit.sparklineHtml (task 1003445) — it was one
1077
+ // of four chart implementations in this surface. This wrapper keeps the
1078
+ // Curve's own default colour, which is NOT the kit's: these four charts each
1079
+ // spend a different accent, and --accent-deep is the first one's.
1076
1080
  function sparkline(values, opts = {}) {
1077
- const W = opts.width || 260;
1078
- const H = opts.height || 46;
1079
- const pad = 3;
1080
- const color = opts.color || 'var(--accent-deep)';
1081
- const nums = (values || []).map((v) => (Number.isFinite(Number(v)) ? Number(v) : 0));
1082
- if (nums.length === 0) {
1083
- return `<svg class="spark" viewBox="0 0 ${W} ${H}" preserveAspectRatio="none" role="img" aria-label="no data"></svg>`;
1084
- }
1085
- const min = Math.min(...nums);
1086
- const max = Math.max(...nums);
1087
- const span = max - min || 1;
1088
- const n = nums.length;
1089
- const x = (i) => (n === 1 ? W / 2 : pad + (i * (W - 2 * pad)) / (n - 1));
1090
- const y = (v) => H - pad - ((v - min) / span) * (H - 2 * pad);
1091
- const pts = nums.map((v, i) => `${x(i).toFixed(1)},${y(v).toFixed(1)}`);
1092
- const line = pts.join(' ');
1093
- const area = `${pad},${H - pad} ${line} ${(W - pad)},${H - pad}`;
1094
- const last = nums[n - 1];
1095
- return `<svg class="spark" viewBox="0 0 ${W} ${H}" preserveAspectRatio="none" role="img" aria-label="trend, latest ${last}">
1096
- <polyline class="spark__area" points="${area}" fill="${color}" />
1097
- <polyline class="spark__line" points="${line}" fill="none" stroke="${color}" />
1098
- ${n === 1 ? `<circle cx="${x(0).toFixed(1)}" cy="${y(last).toFixed(1)}" r="2.4" fill="${color}" />` : ''}
1099
- </svg>`;
1081
+ return window.OTBKit.sparklineHtml(values, { color: 'var(--accent-deep)', ...opts });
1100
1082
  }
1101
1083
  function statCard(label, value, sub) {
1102
1084
  return `<div class="curve__stat">
@@ -1107,11 +1089,7 @@
1107
1089
  }
1108
1090
  function chartCard(title, values, color, footer) {
1109
1091
  const has = Array.isArray(values) && values.length > 0;
1110
- return `<div class="curve__chart">
1111
- <div class="curve__chart-title">${escapeHtml(title)}</div>
1112
- ${has ? sparkline(values, { color }) : `<div class="curve__chart-empty">no data in window</div>`}
1113
- ${footer ? `<div class="curve__chart-foot">${escapeHtml(footer)}</div>` : ''}
1114
- </div>`;
1092
+ return window.OTBKit.chartCardHtml(title, has ? sparkline(values, { color }) : '', footer);
1115
1093
  }
1116
1094
  function renderCurveBody() {
1117
1095
  const headlineEl = $('#curve-headline');
@@ -1177,17 +1155,11 @@
1177
1155
  // Top kinds — horizontal bars.
1178
1156
  const kinds = (data.top_kinds || []).slice(0, 8);
1179
1157
  if (kinds.length) {
1180
- const maxN = Math.max(...kinds.map((k) => Number(k.n)));
1181
1158
  kindsEl.innerHTML =
1182
- `<div class="curve__chart-title">Kinds of work shipped</div>` +
1183
- kinds.map((k) => {
1184
- const w = maxN ? Math.round((Number(k.n) / maxN) * 100) : 0;
1185
- return `<div class="curve__kind">
1186
- <span class="curve__kind-label">${escapeHtml(k.kind)}</span>
1187
- <span class="curve__kind-bar"><span class="curve__kind-fill" style="width:${w}%"></span></span>
1188
- <span class="curve__kind-n">${k.n}</span>
1189
- </div>`;
1190
- }).join('');
1159
+ `<div class="kit-chart__title">Kinds of work shipped</div>` +
1160
+ window.OTBKit.barSeriesHtml(
1161
+ kinds.map((k) => ({ label: k.kind, value: k.n })),
1162
+ );
1191
1163
  } else {
1192
1164
  kindsEl.innerHTML = '';
1193
1165
  }
@@ -55,9 +55,11 @@
55
55
  const PACE_MAX_DAYS = 365;
56
56
  const RANGE_KEY = 'otb.profile.chartRange';
57
57
 
58
- const W = 320; // SVG viewBox; the card scales it
59
- const H = 132;
60
- const PAD = { l: 6, r: 8, t: 20, b: 24 }; // t: the end label's room; b: the axis band
58
+ // The plot's own viewBox width, read from the kit rather than re-declared
59
+ // (task 1003445): wireReadout maps a pointer's x back into viewBox space, so
60
+ // a second copy of this number silently mis-aims the crosshair the day the
61
+ // kit's plot changes size. Its height and padding are the kit's alone now.
62
+ const W = window.OTBKit.plotBox().width;
61
63
 
62
64
  const esc = (s) => window.OTB.escapeHtml(s);
63
65
  const $ = (sel) => window.OTB.$(sel);
@@ -107,9 +109,12 @@
107
109
  const d = midnight(t);
108
110
  byDay.set(d, (byDay.get(d) || 0) + Number(valueOf(r)) || 0);
109
111
  }
112
+ // The grouping is this function's; the running total is the kit's
113
+ // (task 1003445), so the hall has ONE accumulator rather than two that
114
+ // could disagree about what a hole in the series means.
110
115
  const days = [...byDay.keys()].sort((a, b) => a - b);
111
- let acc = 0;
112
- return days.map((d) => ({ day: d, value: (acc += byDay.get(d)) }));
116
+ const totals = window.OTBKit.cumulate(days.map((d) => byDay.get(d)));
117
+ return days.map((d, i) => ({ day: d, value: totals[i] }));
113
118
  }
114
119
 
115
120
  // Clip a cumulative series to [windowStart, ∞) for drawing. The line must
@@ -165,7 +170,7 @@
165
170
  function wireReadout(fig, xs, textAt) {
166
171
  const svg = fig.querySelector('svg');
167
172
  const read = fig.querySelector('.pchart__read');
168
- const hair = fig.querySelector('.pchart__hair');
173
+ const hair = fig.querySelector('.kit-plot__hair');
169
174
  if (!svg || !read || !xs.length) return;
170
175
  let idx = xs.length - 1;
171
176
  const show = (i) => {
@@ -226,39 +231,28 @@
226
231
  wire() {},
227
232
  };
228
233
  }
234
+ // task 1003445: the geometry and the plot are OTBKit's; the time semantics
235
+ // stay here, because the kit must not learn what a week or midnight is.
229
236
  const today = midnight(now);
230
237
  const x0 = windowStart != null ? windowStart : points[0].day;
231
238
  const x1 = Math.max(today, x0 + WEEK);
232
239
  const last = points[points.length - 1];
233
- const vMax = last.value || 1;
234
- const px = (t) => PAD.l + ((t - x0) / (x1 - x0)) * (W - PAD.l - PAD.r);
235
- const py = (v) => H - PAD.b - (v / vMax) * (H - PAD.t - PAD.b);
236
- const base = H - PAD.b;
237
- let d = `M${px(points[0].day).toFixed(1)},${py(points[0].value).toFixed(1)}`;
238
- points.forEach((p, i) => {
239
- if (i === 0) return;
240
- const prev = points[i - 1].value;
241
- d += ` L${px(p.day).toFixed(1)},${py(prev).toFixed(1)} L${px(p.day).toFixed(1)},${py(p.value).toFixed(1)}`;
242
- });
243
- d += ` L${px(x1).toFixed(1)},${py(last.value).toFixed(1)}`;
244
- const area = `${d} L${px(x1).toFixed(1)},${base} L${px(points[0].day).toFixed(1)},${base} Z`;
245
- const endX = px(x1);
246
- const endY = py(last.value);
247
- const xs = points.map((p) => px(p.day));
240
+ const series = window.OTBKit.timeStepSeriesHtml(
241
+ points.map((p) => ({ x: p.day, value: p.value })),
242
+ { x0, x1, yMax: last.value },
243
+ );
244
+ const { endX, endY } = series.geometry;
245
+ const xs = series.xs;
248
246
  const html = `<figure class="pchart" id="${esc(id)}">
249
247
  <figcaption class="pchart__title">${esc(title)}</figcaption>
250
248
  <p class="pchart__read" aria-live="polite"></p>
251
- <svg class="pchart__svg" viewBox="0 0 ${W} ${H}" role="img" tabindex="0"
252
- aria-label="${esc(title)}: ${esc(valueText(last.value))} as of today; arrow keys walk the days">
253
- <path class="pchart__area" d="${area}"></path>
254
- <path class="pchart__line" d="${d}"></path>
255
- <line class="pchart__axis" x1="${PAD.l}" y1="${base}" x2="${W - PAD.r}" y2="${base}"></line>
256
- <line class="pchart__hair" x1="0" y1="${PAD.t}" x2="0" y2="${base}" style="visibility:hidden"></line>
257
- <circle class="pchart__dot" cx="${endX.toFixed(1)}" cy="${endY.toFixed(1)}" r="4"></circle>
258
- <text class="pchart__end" x="${endX.toFixed(1)}" y="${(endY - 8).toFixed(1)}" text-anchor="end">${esc(valueText(last.value))}</text>
259
- <text class="pchart__tick" x="${PAD.l}" y="${H - 6}">${esc(fmtDay(x0))}</text>
260
- <text class="pchart__tick" x="${W - PAD.r}" y="${H - 6}" text-anchor="end">today</text>
261
- </svg>
249
+ ${window.OTBKit.plotSvgHtml({
250
+ series: series.body,
251
+ ariaLabel: `${title}: ${valueText(last.value)} as of today; arrow keys walk the days`,
252
+ label: window.OTBKit.plotValueLabelHtml(endX, endY - 8, valueText(last.value), 'end'),
253
+ leftTick: fmtDay(x0),
254
+ rightTick: 'today',
255
+ })}
262
256
  ${twinHtml(['Day', title], points.map((p) => [fmtDay(p.day), valueText(p.value)]))}
263
257
  <p class="pchart__foot">${esc(footer)}</p>
264
258
  </figure>`;
@@ -274,39 +268,27 @@
274
268
  // Weekly columns: at most 24px thick, a 4px rounded cap on a square base, a
275
269
  // 2px surface gap between neighbours, the tallest column labelled.
276
270
  function columnChart({ id, title, buckets, footer }) {
271
+ // task 1003445: the columns and the plot are OTBKit's. What stays here is
272
+ // which value gets labelled (the peak) and what the ticks say — the
273
+ // reading of the chart, not its drawing.
277
274
  const n = buckets.length;
278
- const max = Math.max(1, ...buckets.map((b) => b.n));
279
- const slot = (W - PAD.l - PAD.r) / n;
280
- const bw = Math.min(24, Math.max(2, slot - 2));
281
- const base = H - PAD.b;
282
- const py = (v) => base - (v / max) * (H - PAD.t - PAD.b);
283
- const xs = buckets.map((_, i) => PAD.l + i * slot + slot / 2);
275
+ const cols = window.OTBKit.columnSeriesHtml(buckets.map((b) => b.n));
284
276
  const peak = buckets.reduce((best, b, i) => (b.n > buckets[best].n ? i : best), 0);
285
- const cols = buckets.map((b, i) => {
286
- const x = xs[i] - bw / 2;
287
- const y = py(b.n);
288
- const h = base - y;
289
- if (!b.n) return `<rect class="pchart__col pchart__col--empty" x="${x.toFixed(1)}" y="${base - 1}" width="${bw.toFixed(1)}" height="1"></rect>`;
290
- const r = Math.min(4, h / 2, bw / 2);
291
- const d = `M${x.toFixed(1)},${base} V${(y + r).toFixed(1)} Q${x.toFixed(1)},${y.toFixed(1)} ${(x + r).toFixed(1)},${y.toFixed(1)} H${(x + bw - r).toFixed(1)} Q${(x + bw).toFixed(1)},${y.toFixed(1)} ${(x + bw).toFixed(1)},${(y + r).toFixed(1)} V${base} Z`;
292
- return `<path class="pchart__col" d="${d}"></path>`;
293
- }).join('');
277
+ const xs = cols.xs;
278
+ const total = buckets.reduce((s, b) => s + b.n, 0);
294
279
  const label = buckets[peak].n
295
- ? `<text class="pchart__end" x="${xs[peak].toFixed(1)}" y="${(py(buckets[peak].n) - 6).toFixed(1)}" text-anchor="middle">${buckets[peak].n}</text>`
280
+ ? window.OTBKit.plotValueLabelHtml(xs[peak], cols.py(buckets[peak].n) - 6, buckets[peak].n, 'middle')
296
281
  : '';
297
- const total = buckets.reduce((s, b) => s + b.n, 0);
298
282
  const html = `<figure class="pchart" id="${esc(id)}">
299
283
  <figcaption class="pchart__title">${esc(title)}</figcaption>
300
284
  <p class="pchart__read" aria-live="polite"></p>
301
- <svg class="pchart__svg" viewBox="0 0 ${W} ${H}" role="img" tabindex="0"
302
- aria-label="${esc(title)}: ${total} in ${n} weeks; arrow keys walk the weeks">
303
- ${cols}
304
- <line class="pchart__axis" x1="${PAD.l}" y1="${base}" x2="${W - PAD.r}" y2="${base}"></line>
305
- <line class="pchart__hair" x1="0" y1="${PAD.t}" x2="0" y2="${base}" style="visibility:hidden"></line>
306
- ${label}
307
- <text class="pchart__tick" x="${PAD.l}" y="${H - 6}">${esc(fmtDay(buckets[0].start))}</text>
308
- <text class="pchart__tick" x="${W - PAD.r}" y="${H - 6}" text-anchor="end">this week</text>
309
- </svg>
285
+ ${window.OTBKit.plotSvgHtml({
286
+ series: cols.body,
287
+ ariaLabel: `${title}: ${total} in ${n} weeks; arrow keys walk the weeks`,
288
+ label,
289
+ leftTick: fmtDay(buckets[0].start),
290
+ rightTick: 'this week',
291
+ })}
310
292
  ${twinHtml(['Week of', 'Works'], buckets.map((b) => [fmtDay(b.start), String(b.n)]))}
311
293
  <p class="pchart__foot">${esc(footer)}</p>
312
294
  </figure>`;
@@ -321,18 +303,29 @@
321
303
 
322
304
  // Horizontal bars for the craft mix. Each row is its own table row already —
323
305
  // label, bar, count — so it needs no twin; the count at the tip is the value.
306
+ // task 1003445: the bars are OTBKit.barSeriesHtml — this was the hall's
307
+ // FOURTH chart drawing site and its third copy of the same horizontal bar
308
+ // (the Curve's kinds and Watch's trend were the other two). The figure, the
309
+ // caption and the foot stay here: they are this page's composition, and the
310
+ // kit deliberately draws a series rather than owning a surface. The list
311
+ // semantics travel as an option, because the mix IS announced as a list.
324
312
  function mixChart({ id, title, mix, footer }) {
325
- const max = Math.max(1, ...mix.map((k) => k.n));
326
- const rows = mix.map((k) => `<div class="pmix__row" role="listitem">
327
- <span class="pmix__label">${esc(k.label)}</span>
328
- <span class="pmix__track" aria-hidden="true"><span class="pmix__fill" style="width:${Math.round((k.n / max) * 100)}%"></span></span>
329
- <span class="pmix__n">${fmtNum(k.n)}</span>
330
- </div>`).join('');
313
+ const bars = window.OTBKit.barSeriesHtml(
314
+ mix.map((k) => ({ label: k.label, value: k.n, valueLabel: fmtNum(k.n) })),
315
+ {
316
+ list: true,
317
+ ariaLabel: title,
318
+ labelWidth: 'minmax(72px, 110px)',
319
+ // The bar grows FROM the axis, so its left edge stays square.
320
+ radius: '0 4px 4px 0',
321
+ track: 'soft',
322
+ },
323
+ );
331
324
  return {
332
325
  id,
333
326
  html: `<figure class="pchart" id="${esc(id)}">
334
327
  <figcaption class="pchart__title">${esc(title)}</figcaption>
335
- <div class="pmix" role="list" aria-label="${esc(title)}">${rows || `<p class="pchart__foot">nothing in this window</p>`}</div>
328
+ ${bars || `<p class="pchart__foot">nothing in this window</p>`}
336
329
  <p class="pchart__foot">${esc(footer)}</p>
337
330
  </figure>`,
338
331
  wire() {},
@@ -134,17 +134,11 @@
134
134
  }
135
135
  .pchart__title { margin: 0; font-size: 13.5px; font-weight: 500; color: var(--ink); }
136
136
  .pchart__read { margin: 2px 0 var(--sp-hair); font-family: var(--label-face); font-size: var(--label-size); letter-spacing: var(--label-space); text-transform: var(--label-case); color: var(--ink-soft); min-height: 1.4em; }
137
- .pchart__svg { display: block; width: 100%; height: auto; overflow: visible; border-radius: var(--radius-sm); }
138
- .pchart__svg:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
139
- .pchart__line { fill: none; stroke: var(--accent); stroke-width: 2; stroke-linejoin: round; stroke-linecap: round; }
140
- .pchart__area { fill: color-mix(in srgb, var(--accent) 10%, transparent); stroke: none; }
141
- .pchart__axis, .pchart__hair { stroke: var(--rule-soft); stroke-width: 1; }
142
- .pchart__hair { stroke: var(--ink-faint); }
143
- .pchart__dot { fill: var(--accent); stroke: var(--bg-card); stroke-width: 2; }
144
- .pchart__col { fill: var(--accent); }
145
- .pchart__col--empty { fill: var(--rule-soft); }
146
- .pchart__end { font-family: var(--font-body); font-size: 11.5px; font-weight: 500; fill: var(--ink); }
147
- .pchart__tick { font-family: var(--label-face); font-size: 10px; letter-spacing: var(--label-space); text-transform: var(--label-case); fill: var(--ink-faint); }
137
+ /* The plot itself — the svg, the line, the columns, the axis, the crosshair,
138
+ the ticks — is OTBKit's `.kit-plot*` in style.css (task 1003445). It was
139
+ written twice in this file's own script, and the goal's next charts want it
140
+ again. The dataviz spec moved verbatim; what stays below is this page's
141
+ FIGURE: the caption, the live readout, the table twin and the foot. */
148
142
  .pchart__twin { margin-top: var(--sp-hair); }
149
143
  .pchart__twin > summary { display: inline-flex; align-items: center; min-height: 24px; padding: 0 4px; margin: 0 -4px; font-family: var(--label-face); font-size: var(--label-size); letter-spacing: var(--label-space); text-transform: var(--label-case); color: var(--ink-soft); cursor: pointer; border-radius: var(--radius-sm); }
150
144
  .pchart__twin > summary:hover { color: var(--ink); background: var(--glass-strong); }
@@ -153,13 +147,10 @@
153
147
  .pchart__foot { margin: var(--sp-hair) 0 0; font-size: 12px; color: var(--ink-faint); }
154
148
  .pchart__note { margin: 0; font-size: 14px; color: var(--ink-soft); }
155
149
  /* the craft mix: label, bar, count — a row is its own table row */
156
- .pmix { display: flex; flex-direction: column; gap: 6px; margin: var(--sp-hair) 0; }
157
- .pmix__row { display: grid; grid-template-columns: minmax(72px, 110px) minmax(0, 1fr) auto; align-items: center; gap: 10px; font-size: 12.5px; min-height: 24px; }
158
- .pmix__label { color: var(--ink-soft); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
159
- .pmix__track { display: block; height: 8px; background: var(--rule-soft); border-radius: 0 4px 4px 0; overflow: hidden; }
160
- .pmix__fill { display: block; height: 100%; background: var(--accent); border-radius: 0 4px 4px 0; }
161
- .pmix__row:hover .pmix__fill { opacity: .8; }
162
- .pmix__n { color: var(--ink-faint); font-variant-numeric: tabular-nums; }
150
+ /* The craft mix is OTBKit.barSeriesHtml (task 1003445) — the .pmix family was
151
+ the hall's third copy of one horizontal bar. Only the vertical air around it
152
+ is this page's; the row, the track and the 24px floor are the kit's. */
153
+ .pchart .kit-bars { margin: var(--sp-hair) 0; }
163
154
  @media (max-width: 720px) {
164
155
  .pcharts { grid-template-columns: minmax(0, 1fr); }
165
156
  }