dsh-token-chart 0.0.0-stage → 0.1.0

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 (59) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +200 -2
  3. package/cordis.patch.yml +7 -0
  4. package/icon.svg +7 -0
  5. package/lib/buckets.js +115 -0
  6. package/lib/chunk/echarts.entry.js +25 -0
  7. package/lib/client.echarts.js +58 -0
  8. package/lib/client.js +1743 -0
  9. package/lib/constants.js +22 -0
  10. package/lib/index.js +640 -0
  11. package/lib/paths.js +24 -0
  12. package/lib/prefs.js +54 -0
  13. package/lib/store.js +273 -0
  14. package/lib/token-chart.css +568 -0
  15. package/lib/types/buckets.d.ts +24 -0
  16. package/lib/types/chunk/echarts.entry.d.ts +11 -0
  17. package/lib/types/constants.d.ts +22 -0
  18. package/lib/types/index.d.ts +8 -0
  19. package/lib/types/paths.d.ts +3 -0
  20. package/lib/types/prefs.d.ts +8 -0
  21. package/lib/types/store.d.ts +72 -0
  22. package/lib/types/types.d.ts +230 -0
  23. package/lib/types/update.d.ts +24 -0
  24. package/lib/types.js +10 -0
  25. package/lib/update.js +110 -0
  26. package/locale/en.json +4 -0
  27. package/locale/zh.json +4 -0
  28. package/package.json +110 -4
  29. package/src/buckets.ts +120 -0
  30. package/src/chunk/echarts.entry.ts +27 -0
  31. package/src/client/api.ts +126 -0
  32. package/src/client/charts.tsx +255 -0
  33. package/src/client/chrome.tsx +127 -0
  34. package/src/client/contract.ts +62 -0
  35. package/src/client/drawer.tsx +113 -0
  36. package/src/client/format.ts +36 -0
  37. package/src/client/globals.d.ts +21 -0
  38. package/src/client/index.tsx +227 -0
  39. package/src/client/locales.ts +164 -0
  40. package/src/client/models.ts +76 -0
  41. package/src/client/mount.tsx +312 -0
  42. package/src/client/panel.tsx +152 -0
  43. package/src/client/pill.ts +21 -0
  44. package/src/client/react.ts +12 -0
  45. package/src/client/series.tsx +140 -0
  46. package/src/client/settings.tsx +347 -0
  47. package/src/client/styles/components.less +191 -0
  48. package/src/client/styles/index.less +7 -0
  49. package/src/client/styles/tailwind.css +21 -0
  50. package/src/client/styles/tokens.less +25 -0
  51. package/src/client/table.tsx +153 -0
  52. package/src/client/ui.tsx +87 -0
  53. package/src/constants.ts +26 -0
  54. package/src/index.ts +673 -0
  55. package/src/paths.ts +28 -0
  56. package/src/prefs.ts +61 -0
  57. package/src/store.ts +295 -0
  58. package/src/types.ts +216 -0
  59. package/src/update.ts +106 -0
package/src/buckets.ts ADDED
@@ -0,0 +1,120 @@
1
+ /**
2
+ * The usage model: provider samples, bucket arithmetic, and the time helpers
3
+ * that turn them into chart-ready rows.
4
+ *
5
+ * @module dsh-token-chart/buckets
6
+ */
7
+ import { DAY_MS } from './constants.ts'
8
+ import type { Buckets, Derived, SessionEvent, UsageSample } from './types.ts'
9
+
10
+ /** One zeroed bucket set. */
11
+ export function zeroBuckets(): Buckets {
12
+ return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }
13
+ }
14
+
15
+ export function isCount(value: unknown): value is number {
16
+ return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0
17
+ }
18
+
19
+ /**
20
+ * The last `usage` chunk embedded in one durable Assistant settlement stream.
21
+ * @param stream - compact records of the settlement.
22
+ * @returns the provider usage object, or undefined.
23
+ */
24
+ export function lastStreamUsage(stream: readonly unknown[] | undefined): UsageSample | undefined {
25
+ if (!Array.isArray(stream)) return undefined
26
+ for (let index = stream.length - 1; index >= 0; index -= 1) {
27
+ const record = stream[index]
28
+ if (record === null || typeof record !== 'object') continue
29
+ if (record.type !== 'chunk') continue
30
+ const chunk = record.chunk
31
+ if (chunk !== null && typeof chunk === 'object' && chunk.type === 'usage') return chunk.usage
32
+ }
33
+ return undefined
34
+ }
35
+
36
+ /** The usage one durable settlement reports, if any. */
37
+ export function usageOf(event: SessionEvent): UsageSample | undefined {
38
+ const data = event.data
39
+ if (data === null || typeof data !== 'object') return undefined
40
+ if (event.type === 'assistant/message' && data.usage !== undefined) return data.usage
41
+ if (event.type !== 'assistant/message' && event.type !== 'assistant/attempt') return undefined
42
+ return lastStreamUsage(data.stream)
43
+ }
44
+
45
+ /** Normalize one provider usage sample into the four billed buckets. */
46
+ export function bucketsFrom(usage: UsageSample | undefined): Buckets | undefined {
47
+ if (usage === null || typeof usage !== 'object') return undefined
48
+ const input = usage.inputTokens
49
+ const output = usage.outputTokens
50
+ if (!isCount(input) || !isCount(output)) return undefined
51
+ const cacheRead = usage.cacheReadTokens === undefined ? 0 : usage.cacheReadTokens
52
+ const cacheWrite = usage.cacheWriteTokens === undefined ? 0 : usage.cacheWriteTokens
53
+ if (!isCount(cacheRead) || !isCount(cacheWrite)) return undefined
54
+ return { input, output, cacheRead, cacheWrite }
55
+ }
56
+
57
+ export function bucketsEqual(left: Buckets, right: Buckets): boolean {
58
+ return (
59
+ left.input === right.input &&
60
+ left.output === right.output &&
61
+ left.cacheRead === right.cacheRead &&
62
+ left.cacheWrite === right.cacheWrite
63
+ )
64
+ }
65
+
66
+ export function addInto(total: Buckets, buckets: Buckets): Buckets {
67
+ total.input += buckets.input
68
+ total.output += buckets.output
69
+ total.cacheRead += buckets.cacheRead
70
+ total.cacheWrite += buckets.cacheWrite
71
+ return total
72
+ }
73
+
74
+ export function subtract(total: Buckets, buckets: Buckets): Buckets {
75
+ total.input -= buckets.input
76
+ total.output -= buckets.output
77
+ total.cacheRead -= buckets.cacheRead
78
+ total.cacheWrite -= buckets.cacheWrite
79
+ return total
80
+ }
81
+
82
+ /** Derived figures shared by every response. */
83
+ export function derived<T extends Record<string, unknown>>(buckets: Buckets, extra: T = {} as T): Derived & T {
84
+ const { input, output, cacheRead, cacheWrite } = buckets
85
+ const cache = cacheRead + cacheWrite
86
+ const billedInput = input + cacheRead + cacheWrite
87
+ const total = billedInput + output
88
+ return {
89
+ input,
90
+ output,
91
+ cacheRead,
92
+ cacheWrite,
93
+ cache,
94
+ total,
95
+ cacheHitRate: billedInput > 0 ? cacheRead / billedInput : 0,
96
+ ...extra,
97
+ }
98
+ }
99
+
100
+ /** Local calendar-day key `YYYY-MM-DD`. */
101
+ export function dayKey(time: number): string {
102
+ const date = new Date(time)
103
+ const month = String(date.getMonth() + 1).padStart(2, '0')
104
+ const day = String(date.getDate()).padStart(2, '0')
105
+ return `${date.getFullYear()}-${month}-${day}`
106
+ }
107
+
108
+ /** Epoch ms of local midnight, `offset` days back. */
109
+ export function startOfLocalDay(offset = 0): number {
110
+ const date = new Date()
111
+ date.setHours(0, 0, 0, 0)
112
+ return date.getTime() - offset * DAY_MS
113
+ }
114
+
115
+ export function timeLabel(time: number): string {
116
+ const date = new Date(time)
117
+ const hours = String(date.getHours()).padStart(2, '0')
118
+ const minutes = String(date.getMinutes()).padStart(2, '0')
119
+ return `${hours}:${minutes}`
120
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Build entry for the package-local lazy chunk `client.echarts.js`.
3
+ *
4
+ * Only what the token-usage surfaces actually draw is pulled in: bar, line and
5
+ * pie series plus the grid/tooltip/legend/dataZoom components and the SVG
6
+ * renderer.
7
+ * Everything else ECharts ships (maps, 3D, GL, canvas renderer, the full
8
+ * component set) is tree-shaken away by the bundler.
9
+ */
10
+ import * as echarts from 'echarts/core'
11
+ import { BarChart, LineChart, PieChart } from 'echarts/charts'
12
+ import { GridComponent, TooltipComponent, LegendComponent, DataZoomComponent } from 'echarts/components'
13
+ import { SVGRenderer } from 'echarts/renderers'
14
+
15
+ echarts.use([
16
+ BarChart,
17
+ LineChart,
18
+ PieChart,
19
+ GridComponent,
20
+ TooltipComponent,
21
+ LegendComponent,
22
+ // The x-axis range slider under the chart.
23
+ DataZoomComponent,
24
+ SVGRenderer,
25
+ ])
26
+
27
+ export default echarts
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Transport and shared state: the preference store every seat reads,
3
+ * the JSON helpers, and the polling hooks the surfaces use.
4
+ *
5
+ * @module dsh-token-chart/client/api
6
+ */
7
+ import type { Prefs } from '../types.ts'
8
+
9
+ import type { EndpointState } from './models.ts'
10
+
11
+ import { React } from './react.ts'
12
+
13
+ import { API, DEFAULT_PREFS } from './contract.ts'
14
+
15
+ export const useSyncExternalStore =
16
+ typeof React.useSyncExternalStore === 'function'
17
+ ? React.useSyncExternalStore
18
+ : (subscribe: (onStoreChange: () => void) => () => void, getSnapshot: () => unknown) => {
19
+ const [value, setValue] = React.useState(getSnapshot)
20
+ React.useEffect(() => subscribe(() => setValue(getSnapshot())), [subscribe, getSnapshot])
21
+ return value
22
+ }
23
+
24
+ /** Snapshot store every seat subscribes to. */
25
+ export interface PrefsStore {
26
+ get: () => Prefs
27
+ set: (next: Prefs) => void
28
+ subscribe: (listener: () => void) => () => void
29
+ }
30
+
31
+ export const prefsStore: PrefsStore = (() => {
32
+ let value: Prefs = { ...DEFAULT_PREFS }
33
+ const listeners = new Set<() => void>()
34
+ return {
35
+ get: () => value,
36
+ set: (next: Prefs) => {
37
+ value = next
38
+ for (const listener of [...listeners]) (listener as () => void)()
39
+ },
40
+ subscribe: (listener: () => void) => {
41
+ listeners.add(listener)
42
+ return () => listeners.delete(listener)
43
+ },
44
+ }
45
+ })()
46
+
47
+ export function usePrefs(): Prefs {
48
+ return useSyncExternalStore(prefsStore.subscribe, prefsStore.get) as Prefs
49
+ }
50
+
51
+ /* --------------------------------------------------------- api client */
52
+
53
+ export async function apiGet(path: string): Promise<{ value: any }> {
54
+ const response = await fetch(`${API}${path}`, { headers: { accept: 'application/json' } })
55
+ const payload = await response.json().catch(() => null)
56
+ if (payload === null || payload.ok !== true) {
57
+ throw new Error(payload?.error?.message ?? `HTTP ${response.status}`)
58
+ }
59
+ return payload
60
+ }
61
+
62
+ export async function apiPost(path: string, body: unknown): Promise<{ value: any }> {
63
+ const response = await fetch(`${API}${path}`, {
64
+ method: 'POST',
65
+ headers: { 'content-type': 'application/json' },
66
+ body: JSON.stringify(body ?? {}),
67
+ })
68
+ const payload = await response.json().catch(() => null)
69
+ if (payload === null || payload.ok !== true) {
70
+ throw new Error(payload?.error?.message ?? `HTTP ${response.status}`)
71
+ }
72
+ return payload
73
+ }
74
+
75
+ /**
76
+ * Poll one API path: initial load, then a refresh whenever `tick` changes.
77
+ * @param path - API path including its query string.
78
+ * @param tick - refresh trigger.
79
+ */
80
+ export function useEndpoint(path: string, tick: number): EndpointState {
81
+ const [state, setState] = React.useState<EndpointState>({ loading: true, value: undefined })
82
+ React.useEffect(() => {
83
+ let live = true
84
+ setState((current) => ({ ...current, loading: true }))
85
+ apiGet(path)
86
+ .then((payload) => {
87
+ if (!live) return
88
+ setState({ loading: false, value: payload.value, error: undefined })
89
+ })
90
+ .catch((error) => {
91
+ if (!live) return
92
+ setState({
93
+ loading: false,
94
+ value: undefined,
95
+ error: String((error as Error)?.message ?? error),
96
+ } as EndpointState)
97
+ })
98
+ return () => {
99
+ live = false
100
+ }
101
+ }, [path, tick])
102
+ return state
103
+ }
104
+
105
+ /* ------------------------------------------------------------- layout */
106
+
107
+ export function useElementWidth(): [React.RefObject<any>, number] {
108
+ const ref = React.useRef(null)
109
+ const [width, setWidth] = React.useState(0)
110
+ React.useEffect(() => {
111
+ const node = ref.current as HTMLElement | null
112
+ if (node === null) return undefined
113
+ const update = (): void => setWidth((node as HTMLElement).clientWidth)
114
+ update()
115
+ if (typeof ResizeObserver === 'undefined') {
116
+ window.addEventListener('resize', update)
117
+ return () => window.removeEventListener('resize', update)
118
+ }
119
+ const observer = new ResizeObserver(update)
120
+ observer.observe(node)
121
+ return () => observer.disconnect()
122
+ }, [])
123
+ return [ref, width]
124
+ }
125
+
126
+ /* ----------------------------------------------------------- charts */
@@ -0,0 +1,255 @@
1
+ /**
2
+ * Built-in SVG fallback charts, used when the ECharts chunk is
3
+ * unavailable (offline first paint, chunk failure, degraded mode).
4
+ *
5
+ * @module dsh-token-chart/client/charts
6
+ */
7
+ import type { ChartPoint, SeriesSpec, SliceSpec, Translate } from './models.ts'
8
+
9
+ import { useElementWidth } from './api.ts'
10
+ import { compactTokens, exactTokens, percentText } from './format.ts'
11
+
12
+ /** One bar/line chart: bars and lines share points and a metric axis. */
13
+ export function CartesianChart({
14
+ points,
15
+ series,
16
+ height = 210,
17
+ }: {
18
+ points: ChartPoint[]
19
+ series: SeriesSpec[]
20
+ height?: number
21
+ t: Translate
22
+ }) {
23
+ const [ref, width] = useElementWidth()
24
+ const box = Math.max(220, width || 320)
25
+ const hasRight = series.some((entry) => entry.axis === 'right')
26
+ const padding = { top: 12, right: hasRight ? 46 : 14, bottom: 30, left: 48 }
27
+ const innerWidth = Math.max(40, box - padding.left - padding.right)
28
+ const innerHeight = Math.max(40, height - padding.top - padding.bottom)
29
+
30
+ const leftMax = Math.max(
31
+ 1,
32
+ ...series
33
+ .filter((entry) => entry.axis !== 'right')
34
+ .flatMap((entry) => points.map((point) => Number(point.values[entry.key]) || 0)),
35
+ )
36
+ const rightMax = Math.max(
37
+ 0.0001,
38
+ ...series
39
+ .filter((entry) => entry.axis === 'right')
40
+ .flatMap((entry) => points.map((point) => Number(point.values[entry.key]) || 0)),
41
+ )
42
+ const scaleY = (value: number, axis: 'left' | 'right' | undefined): number => {
43
+ const max = axis === 'right' ? rightMax : leftMax
44
+ return padding.top + innerHeight - (Math.max(0, value) / max) * innerHeight
45
+ }
46
+ const step = innerWidth / Math.max(1, points.length)
47
+ const barSeries = series.filter((entry) => entry.type === 'bar')
48
+ const barWidth = Math.max(2, Math.min(26, (step * 0.72) / Math.max(1, barSeries.length)))
49
+ const gridLines = [0, 0.25, 0.5, 0.75, 1]
50
+ const labelEvery = Math.max(1, Math.ceil(points.length / Math.max(2, Math.floor(innerWidth / 56))))
51
+
52
+ const children: any[] = []
53
+ for (const ratio of gridLines) {
54
+ const y = padding.top + innerHeight * ratio
55
+ children.push(
56
+ <line
57
+ key={`grid-${ratio}`}
58
+ x1={padding.left}
59
+ x2={padding.left + innerWidth}
60
+ y1={y}
61
+ y2={y}
62
+ stroke="var(--dsw-alias-border-l1)"
63
+ strokeWidth={1}
64
+ strokeDasharray={ratio === 1 ? undefined : '3 4'}
65
+ />,
66
+ )
67
+ }
68
+ children.push(
69
+ <text
70
+ key="axis-left-max"
71
+ x={padding.left - 8}
72
+ y={padding.top + 4}
73
+ textAnchor="end"
74
+ fontSize={10}
75
+ fill="var(--dsw-alias-label-secondary)"
76
+ >
77
+ {compactTokens(leftMax)}
78
+ </text>,
79
+ )
80
+ children.push(
81
+ <text
82
+ key="axis-left-zero"
83
+ x={padding.left - 8}
84
+ y={padding.top + innerHeight}
85
+ textAnchor="end"
86
+ fontSize={10}
87
+ fill="var(--dsw-alias-label-secondary)"
88
+ >
89
+ {'0'}
90
+ </text>,
91
+ )
92
+ if (hasRight) {
93
+ children.push(
94
+ <text
95
+ key="axis-right-max"
96
+ x={padding.left + innerWidth + 8}
97
+ y={padding.top + 4}
98
+ fontSize={10}
99
+ fill="var(--dsw-alias-label-secondary)"
100
+ >
101
+ {percentText(rightMax)}
102
+ </text>,
103
+ )
104
+ }
105
+
106
+ points.forEach((point, index) => {
107
+ if (index % labelEvery !== 0) return
108
+ children.push(
109
+ <text
110
+ key={`label-${index}`}
111
+ x={padding.left + step * index + step / 2}
112
+ y={padding.top + innerHeight + 16}
113
+ textAnchor="middle"
114
+ fontSize={10}
115
+ fill="var(--dsw-alias-label-secondary)"
116
+ >
117
+ {String(point.label ?? '')}
118
+ </text>,
119
+ )
120
+ })
121
+
122
+ barSeries.forEach((entry, seriesIndex) => {
123
+ points.forEach((point, index) => {
124
+ const value = Math.max(0, Number(point.values[entry.key]) || 0)
125
+ if (value <= 0) return
126
+ const x = padding.left + step * index + step / 2 - (barWidth * barSeries.length) / 2 + barWidth * seriesIndex
127
+ const y = scaleY(value, entry.axis)
128
+ children.push(
129
+ <rect
130
+ key={`bar-${entry.key}-${index}`}
131
+ x={x}
132
+ y={y}
133
+ width={barWidth}
134
+ height={Math.max(1, padding.top + innerHeight - y)}
135
+ rx={Math.min(3, barWidth / 2)}
136
+ fill={entry.color}
137
+ >
138
+ <title>{`${entry.label}: ${exactTokens(value)}`}</title>
139
+ </rect>,
140
+ )
141
+ })
142
+ })
143
+
144
+ for (const entry of series.filter((item) => item.type === 'line')) {
145
+ const coordinates = points.map((point, index) => {
146
+ const value = Number(point.values[entry.key]) || 0
147
+ return [padding.left + step * index + step / 2, scaleY(value, entry.axis)]
148
+ })
149
+ children.push(
150
+ <polyline
151
+ key={`line-${entry.key}`}
152
+ points={coordinates.map((pair) => pair.join(',')).join(' ')}
153
+ fill="none"
154
+ stroke={entry.color}
155
+ strokeWidth={2}
156
+ strokeLinejoin="round"
157
+ strokeLinecap="round"
158
+ />,
159
+ )
160
+ coordinates.forEach((pair, index) => {
161
+ children.push(
162
+ <circle key={`dot-${entry.key}-${index}`} cx={pair[0]} cy={pair[1]} r={2.6} fill={entry.color}>
163
+ <title>{`${entry.label}: ${exactTokens(points[index].values[entry.key])}`}</title>
164
+ </circle>,
165
+ )
166
+ })
167
+ }
168
+
169
+ return (
170
+ <div className="dtc-chart" ref={ref}>
171
+ {
172
+ <svg width={box} height={height} role="img" aria-label={series.map((entry) => entry.label).join(', ')}>
173
+ {children}
174
+ </svg>
175
+ }
176
+ <Legend series={series} />
177
+ </div>
178
+ )
179
+ }
180
+
181
+ export function Legend({ series }: { series: SeriesSpec[] }) {
182
+ return (
183
+ <div className="dtc-legend">
184
+ {series.map((entry) => (
185
+ <span key={entry.key} className="dtc-legend-item">
186
+ <span className="dtc-swatch" style={{ background: entry.color }} />
187
+ <span>{entry.label}</span>
188
+ </span>
189
+ ))}
190
+ </div>
191
+ )
192
+ }
193
+
194
+ /** Donut chart over prepared slices. */
195
+ export function PieChart({ slices, t }: { slices: SliceSpec[]; t: Translate }) {
196
+ const [ref, width] = useElementWidth()
197
+ const box = Math.max(180, Math.min(width || 240, 240))
198
+ const size = box
199
+ const radius = size / 2 - 6
200
+ const inner = radius * 0.58
201
+ const center = size / 2
202
+ const total = slices.reduce((sum, slice) => sum + (Number(slice.value) || 0), 0)
203
+ if (total <= 0) return <p className="dtc-empty">{t('panel.empty')}</p>
204
+ let angle = -Math.PI / 2
205
+ const paths = slices.map((slice, index) => {
206
+ const fraction = (Number(slice.value) || 0) / total
207
+ const sweep = fraction * Math.PI * 2
208
+ const start = angle
209
+ const end = angle + sweep
210
+ angle = end
211
+ const large = sweep > Math.PI ? 1 : 0
212
+ const point = (r: number, a: number): [number, number] => [center + r * Math.cos(a), center + r * Math.sin(a)]
213
+ const [x1, y1] = point(radius, start)
214
+ const [x2, y2] = point(radius, end)
215
+ const [x3, y3] = point(inner, end)
216
+ const [x4, y4] = point(inner, start)
217
+ const d =
218
+ slice.value <= 0
219
+ ? ''
220
+ : `M ${x1} ${y1} A ${radius} ${radius} 0 ${large} 1 ${x2} ${y2} L ${x3} ${y3} A ${inner} ${inner} 0 ${large} 0 ${x4} ${y4} Z`
221
+ return (
222
+ <path
223
+ key={`slice-${slice.key}-${index}`}
224
+ d={d}
225
+ fill={slice.color}
226
+ fillOpacity={slice.opacity ?? 1}
227
+ stroke="var(--dsw-alias-bg-base)"
228
+ strokeWidth={1.5}
229
+ >
230
+ <title>{`${slice.label}: ${exactTokens(slice.value)} (${percentText(fraction)})`}</title>
231
+ </path>
232
+ )
233
+ })
234
+ return (
235
+ <div className="dtc-chart dtc-chart-pie" ref={ref}>
236
+ <svg width={size} height={size} role="img" aria-label={t('chart.pie')}>
237
+ {paths}
238
+ </svg>
239
+ {
240
+ <div className="dtc-legend dtc-legend-column">
241
+ {slices.map((slice, index) => (
242
+ <span key={`legend-${slice.key}-${index}`} className="dtc-legend-item">
243
+ <span className="dtc-swatch" style={{ background: slice.color, opacity: slice.opacity ?? 1 }} />
244
+ <span>{`${slice.label} · ${compactTokens(slice.value)} · ${percentText((Number(slice.value) || 0) / total)}`}</span>
245
+ </span>
246
+ ))}
247
+ </div>
248
+ }
249
+ </div>
250
+ )
251
+ }
252
+
253
+ /** Page sizes offered under a table, smallest first. */
254
+ export const PAGE_SIZES = [10, 20, 50]
255
+ export const DEFAULT_PAGE_SIZE = 20
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Shell chrome: the icon shared by the menu and the settings row, the
3
+ * session-header entry, the settings-nav marker and the style installer.
4
+ *
5
+ * @module dsh-token-chart/client/chrome
6
+ */
7
+ import type { Translate } from './models.ts'
8
+
9
+ export function PanelIcon({ size = 18, active = false }: { size?: number; active?: boolean }) {
10
+ return (
11
+ <svg
12
+ width={size}
13
+ height={size}
14
+ viewBox="0 0 24 24"
15
+ fill="none"
16
+ aria-hidden={true}
17
+ style={{ opacity: active ? 1 : 0.9, display: 'block' }}
18
+ >
19
+ <rect x={3} y={12} width={4} height={9} rx={1} fill="currentColor" />
20
+ <rect x={9} y={8} width={4} height={13} rx={1} fill="currentColor" opacity={0.75} />
21
+ <rect x={15} y={4} width={4} height={17} rx={1} fill="currentColor" opacity={0.5} />
22
+ </svg>
23
+ )
24
+ }
25
+
26
+ /** The session-header usage entry; always present (see the settings copy). */
27
+ export function HeaderUsageButton({
28
+ sessionId,
29
+ open,
30
+ t,
31
+ }: {
32
+ sessionId: string
33
+ open: (sessionId: string) => boolean
34
+ t: Translate
35
+ }) {
36
+ return (
37
+ <button
38
+ type="button"
39
+ className="dtc-header-button"
40
+ aria-label={t('usage.openDrawer')}
41
+ title={t('usage.title')}
42
+ onClick={() => open(sessionId)}
43
+ >
44
+ <svg width={16} height={16} viewBox="0 0 24 24" fill="none" aria-hidden={true}>
45
+ <path d="M4 19V9M10 19V5M16 19v-7M22 19H2" stroke="currentColor" strokeWidth={1.6} strokeLinecap="round" />
46
+ </svg>
47
+ <span className="dtc-header-label">{t('panel.title')}</span>
48
+ </button>
49
+ )
50
+ }
51
+
52
+ /** True when the clicked element is one of the product's usage pills. */
53
+ export function isUsagePill(target: any): boolean {
54
+ let node = target
55
+ for (let depth = 0; depth < 6 && node !== null && node !== undefined; depth += 1) {
56
+ if (node.nodeType === 1 && node.tagName === 'BUTTON') {
57
+ const text = node.textContent ?? ''
58
+ if (node.getAttribute('aria-haspopup') === 'dialog' && /\btok\b/i.test(text)) return true
59
+ return false
60
+ }
61
+ node = node.parentNode
62
+ }
63
+ return false
64
+ }
65
+
66
+ /** The Host route that serves the compiled stylesheet. */
67
+ const STYLE_PATH = '/token-chart/assets/token-chart.css'
68
+ /** Where the stylesheet link is remembered, so a re-mount does not duplicate it. */
69
+ const STYLE_LINK_MARKER = 'data-dtc-styles'
70
+
71
+ export const SETTINGS_NAV_MARKER = 'data-dtc-settings-nav'
72
+
73
+ /**
74
+ * Keep the marker on our own settings-nav row.
75
+ *
76
+ * The settings shell projects only `id`, `order` and `label` from a
77
+ * `settings.section` registration and picks its icon from a closed list of
78
+ * built-in ids, so there is no icon field to set. Until that contract grows
79
+ * one, identify our localized row after the dialog mounts and draw the same
80
+ * glyph the left-sidebar menu uses through CSS on the marker.
81
+ * @param label - locale-aware resolver for the section label.
82
+ * @returns disposer removing the observer and every marker it set.
83
+ */
84
+ export function registerSettingsNavIcon(label: () => string): () => void {
85
+ let disposed = false
86
+ const sync = () => {
87
+ if (disposed) return
88
+ const current = label().trim()
89
+ for (const button of document.querySelectorAll('[role="dialog"] nav button')) {
90
+ if (current !== '' && (button.textContent ?? '').trim() === current) button.setAttribute(SETTINGS_NAV_MARKER, '')
91
+ else button.removeAttribute(SETTINGS_NAV_MARKER)
92
+ }
93
+ }
94
+ sync()
95
+ const observer = new MutationObserver(sync)
96
+ observer.observe(document.body, { childList: true, subtree: true, characterData: true })
97
+ return () => {
98
+ disposed = true
99
+ observer.disconnect()
100
+ for (const marked of document.querySelectorAll(`[${SETTINGS_NAV_MARKER}]`))
101
+ marked.removeAttribute(SETTINGS_NAV_MARKER)
102
+ }
103
+ }
104
+
105
+ /**
106
+ * Link the plugin's compiled stylesheet.
107
+ *
108
+ * The CSS is a real artifact served by the Host half and loaded as a stylesheet
109
+ * resource — nothing about styling rides inside the client bundle, and the
110
+ * loader removes the link again when this fiber is disposed.
111
+ *
112
+ * @returns disposer removing the link.
113
+ */
114
+ export function installStyles(): () => void {
115
+ const existing = document.querySelector(`link[${STYLE_LINK_MARKER}]`)
116
+ if (existing !== null) return () => {}
117
+
118
+ const link = document.createElement('link')
119
+ link.rel = 'stylesheet'
120
+ link.href = STYLE_PATH
121
+ link.setAttribute(STYLE_LINK_MARKER, '')
122
+ document.head.appendChild(link)
123
+
124
+ return () => {
125
+ link.remove()
126
+ }
127
+ }