@xemahq/ui-kernel 0.6.0 → 0.6.1

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 (130) hide show
  1. package/dist/lib/biome-host/define-web-biome.d.ts +3 -2
  2. package/dist/lib/biome-host/define-web-biome.d.ts.map +1 -1
  3. package/dist/lib/biome-host/define-web-biome.js +23 -1
  4. package/dist/lib/biome-host/define-web-biome.js.map +1 -1
  5. package/dist/lib/biome-host/frontend-biome.d.ts +4 -1
  6. package/dist/lib/biome-host/frontend-biome.d.ts.map +1 -1
  7. package/dist/lib/biome-host/host-bridge.d.ts +2 -0
  8. package/dist/lib/biome-host/host-bridge.d.ts.map +1 -1
  9. package/dist/lib/biome-host/host-bridge.js.map +1 -1
  10. package/dist/lib/biome-host/i18n-hooks.d.ts +4 -0
  11. package/dist/lib/biome-host/i18n-hooks.d.ts.map +1 -0
  12. package/dist/lib/biome-host/i18n-hooks.js +22 -0
  13. package/dist/lib/biome-host/i18n-hooks.js.map +1 -0
  14. package/dist/lib/biome-host/i18n-port.d.ts +15 -0
  15. package/dist/lib/biome-host/i18n-port.d.ts.map +1 -0
  16. package/dist/lib/biome-host/i18n-port.js +29 -0
  17. package/dist/lib/biome-host/i18n-port.js.map +1 -0
  18. package/dist/lib/biome-host/index.d.ts +2 -0
  19. package/dist/lib/biome-host/index.d.ts.map +1 -1
  20. package/dist/lib/biome-host/index.js +2 -0
  21. package/dist/lib/biome-host/index.js.map +1 -1
  22. package/dist/lib/biome-host/realtime-port.d.ts +1 -1
  23. package/dist/lib/biome-host/realtime-port.d.ts.map +1 -1
  24. package/dist/session-kit/approvals/approval-model.d.ts.map +1 -1
  25. package/dist/session-kit/approvals/approval-model.js.map +1 -1
  26. package/dist/ui/branding/app-branding-provider.d.ts +17 -0
  27. package/dist/ui/branding/app-branding-provider.d.ts.map +1 -0
  28. package/dist/ui/branding/app-branding-provider.js +81 -0
  29. package/dist/ui/branding/app-branding-provider.js.map +1 -0
  30. package/dist/ui/branding/sanitize.d.ts +4 -0
  31. package/dist/ui/branding/sanitize.d.ts.map +1 -0
  32. package/dist/ui/branding/sanitize.js +47 -0
  33. package/dist/ui/branding/sanitize.js.map +1 -0
  34. package/dist/ui/breakpoints.d.ts +2 -0
  35. package/dist/ui/breakpoints.d.ts.map +1 -0
  36. package/dist/ui/breakpoints.js +5 -0
  37. package/dist/ui/breakpoints.js.map +1 -0
  38. package/dist/ui/charts/bar-chart.d.ts +7 -0
  39. package/dist/ui/charts/bar-chart.d.ts.map +1 -0
  40. package/dist/ui/charts/bar-chart.js +47 -0
  41. package/dist/ui/charts/bar-chart.js.map +1 -0
  42. package/dist/ui/charts/chart-frame.d.ts +25 -0
  43. package/dist/ui/charts/chart-frame.d.ts.map +1 -0
  44. package/dist/ui/charts/chart-frame.js +23 -0
  45. package/dist/ui/charts/chart-frame.js.map +1 -0
  46. package/dist/ui/charts/chart-types.d.ts +19 -0
  47. package/dist/ui/charts/chart-types.d.ts.map +1 -0
  48. package/dist/ui/charts/chart-types.js +3 -0
  49. package/dist/ui/charts/chart-types.js.map +1 -0
  50. package/dist/ui/charts/chart-utils.d.ts +22 -0
  51. package/dist/ui/charts/chart-utils.d.ts.map +1 -0
  52. package/dist/ui/charts/chart-utils.js +97 -0
  53. package/dist/ui/charts/chart-utils.js.map +1 -0
  54. package/dist/ui/charts/donut-chart.d.ts +7 -0
  55. package/dist/ui/charts/donut-chart.d.ts.map +1 -0
  56. package/dist/ui/charts/donut-chart.js +55 -0
  57. package/dist/ui/charts/donut-chart.js.map +1 -0
  58. package/dist/ui/charts/index.d.ts +8 -0
  59. package/dist/ui/charts/index.d.ts.map +1 -0
  60. package/dist/ui/charts/index.js +21 -0
  61. package/dist/ui/charts/index.js.map +1 -0
  62. package/dist/ui/charts/line-chart.d.ts +7 -0
  63. package/dist/ui/charts/line-chart.d.ts.map +1 -0
  64. package/dist/ui/charts/line-chart.js +70 -0
  65. package/dist/ui/charts/line-chart.js.map +1 -0
  66. package/dist/ui/charts/sparkline.d.ts +13 -0
  67. package/dist/ui/charts/sparkline.d.ts.map +1 -0
  68. package/dist/ui/charts/sparkline.js +39 -0
  69. package/dist/ui/charts/sparkline.js.map +1 -0
  70. package/dist/ui/hooks/use-is-mobile.d.ts +2 -0
  71. package/dist/ui/hooks/use-is-mobile.d.ts.map +1 -0
  72. package/dist/ui/hooks/use-is-mobile.js +19 -0
  73. package/dist/ui/hooks/use-is-mobile.js.map +1 -0
  74. package/dist/ui/index.d.ts +6 -0
  75. package/dist/ui/index.d.ts.map +1 -1
  76. package/dist/ui/index.js +14 -1
  77. package/dist/ui/index.js.map +1 -1
  78. package/dist/ui/pdf/pdf-preview.d.ts +8 -0
  79. package/dist/ui/pdf/pdf-preview.d.ts.map +1 -0
  80. package/dist/ui/pdf/pdf-preview.js +34 -0
  81. package/dist/ui/pdf/pdf-preview.js.map +1 -0
  82. package/dist/ui/primitives/preview-device-frame.d.ts +9 -0
  83. package/dist/ui/primitives/preview-device-frame.d.ts.map +1 -0
  84. package/dist/ui/primitives/preview-device-frame.js +90 -0
  85. package/dist/ui/primitives/preview-device-frame.js.map +1 -0
  86. package/package.json +8 -7
  87. package/src/lib/biome-host/define-web-biome.test.js +93 -0
  88. package/src/lib/biome-host/define-web-biome.ts +71 -4
  89. package/src/lib/biome-host/frontend-biome.ts +45 -4
  90. package/src/lib/biome-host/host-bridge.ts +13 -0
  91. package/src/lib/biome-host/i18n-hooks.ts +50 -0
  92. package/src/lib/biome-host/i18n-port.test.ts +50 -0
  93. package/src/lib/biome-host/i18n-port.ts +96 -0
  94. package/src/lib/biome-host/index.ts +2 -0
  95. package/src/lib/biome-host/realtime-port.ts +9 -3
  96. package/src/lib/system-bus/enums.ts +1 -1
  97. package/src/lib/system-bus/index.ts +1 -1
  98. package/src/lib/system-bus/intents.ts +1 -1
  99. package/src/session-kit/approvals/ApprovalCard.tsx +1 -1
  100. package/src/session-kit/approvals/ApprovalsCenter.tsx +2 -2
  101. package/src/session-kit/approvals/CapabilityApprovalStickyBar.tsx +1 -1
  102. package/src/session-kit/approvals/Obligation.tsx +1 -1
  103. package/src/session-kit/approvals/ScopedBody.tsx +1 -1
  104. package/src/session-kit/approvals/approval-model.ts +7 -8
  105. package/src/session-kit/approvals/index.ts +1 -1
  106. package/src/session-kit/approvals/obligation-display.ts +1 -1
  107. package/src/session-kit/approvals/risk-accent.ts +1 -1
  108. package/src/session-kit/combobox/Combobox.tsx +1 -1
  109. package/src/session-kit/display/MessageTurn.tsx +1 -1
  110. package/src/session-kit/display/TokenMeter.tsx +1 -1
  111. package/src/session-kit/index.ts +3 -3
  112. package/src/session-kit/lib/enums.ts +1 -1
  113. package/src/session-kit/lib/status-dot.ts +1 -1
  114. package/src/session-kit/primitives/RiskPill.tsx +1 -1
  115. package/src/ui/branding/app-branding-provider.tsx +146 -0
  116. package/src/ui/branding/sanitize.ts +77 -0
  117. package/src/ui/breakpoints.ts +14 -0
  118. package/src/ui/charts/bar-chart.tsx +135 -0
  119. package/src/ui/charts/chart-frame.tsx +112 -0
  120. package/src/ui/charts/chart-types.ts +52 -0
  121. package/src/ui/charts/chart-utils.ts +158 -0
  122. package/src/ui/charts/charts.test.tsx +204 -0
  123. package/src/ui/charts/donut-chart.tsx +111 -0
  124. package/src/ui/charts/index.ts +27 -0
  125. package/src/ui/charts/line-chart.tsx +180 -0
  126. package/src/ui/charts/sparkline.tsx +101 -0
  127. package/src/ui/hooks/use-is-mobile.ts +28 -0
  128. package/src/ui/index.ts +22 -0
  129. package/src/ui/pdf/pdf-preview.tsx +122 -0
  130. package/src/ui/primitives/preview-device-frame.tsx +205 -0
@@ -0,0 +1,111 @@
1
+ // ═══════════════════════════════════════════════════════════════════════════
2
+ // DonutChart — proportional ring, pure SVG (Wave 9.2)
3
+ //
4
+ // Slices are built from finite, non-negative values (a slice needs a magnitude).
5
+ // If no finite positive value remains, an explicit empty state renders instead
6
+ // of a broken ring.
7
+ // ═══════════════════════════════════════════════════════════════════════════
8
+ import type { ReactNode } from 'react';
9
+
10
+ import { ChartEmpty, ChartFrame, type ChartLegendItem } from './chart-frame';
11
+ import type { ChartBaseProps, ChartDatum } from './chart-types';
12
+ import { buildAriaSummary, chartColorAt, defaultFormatValue } from './chart-utils';
13
+
14
+ const SIZE = 200;
15
+ const CX = SIZE / 2;
16
+ const CY = SIZE / 2;
17
+ const R_OUTER = 90;
18
+ const R_INNER = 56;
19
+ const FULL = Math.PI * 2;
20
+
21
+ export interface DonutChartProps extends ChartBaseProps {
22
+ /** The slices — one per labelled datum. Negative/non-finite values are skipped. */
23
+ readonly data: readonly ChartDatum[];
24
+ }
25
+
26
+ /** Point on a circle at `angle` (radians, 0 = 12 o'clock, clockwise). */
27
+ function polar(radius: number, angle: number): [number, number] {
28
+ return [CX + radius * Math.sin(angle), CY - radius * Math.cos(angle)];
29
+ }
30
+
31
+ /** Annulus-segment path from `start` to `end` (radians). */
32
+ function slicePath(start: number, end: number): string {
33
+ const [ox1, oy1] = polar(R_OUTER, start);
34
+ const [ox2, oy2] = polar(R_OUTER, end);
35
+ const [ix2, iy2] = polar(R_INNER, end);
36
+ const [ix1, iy1] = polar(R_INNER, start);
37
+ const largeArc = end - start > Math.PI ? 1 : 0;
38
+ return [
39
+ `M ${ox1} ${oy1}`,
40
+ `A ${R_OUTER} ${R_OUTER} 0 ${largeArc} 1 ${ox2} ${oy2}`,
41
+ `L ${ix2} ${iy2}`,
42
+ `A ${R_INNER} ${R_INNER} 0 ${largeArc} 0 ${ix1} ${iy1}`,
43
+ 'Z',
44
+ ].join(' ');
45
+ }
46
+
47
+ export function DonutChart({
48
+ data,
49
+ title,
50
+ ariaLabel,
51
+ formatValue,
52
+ className,
53
+ emptyLabel = 'No data',
54
+ }: DonutChartProps): ReactNode {
55
+ const format = formatValue ?? defaultFormatValue;
56
+ const slices = data.filter((d) => Number.isFinite(d.value) && d.value > 0);
57
+ const total = slices.reduce((sum, d) => sum + d.value, 0);
58
+
59
+ if (slices.length === 0 || total <= 0) {
60
+ return <ChartEmpty title={title} label={emptyLabel} className={className} />;
61
+ }
62
+
63
+ const legend: ChartLegendItem[] = slices.map((d, i) => ({
64
+ label: d.label,
65
+ value: `${format(d.value)} (${Math.round((d.value / total) * 100)}%)`,
66
+ color: chartColorAt(i, d.color),
67
+ }));
68
+
69
+ const summaryPoints = slices.map((d) => ({ label: d.label, value: d.value }));
70
+ const summary = ariaLabel ?? buildAriaSummary(title, summaryPoints, format, 'slice');
71
+
72
+ let cursor = 0;
73
+ const arcs = slices.map((d, i) => {
74
+ const start = cursor;
75
+ // A lone full-circle slice can't be drawn as a single arc (start===end):
76
+ // nudge the end just shy of a full turn so the ring still closes visually.
77
+ const sweep = slices.length === 1 ? FULL - 1e-3 : (d.value / total) * FULL;
78
+ const end = start + sweep;
79
+ cursor = end;
80
+ return { path: slicePath(start, end), color: chartColorAt(i, d.color), key: `${d.label}-${i}` };
81
+ });
82
+
83
+ const plot = (
84
+ <svg
85
+ role="img"
86
+ aria-label={summary}
87
+ width="100%"
88
+ viewBox={`0 0 ${SIZE} ${SIZE}`}
89
+ preserveAspectRatio="xMidYMid meet"
90
+ className="mx-auto block h-auto w-full max-w-[240px] text-ink-4"
91
+ >
92
+ {arcs.map((a) => (
93
+ <path key={a.key} d={a.path} fill={a.color} stroke="var(--card, transparent)" strokeWidth={1} />
94
+ ))}
95
+ <text
96
+ x={CX}
97
+ y={CY}
98
+ textAnchor="middle"
99
+ dominantBaseline="middle"
100
+ fontSize={20}
101
+ fontWeight={600}
102
+ fill="currentColor"
103
+ className="text-ink"
104
+ >
105
+ {format(total)}
106
+ </text>
107
+ </svg>
108
+ );
109
+
110
+ return <ChartFrame plot={plot} legend={legend} className={className} />;
111
+ }
@@ -0,0 +1,27 @@
1
+ // ═══════════════════════════════════════════════════════════════════════════
2
+ // Chart primitives (Wave 9.2) — pure-SVG, responsive-by-construction dashboard
3
+ // charts. The kernel ships NO chart library: a strict CSP blocks external
4
+ // scripts/fonts/images, so every chart is inline SVG built from React.
5
+ // ═══════════════════════════════════════════════════════════════════════════
6
+ export type {
7
+ ChartDatum,
8
+ ChartLineSeries,
9
+ ChartValueFormatter,
10
+ ChartBaseProps,
11
+ } from './chart-types';
12
+ export {
13
+ CHART_PALETTE,
14
+ chartColorAt,
15
+ defaultFormatValue,
16
+ finitePoints,
17
+ niceScale,
18
+ buildAriaSummary,
19
+ type NiceScale,
20
+ type NiceScaleOptions,
21
+ type FiniteDatum,
22
+ } from './chart-utils';
23
+ export { ChartLegend, type ChartLegendItem } from './chart-frame';
24
+ export { BarChart, type BarChartProps } from './bar-chart';
25
+ export { LineChart, type LineChartProps } from './line-chart';
26
+ export { Sparkline, type SparklineProps } from './sparkline';
27
+ export { DonutChart, type DonutChartProps } from './donut-chart';
@@ -0,0 +1,180 @@
1
+ // ═══════════════════════════════════════════════════════════════════════════
2
+ // LineChart — one or more line series over shared x-labels, pure SVG (Wave 9.2)
3
+ //
4
+ // Non-finite points break the line (a new segment starts after the gap) rather
5
+ // than emitting a NaN coordinate.
6
+ // ═══════════════════════════════════════════════════════════════════════════
7
+ import type { ReactNode } from 'react';
8
+
9
+ import { ChartEmpty, ChartFrame, type ChartLegendItem } from './chart-frame';
10
+ import type { ChartBaseProps, ChartLineSeries } from './chart-types';
11
+ import {
12
+ buildAriaSummary,
13
+ chartColorAt,
14
+ defaultFormatValue,
15
+ niceScale,
16
+ } from './chart-utils';
17
+
18
+ const VIEW_W = 640;
19
+ const VIEW_H = 360;
20
+ const PAD = { top: 12, right: 12, bottom: 44, left: 48 } as const;
21
+ const PLOT_L = PAD.left;
22
+ const PLOT_R = VIEW_W - PAD.right;
23
+ const PLOT_T = PAD.top;
24
+ const PLOT_B = VIEW_H - PAD.bottom;
25
+ const PLOT_W = PLOT_R - PLOT_L;
26
+ const PLOT_H = PLOT_B - PLOT_T;
27
+
28
+ export interface LineChartProps extends ChartBaseProps {
29
+ /** One or more named line series. */
30
+ readonly series: readonly ChartLineSeries[];
31
+ }
32
+
33
+ /** Break a series' points into finite segments (a gap ends the current run). */
34
+ function toSegments(
35
+ points: readonly { value: number }[],
36
+ xToPx: (i: number) => number,
37
+ yToPx: (v: number) => number,
38
+ ): string[] {
39
+ const segments: string[] = [];
40
+ let current: string[] = [];
41
+ points.forEach((p, i) => {
42
+ if (Number.isFinite(p.value)) {
43
+ current.push(`${xToPx(i)},${yToPx(p.value)}`);
44
+ } else if (current.length > 0) {
45
+ segments.push(current.join(' '));
46
+ current = [];
47
+ }
48
+ });
49
+ if (current.length > 0) segments.push(current.join(' '));
50
+ return segments;
51
+ }
52
+
53
+ export function LineChart({
54
+ series,
55
+ title,
56
+ ariaLabel,
57
+ formatValue,
58
+ className,
59
+ emptyLabel = 'No data',
60
+ }: LineChartProps): ReactNode {
61
+ const format = formatValue ?? defaultFormatValue;
62
+
63
+ // Every finite value across all series drives the y-domain and the summary.
64
+ const finiteValues: number[] = [];
65
+ let maxLen = 0;
66
+ for (const s of series) {
67
+ maxLen = Math.max(maxLen, s.points.length);
68
+ for (const p of s.points) if (Number.isFinite(p.value)) finiteValues.push(p.value);
69
+ }
70
+
71
+ if (finiteValues.length === 0) {
72
+ return <ChartEmpty title={title} label={emptyLabel} className={className} />;
73
+ }
74
+
75
+ const scale = niceScale(Math.min(...finiteValues), Math.max(...finiteValues), {
76
+ pinZero: false,
77
+ });
78
+ const span = scale.max - scale.min;
79
+ const yToPx = (v: number): number => PLOT_B - ((v - scale.min) / span) * PLOT_H;
80
+ const xToPx = (i: number): number =>
81
+ maxLen <= 1 ? PLOT_L + PLOT_W / 2 : PLOT_L + (i / (maxLen - 1)) * PLOT_W;
82
+
83
+ // x labels from the longest series (deterministic pick: first of that length).
84
+ const xLabelSource = series.find((s) => s.points.length === maxLen)?.points ?? [];
85
+
86
+ const legend: ChartLegendItem[] = series.map((s, i) => ({
87
+ label: s.name,
88
+ color: chartColorAt(i, s.color),
89
+ }));
90
+
91
+ // Summarize the first series' points (or the union count) for the aria label.
92
+ const summaryPoints = series.flatMap((s) =>
93
+ s.points
94
+ .filter((p) => Number.isFinite(p.value))
95
+ .map((p) => ({ label: `${s.name} ${p.label}`, value: p.value })),
96
+ );
97
+ const summary = ariaLabel ?? buildAriaSummary(title, summaryPoints, format, 'point');
98
+
99
+ const plot = (
100
+ <svg
101
+ role="img"
102
+ aria-label={summary}
103
+ width="100%"
104
+ viewBox={`0 0 ${VIEW_W} ${VIEW_H}`}
105
+ preserveAspectRatio="xMidYMid meet"
106
+ className="block h-auto w-full overflow-visible text-ink-4"
107
+ >
108
+ {/* y gridlines + tick labels */}
109
+ {scale.ticks.map((t) => {
110
+ const y = yToPx(t);
111
+ return (
112
+ <g key={`y-${t}`}>
113
+ <line
114
+ x1={PLOT_L}
115
+ x2={PLOT_R}
116
+ y1={y}
117
+ y2={y}
118
+ stroke="currentColor"
119
+ strokeOpacity={0.12}
120
+ />
121
+ <text
122
+ x={PLOT_L - 8}
123
+ y={y}
124
+ textAnchor="end"
125
+ dominantBaseline="middle"
126
+ fontSize={12}
127
+ fill="currentColor"
128
+ >
129
+ {format(t)}
130
+ </text>
131
+ </g>
132
+ );
133
+ })}
134
+
135
+ {/* x labels */}
136
+ {xLabelSource.map((p, i) => (
137
+ <text
138
+ key={`x-${p.label}-${i}`}
139
+ x={xToPx(i)}
140
+ y={PLOT_B + 16}
141
+ textAnchor="middle"
142
+ fontSize={12}
143
+ fill="currentColor"
144
+ >
145
+ {p.label}
146
+ </text>
147
+ ))}
148
+
149
+ {/* lines */}
150
+ {series.map((s, si) => {
151
+ const color = chartColorAt(si, s.color);
152
+ const segments = toSegments(s.points, xToPx, yToPx);
153
+ return (
154
+ <g key={`line-${s.name}-${si}`}>
155
+ {segments.map((pts, gi) => (
156
+ <polyline
157
+ key={`seg-${gi}`}
158
+ points={pts}
159
+ fill="none"
160
+ stroke={color}
161
+ strokeWidth={2}
162
+ strokeLinejoin="round"
163
+ strokeLinecap="round"
164
+ />
165
+ ))}
166
+ {s.points.map((p, i) =>
167
+ Number.isFinite(p.value) ? (
168
+ <circle key={`pt-${i}`} cx={xToPx(i)} cy={yToPx(p.value)} r={2.5} fill={color}>
169
+ <title>{`${s.name} — ${p.label}: ${format(p.value)}`}</title>
170
+ </circle>
171
+ ) : null,
172
+ )}
173
+ </g>
174
+ );
175
+ })}
176
+ </svg>
177
+ );
178
+
179
+ return <ChartFrame plot={plot} legend={legend} className={className} />;
180
+ }
@@ -0,0 +1,101 @@
1
+ // ═══════════════════════════════════════════════════════════════════════════
2
+ // Sparkline — tiny inline trend line, pure SVG, no axes/legend (Wave 9.2)
3
+ // ═══════════════════════════════════════════════════════════════════════════
4
+ import type { ReactNode } from 'react';
5
+
6
+ import { cn } from '../cn';
7
+
8
+ import type { ChartValueFormatter } from './chart-types';
9
+ import { chartColorAt, defaultFormatValue, niceScale } from './chart-utils';
10
+
11
+ const VIEW_W = 100;
12
+ const VIEW_H = 28;
13
+ const PAD_Y = 3;
14
+
15
+ export interface SparklineProps {
16
+ /** The ordered values. Non-finite entries break the line rather than plotting NaN. */
17
+ readonly values: readonly number[];
18
+ /** Title — the spine of the `aria-label` summary. */
19
+ readonly title: string;
20
+ readonly ariaLabel?: string;
21
+ readonly formatValue?: ChartValueFormatter;
22
+ /** Line color (CSS color / `currentColor` / `var(--…)`). Defaults to the brand palette slot 0. */
23
+ readonly color?: string;
24
+ readonly className?: string;
25
+ readonly emptyLabel?: string;
26
+ }
27
+
28
+ export function Sparkline({
29
+ values,
30
+ title,
31
+ ariaLabel,
32
+ formatValue,
33
+ color,
34
+ className,
35
+ emptyLabel = 'No data',
36
+ }: SparklineProps): ReactNode {
37
+ const format = formatValue ?? defaultFormatValue;
38
+ const finite = values.filter((v) => Number.isFinite(v));
39
+
40
+ if (finite.length === 0) {
41
+ return (
42
+ <span
43
+ role="img"
44
+ aria-label={`${title}: ${emptyLabel}`}
45
+ className={cn('inline-block text-body-2 text-ink-4', className)}
46
+ >
47
+ {emptyLabel}
48
+ </span>
49
+ );
50
+ }
51
+
52
+ const scale = niceScale(Math.min(...finite), Math.max(...finite), { pinZero: false });
53
+ const span = scale.max - scale.min;
54
+ const yToPx = (v: number): number =>
55
+ VIEW_H - PAD_Y - ((v - scale.min) / span) * (VIEW_H - 2 * PAD_Y);
56
+ const xToPx = (i: number): number =>
57
+ values.length <= 1 ? VIEW_W / 2 : (i / (values.length - 1)) * VIEW_W;
58
+
59
+ // Break into finite segments so a gap never yields a NaN coordinate.
60
+ const segments: string[] = [];
61
+ let current: string[] = [];
62
+ values.forEach((v, i) => {
63
+ if (Number.isFinite(v)) current.push(`${xToPx(i)},${yToPx(v)}`);
64
+ else if (current.length > 0) {
65
+ segments.push(current.join(' '));
66
+ current = [];
67
+ }
68
+ });
69
+ if (current.length > 0) segments.push(current.join(' '));
70
+
71
+ const stroke = chartColorAt(0, color);
72
+ const first = finite[0];
73
+ const last = finite[finite.length - 1];
74
+ const summary =
75
+ ariaLabel ??
76
+ `${title}: trend of ${finite.length} points, from ${format(first)} to ${format(last)}.`;
77
+
78
+ return (
79
+ <svg
80
+ role="img"
81
+ aria-label={summary}
82
+ width="100%"
83
+ viewBox={`0 0 ${VIEW_W} ${VIEW_H}`}
84
+ preserveAspectRatio="none"
85
+ className={cn('block h-auto w-full overflow-visible', className)}
86
+ >
87
+ {segments.map((pts, i) => (
88
+ <polyline
89
+ key={`seg-${i}`}
90
+ points={pts}
91
+ fill="none"
92
+ stroke={stroke}
93
+ strokeWidth={1.5}
94
+ strokeLinejoin="round"
95
+ strokeLinecap="round"
96
+ vectorEffect="non-scaling-stroke"
97
+ />
98
+ ))}
99
+ </svg>
100
+ );
101
+ }
@@ -0,0 +1,28 @@
1
+ import { useEffect, useState } from 'react';
2
+
3
+ import { MOBILE_BREAKPOINT } from '../breakpoints';
4
+
5
+ /**
6
+ * `true` when the viewport is narrower than the shared {@link MOBILE_BREAKPOINT}
7
+ * (Tailwind's `md`). Server-driven renderers use this to collapse tables to
8
+ * cards, reflow grids and go single-column WITHOUT the author declaring a
9
+ * breakpoint — responsiveness is a property of the primitive, not a checklist.
10
+ *
11
+ * Reads `window.innerWidth`, so it returns `false` until mounted on the client
12
+ * (SSR-safe: no layout thrash, no hydration mismatch beyond the first paint).
13
+ */
14
+ export function useIsMobile(): boolean {
15
+ const [isMobile, setIsMobile] = useState<boolean | undefined>(undefined);
16
+
17
+ useEffect(() => {
18
+ const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
19
+ const onChange = (): void => {
20
+ setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
21
+ };
22
+ mql.addEventListener('change', onChange);
23
+ setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
24
+ return () => mql.removeEventListener('change', onChange);
25
+ }, []);
26
+
27
+ return !!isMobile;
28
+ }
package/src/ui/index.ts CHANGED
@@ -15,8 +15,12 @@ export { cn } from './cn';
15
15
  // ── design tokens (semantic styling SSOT) ──
16
16
  export * from './design-tokens';
17
17
 
18
+ // ── responsive breakpoint token (JS ↔ Tailwind `md`, single source) ──
19
+ export { MOBILE_BREAKPOINT } from './breakpoints';
20
+
18
21
  // ── hooks ──
19
22
  export { useDebouncedValue } from './hooks/use-debounced-value';
23
+ export { useIsMobile } from './hooks/use-is-mobile';
20
24
 
21
25
  // ── shadcn primitives ──
22
26
  export * from './primitives/button';
@@ -46,6 +50,22 @@ export * from './primitives/tag-multi-select';
46
50
  export * from './primitives/sheet';
47
51
  export * from './primitives/form-stepper';
48
52
  export * from './primitives/async-combobox';
53
+ export {
54
+ PreviewDeviceFrame,
55
+ type PreviewDevice,
56
+ } from './primitives/preview-device-frame';
57
+
58
+ // ── chart primitives (Wave 9.2 — pure SVG, no external chart lib) ──
59
+ export * from './charts';
60
+
61
+ // ── runtime branding applier (Wave 8.4) ──
62
+ export {
63
+ AppBrandingProvider,
64
+ useAppBranding,
65
+ APP_PRIMARY_COLOR_VAR,
66
+ type AppBrandingProviderProps,
67
+ type AppBrandingContextValue,
68
+ } from './branding/app-branding-provider';
49
69
 
50
70
  // ── Xema chrome ──
51
71
  export { default as ErrorCard, type ErrorMessageFormatter } from './chrome/ErrorCard';
@@ -66,3 +86,5 @@ export { StateCard } from './chrome/StateCard';
66
86
  export { default as PageHeader, PageToolbar } from './chrome/PageHeader';
67
87
  export { default as LoadingState, type LoadingStateVariant } from './chrome/LoadingState';
68
88
  export { default as AsyncBoundary } from './chrome/AsyncBoundary';
89
+
90
+ export { PdfPreview, type PdfPreviewProps } from './pdf/pdf-preview';
@@ -0,0 +1,122 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * PdfPreview — a reusable, canvas-rendered PDF viewer with in-app zoom, for any
5
+ * web biome or host surface (exported from `@xemahq/ui-kernel/ui`).
6
+ *
7
+ * WHY canvas, not an `<iframe>`: the bytes are frequently UNTRUSTED (user
8
+ * uploads, third-party artifacts). pdf.js renders each page to a `<canvas>` with
9
+ * NO iframe and NO script execution — strictly safer than embedding the file in
10
+ * a browser viewer — while giving zoom controls a fully-sandboxed iframe cannot.
11
+ * Text/annotation layers are disabled: this is a PREVIEW, not an editor.
12
+ *
13
+ * The component only renders from a resolved `url` (durable publicUrl, dataUrl,
14
+ * or `blob:`); the caller owns fetching/authing/revoking that URL.
15
+ */
16
+ import { RotateCcw, ZoomIn, ZoomOut } from 'lucide-react';
17
+ import { useCallback, useMemo, useState, type JSX } from 'react';
18
+ import { Document, Page, pdfjs } from 'react-pdf';
19
+
20
+ import { cn } from '../cn';
21
+
22
+ // The pdf.js worker is loaded same-origin (CSP-safe, and CJS-build-safe — no
23
+ // `import.meta`). The HOST app must serve the version-matched worker at this
24
+ // path — host-web copies `pdfjs-dist/build/pdf.worker.min.mjs` into `public/`
25
+ // via its `copy-pdf-worker` script (run on predev/prebuild). Overridable before
26
+ // first render for hosts that serve it elsewhere.
27
+ if (!pdfjs.GlobalWorkerOptions.workerSrc) {
28
+ pdfjs.GlobalWorkerOptions.workerSrc = '/pdf.worker.min.mjs';
29
+ }
30
+
31
+ const MIN_SCALE = 0.5;
32
+ const MAX_SCALE = 3;
33
+ const STEP = 0.25;
34
+ const DEFAULT_SCALE = 1;
35
+
36
+ const clampScale = (s: number): number =>
37
+ Math.min(MAX_SCALE, Math.max(MIN_SCALE, Math.round(s * 100) / 100));
38
+
39
+ export interface PdfPreviewProps {
40
+ /** A resolved, fetchable URL: durable `publicUrl`, `dataUrl`, or `blob:`. */
41
+ readonly url: string;
42
+ /** Human label shown in the toolbar / used as the document title. */
43
+ readonly title?: string;
44
+ readonly className?: string;
45
+ }
46
+
47
+ export function PdfPreview({ url, title, className }: PdfPreviewProps): JSX.Element {
48
+ const [numPages, setNumPages] = useState(0);
49
+ const [scale, setScale] = useState(DEFAULT_SCALE);
50
+ const [error, setError] = useState<string | null>(null);
51
+
52
+ // Stable `file` identity so react-pdf doesn't re-fetch on every render.
53
+ const file = useMemo(() => ({ url }), [url]);
54
+
55
+ const zoomOut = useCallback(() => setScale((s) => clampScale(s - STEP)), []);
56
+ const zoomIn = useCallback(() => setScale((s) => clampScale(s + STEP)), []);
57
+ const reset = useCallback(() => setScale(DEFAULT_SCALE), []);
58
+
59
+ const btn =
60
+ 'inline-flex h-7 w-7 items-center justify-center rounded text-neutral-600 ' +
61
+ 'hover:bg-neutral-200/70 disabled:pointer-events-none disabled:opacity-40 ' +
62
+ 'focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-1';
63
+
64
+ return (
65
+ <div
66
+ className={cn(
67
+ 'flex h-[520px] flex-col overflow-hidden rounded border border-neutral-200 bg-white',
68
+ className,
69
+ )}
70
+ >
71
+ <div className="flex items-center gap-1 border-b border-neutral-200 bg-neutral-50 px-2 py-1">
72
+ <button type="button" onClick={zoomOut} disabled={scale <= MIN_SCALE} aria-label="Zoom out" className={btn}>
73
+ <ZoomOut className="h-4 w-4" />
74
+ </button>
75
+ <span className="w-12 text-center text-xs tabular-nums text-neutral-500">
76
+ {Math.round(scale * 100)}%
77
+ </span>
78
+ <button type="button" onClick={zoomIn} disabled={scale >= MAX_SCALE} aria-label="Zoom in" className={btn}>
79
+ <ZoomIn className="h-4 w-4" />
80
+ </button>
81
+ <button type="button" onClick={reset} disabled={scale === DEFAULT_SCALE} aria-label="Reset zoom" className={btn}>
82
+ <RotateCcw className="h-4 w-4" />
83
+ </button>
84
+ {title ? (
85
+ <span className="ml-2 min-w-0 flex-1 truncate text-xs text-neutral-400" title={title}>
86
+ {title}
87
+ </span>
88
+ ) : null}
89
+ </div>
90
+
91
+ <div className="flex-1 overflow-auto bg-neutral-100 p-3">
92
+ {error ? (
93
+ <div className="p-4 text-xs text-neutral-500">
94
+ Couldn&apos;t render this PDF ({error}). Use the download affordance.
95
+ </div>
96
+ ) : (
97
+ <Document
98
+ file={file}
99
+ onLoadSuccess={({ numPages: n }) => {
100
+ setNumPages(n);
101
+ setError(null);
102
+ }}
103
+ onLoadError={(e) => setError(e.message)}
104
+ loading={<div className="p-4 text-xs text-neutral-500">Loading PDF…</div>}
105
+ className="flex flex-col items-center gap-3"
106
+ >
107
+ {Array.from({ length: numPages }, (_, i) => (
108
+ <Page
109
+ key={i}
110
+ pageNumber={i + 1}
111
+ scale={scale}
112
+ renderTextLayer={false}
113
+ renderAnnotationLayer={false}
114
+ className="shadow-sm"
115
+ />
116
+ ))}
117
+ </Document>
118
+ )}
119
+ </div>
120
+ </div>
121
+ );
122
+ }