@rizal72/oc-budget 0.0.1 → 0.1.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.
package/gui.tsx DELETED
@@ -1,622 +0,0 @@
1
- // oc-budget — GUI half.
2
- //
3
- // One surface: the figure in the session header, beside the app's own context ring and session cost, where
4
- // a reader already looks for what the session is costing. Its popover carries the whole block — every
5
- // wallet, the Go bar, the resets, the workspace, the peak mark — the same lines the terminal draws.
6
- //
7
- // The mark is a ring only where a percentage exists: Go's month. ZEN, DS and OR are money, and a ring
8
- // around money would imitate a share of a cap that does not exist, so their figure carries the colour and
9
- // the name stays quiet. The app's catalog has no money glyph — `providers` says provider, not money — so the
10
- // card is drawn here; `ctx.ui.Icon` supplies the warning one.
11
- //
12
- // Colours come from the theme, never from hex: a GUI plugin has no theme API, but the app publishes its
13
- // tokens as CSS variables, and these are the three alert levels the terminal uses.
14
- import { Plugin, Slot, Style, createKeyed, type SlotMap } from "@opencode/plugin/gui"
15
- import { Show, createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js"
16
-
17
- import {
18
- DS_WARN_USD,
19
- GO_WARN_PERCENT,
20
- OR_WARN_USD,
21
- ZEN_WARN_USD,
22
- ageText,
23
- connected,
24
- countdown,
25
- payingWallet,
26
- windowPercent,
27
- } from "./budget.ts"
28
- import type { Wallet } from "./budget.ts"
29
- import { Budget } from "./rpc.ts"
30
- import type { BudgetSnapshot } from "./rpc.ts"
31
- import { HeaderMenu } from "./header-menu.tsx"
32
- import { clock, doubles as peakDoubles, isPeak } from "./peak.ts"
33
-
34
- /** The mark says "this tariff is in its doubled window"; the figure adds it to the *paying* model only. */
35
- const PEAK_MARK = "↗"
36
-
37
- const REFRESH_MS = 5 * 60 * 1000
38
- /** A failed poll is not a slow poll: ask again in half a minute, before the five-minute tick. */
39
- const RETRY_MS = 30_000
40
- /** Two missed polls: from here the numbers on screen are history, and the figure says so. */
41
- const STALE_MS = 2 * REFRESH_MS
42
- const RPC_TIMEOUT_MS = 15_000
43
-
44
- const CSS = `
45
- /* The block that opens from the header figure wears that figure's own type: one size, 13px, weight 440, the
46
- app's tight tracking. Hierarchy here is colour and weight, never a second size. */
47
- /* anywhere: an error carrying a URL or a token must not paint outside the box. This is our content; the
48
- overflow is ours too. */
49
- .ocb { display: flex; flex-direction: column; gap: 5px; width: 100%; font-size: 13px; font-weight: 440;
50
- letter-spacing: -0.04px; font-variant-numeric: tabular-nums; overflow-wrap: anywhere; }
51
- .ocb-line { display: flex; align-items: center; gap: 6px; }
52
- .ocb-name { font-weight: 600; letter-spacing: .02em; min-width: 2.6em; }
53
- .ocb-figure { margin-left: auto; min-width: 4.2em; text-align: right; }
54
- .ocb-peak { margin-left: 4px; }
55
- .ocb-windows { gap: 4px; }
56
- .ocb-ok { color: var(--v2-state-fg-success, inherit); }
57
- .ocb-warn { color: var(--v2-state-fg-warning, inherit); }
58
- .ocb-bad { color: var(--v2-state-fg-danger, inherit); }
59
- .ocb-dim { color: var(--v2-text-text-muted, inherit); }
60
- /* The empty state's two lines only: the words are short and the panel is 18em wide, so left-aligned they
61
- leave a column of dead space under the figure they belong to. They sit against the same edge the figure
62
- does, and nothing else in this plugin is right-aligned. */
63
- .ocb-empty { text-align: right; }
64
- .ocb-accent { color: var(--v2-text-text-accent, inherit); }
65
- .ocb-bar { flex: 1 1 auto; min-width: 90px; height: 6px; border-radius: 3px; overflow: hidden;
66
- background: color-mix(in srgb, currentColor 16%, transparent); }
67
- .ocb-fill { display: block; height: 100%; border-radius: 3px; background: currentColor; }
68
- .ocb-rule { height: 1px; margin: 2px 0; background: color-mix(in srgb, currentColor 18%, transparent); }
69
- /* The app's own toolbar button, hand-written: its height and its hover plate are what make the figure read as
70
- one of the header's controls. */
71
- .ocb-head { display: inline-flex; align-items: center; gap: 5px; height: 28px; padding: 0 6px; border: 0;
72
- border-radius: 6px; background: none; color: var(--v2-text-text-faint); font: inherit; font-size: 13px;
73
- font-weight: 440; letter-spacing: -0.04px; font-variant-numeric: tabular-nums; cursor: pointer; }
74
- .ocb-head:hover, .ocb-head:focus-visible {
75
- background: color-mix(in srgb, var(--v2-text-text-base) 8%, transparent);
76
- color: var(--v2-text-text-base);
77
- }
78
- /* Two classes, so the open plate outranks the hover plate while the pointer still rests on the trigger. */
79
- .ocb-head.ocb-head-on {
80
- background: color-mix(in srgb, var(--v2-text-text-base) 12%, transparent);
81
- color: var(--v2-text-text-base);
82
- }
83
- .ocb-mark { display: inline-flex; align-items: center; justify-content: center; width: 16px; height: 16px; }
84
- .ocb-head-label { white-space: nowrap; color: inherit; }
85
- /* The block: Kobalte puts it in the app's own top layer and positions it from the trigger, so there is no
86
- position rule here — only the box. */
87
- .ocb-pop { width: max-content; min-width: 18em;
88
- max-width: min(22em, calc(100vw - 16px)); padding: 8px 10px; border-radius: 8px;
89
- border: 1px solid var(--v2-border-border-muted, rgba(255, 255, 255, .12));
90
- background: var(--v2-background-bg-layer-02, #1b1b1b);
91
- box-shadow: 0 8px 24px rgba(0, 0, 0, .35); color: var(--v2-text-text-base, inherit); font-size: 13px; }
92
- /* The hover hint is wider than this panel, so it would show beside it: while any figure's panel is open, no
93
- tooltip is drawn anywhere. CSS cannot select only ours, and the panel is not modal, so the rest of the header
94
- stays hoverable — a trade, recorded in docs/oc-budget.md. */
95
- body:has(.ocb-head[aria-expanded="true"]) [data-component="tooltip-v2"] { display: none; }
96
- `
97
-
98
- type Tone = "success" | "warning" | "danger"
99
-
100
- const CLASS: Record<Tone, string> = { success: "ocb-ok", warning: "ocb-warn", danger: "ocb-bad" }
101
- const cls = (tone: Tone | undefined) => (tone ? CLASS[tone] : "")
102
-
103
- /** A stalled call must not leave the figure frozen: the guard resets only when the promise settles. */
104
- const withTimeout = <T,>(promise: Promise<T>, ms: number): Promise<T> =>
105
- new Promise<T>((resolve, reject) => {
106
- const timer = setTimeout(() => reject(new Error("rpc timeout")), ms)
107
- promise.then(
108
- (value) => {
109
- clearTimeout(timer)
110
- resolve(value)
111
- },
112
- (error) => {
113
- clearTimeout(timer)
114
- reject(error)
115
- },
116
- )
117
- })
118
-
119
- const usd = (value: number | undefined) =>
120
- typeof value === "number" && Number.isFinite(value) ? `$${value.toFixed(2)}` : "—"
121
-
122
- const percent = (value: number | null) => (value === null ? "—" : `${Math.round(value)}%`)
123
-
124
- /** The bar's width: a percent clamped to its track, because the value can overshoot and never undershoot. */
125
- const fill = (value: number | null) => Math.max(0, Math.min(100, value ?? 0))
126
-
127
- /** Red at the wall, yellow past the threshold, green elsewhere — the answer is the same for a percent
128
- * and for a balance, only the wall differs. */
129
- const tone = (value: number | null, warn: number, empty: number): Tone | undefined => {
130
- if (value === null) return undefined
131
- if (value <= empty) return "danger"
132
- return value <= warn ? "warning" : "success"
133
- }
134
-
135
- /** A quota window's colour, as the terminal draws it: done at 100, alert at the threshold. */
136
- const windowTone = (value: number | null): Tone | undefined => {
137
- if (value === null) return undefined
138
- if (value >= 100) return "danger"
139
- return value >= GO_WARN_PERCENT ? "warning" : "success"
140
- }
141
-
142
- type Line = { figure?: number; tone?: Tone; note?: string }
143
-
144
- /** What the header row and the popover both read: a session and its screen, and nothing else. */
145
- type SurfaceInput = Pick<SlotMap["session.header"], "session" | "screen">
146
-
147
- /** Everything the figure and its block say, computed once. Reads the signals, so call it from JSX: the slot
148
- * renders it reactively, and the popover reads the same signal while it is open. */
149
- type Figures = {
150
- ready: boolean
151
- /** No wallet exists at all: an account with no provider connected, which is a state and not a fault.
152
- * Every surface says it instead of drawing a figure, and the header keeps its figure clickable. */
153
- empty: boolean
154
- error?: string
155
- label?: string
156
- walletTone?: Tone
157
- /** Which wallet pays: the ring is drawn where a share of a cap exists, the figure where money does. */
158
- wallet: Wallet
159
- stale: boolean
160
- age: number
161
- atPeak: boolean
162
- marked: boolean
163
- go?: {
164
- rolling: number | null
165
- weekly: number | null
166
- monthly: number | null
167
- resets: { rolling: string; weekly: string; monthly: string }
168
- error?: string
169
- }
170
- workspace?: string
171
- /** A wallet the snapshot does not carry has no connection: it is left out, not drawn empty. */
172
- ds?: Line
173
- or?: Line
174
- zen?: Line
175
- }
176
-
177
- const NO_LINES: Pick<Figures, "ds" | "or" | "zen"> = {}
178
-
179
- export default Plugin.define({
180
- setup(ctx) {
181
- const [snapshot, setSnapshot] = createSignal<{ server: string; value: BudgetSnapshot } | undefined>(
182
- undefined,
183
- )
184
- /** Why the last read failed, and nothing else: cleared by the next good one. */
185
- const [failure, setFailure] = createSignal<string | undefined>(undefined)
186
- let timer: ReturnType<typeof setInterval> | undefined
187
- let retry: ReturnType<typeof setTimeout> | undefined
188
- let reading: string | undefined
189
- /** The current server's read, for the popover: opening it must not show a figure from five minutes ago. */
190
- let refreshNow: (() => void) | undefined
191
-
192
- // One poll per server, on that server's own client: the credential lives where the plugin runs, and a
193
- // server that does not run it has nothing to answer.
194
- createEffect(() => {
195
- const session = ctx.sessions.current()
196
- const server = session?.server.id
197
-
198
- if (timer) clearInterval(timer)
199
- if (retry) clearTimeout(retry)
200
- timer = undefined
201
- retry = undefined
202
- refreshNow = undefined
203
- // Another server's failure is not this session's: the effect re-runs when the routing moves.
204
- setFailure(undefined)
205
- if (!server) {
206
- setSnapshot(undefined)
207
- return
208
- }
209
-
210
- const refresh = (force = false) => {
211
- if (reading === server) return
212
- reading = server
213
- const input = force ? { force: true } : {}
214
- void withTimeout(session!.server.client.rpc(Budget).snapshot(input), RPC_TIMEOUT_MS)
215
- .then((value) => {
216
- // A slow answer from the server that was polled before must not overwrite this one.
217
- if (ctx.sessions.current()?.server.id !== server) return
218
- setSnapshot({ server, value: value as BudgetSnapshot })
219
- setFailure(undefined)
220
- })
221
- .catch((error: unknown) => {
222
- // Keep the last good reading: a failed poll says nothing about the balance, and
223
- // blanking the figure for five minutes cost a whole night (measured 2026-10-28, the
224
- // pill gone from 07:00 to 12:26, every poll a fast 500). Ask again sooner instead.
225
- if (ctx.sessions.current()?.server.id !== server) return
226
- setFailure(error instanceof Error ? error.message : String(error))
227
- if (retry) clearTimeout(retry)
228
- retry = setTimeout(refresh, RETRY_MS)
229
- })
230
- .finally(() => {
231
- if (reading === server) reading = undefined
232
- })
233
- }
234
-
235
- refreshNow = () => refresh(true)
236
- refresh()
237
- timer = setInterval(refresh, REFRESH_MS)
238
- })
239
-
240
- onCleanup(() => {
241
- if (timer) clearInterval(timer)
242
- if (retry) clearTimeout(retry)
243
- })
244
-
245
- const view = (input: SurfaceInput): Figures => {
246
- const current = snapshot()
247
- const error = failure()
248
- const model = input.screen.composer.model()
249
- const now = clock()
250
- // Two questions, two functions (`peak.ts`). `isPeak` is the tariff: GO and DS are marked whoever
251
- // pays, because the mark is the reminder not to switch *to* them. `doubles` is your own spend.
252
- const atPeak = isPeak(now)
253
-
254
- if (!current || current.server !== input.session.server.id) {
255
- return {
256
- ready: false,
257
- empty: false,
258
- error,
259
- wallet: "none",
260
- stale: false,
261
- age: 0,
262
- atPeak,
263
- marked: false,
264
- ...NO_LINES,
265
- }
266
- }
267
-
268
- const value = current.value
269
- const wallet = payingWallet(model, value)
270
- const go = value.go
271
- const rolling = windowPercent(go?.rolling)
272
- const weekly = windowPercent(go?.weekly)
273
- const monthly = windowPercent(go?.monthly)
274
- const zen = value.zen?.available ?? value.zen?.balance
275
- const age = Date.now() - value.at
276
-
277
- // A wallet absent from the snapshot has no connection, so the figure says nothing — a dash
278
- // would still claim a reading. The popover leaves its line out for the same reason.
279
- const label =
280
- wallet === "go"
281
- ? go
282
- ? `GO · ${percent(monthly)}`
283
- : undefined
284
- : wallet === "zen"
285
- ? value.zen
286
- ? `ZEN · ${usd(zen)}`
287
- : undefined
288
- : wallet === "ds"
289
- ? value.ds
290
- ? `DS · ${usd(value.ds.balance)}`
291
- : undefined
292
- : wallet === "or"
293
- ? value.or
294
- ? `OR · ${usd(value.or.balance)}`
295
- : undefined
296
- : undefined
297
-
298
- // No figure at all is not "healthy": the colour is left out, as it is for a money wallet with
299
- // nothing to read.
300
- const goTone: Tone | undefined = [rolling, weekly, monthly].every((figure) => figure === null)
301
- ? undefined
302
- : [rolling, weekly, monthly].some((figure) => figure !== null && figure >= 100)
303
- ? "danger"
304
- : [rolling, weekly, monthly].some(
305
- (figure) => figure !== null && figure >= GO_WARN_PERCENT,
306
- )
307
- ? "warning"
308
- : "success"
309
- const walletTone: Tone | undefined =
310
- wallet === "go"
311
- ? goTone
312
- : wallet === "zen"
313
- ? tone(zen ?? null, ZEN_WARN_USD, 0)
314
- : wallet === "ds"
315
- ? tone(value.ds?.balance ?? null, DS_WARN_USD, 0)
316
- : tone(value.or?.balance ?? null, OR_WARN_USD, 0)
317
-
318
- return {
319
- ready: true,
320
- empty: !connected(value),
321
- error,
322
- label,
323
- walletTone,
324
- wallet,
325
- stale: age > STALE_MS,
326
- age,
327
- atPeak,
328
- marked: peakDoubles(model, wallet, now),
329
- go: go
330
- ? {
331
- rolling,
332
- weekly,
333
- monthly,
334
- // One reset per window, as the block prints them: the monthly one alone said nothing
335
- // about the two shorter windows that empty first.
336
- resets: {
337
- rolling: countdown(go.rolling?.resetsAt),
338
- weekly: countdown(go.weekly?.resetsAt),
339
- monthly: countdown(go.monthly?.resetsAt),
340
- },
341
- error: go.error,
342
- }
343
- : undefined,
344
- // The block says the workspace only when the account has peers: alone in it, the name says nothing.
345
- workspace: value.zen?.org && (value.zen.peers ?? 0) > 1 ? value.zen.org : undefined,
346
- ds: value.ds && {
347
- figure: value.ds.balance,
348
- tone: tone(value.ds.balance ?? null, DS_WARN_USD, 0),
349
- note: value.ds.error,
350
- },
351
- or: value.or && {
352
- figure: value.or.balance,
353
- tone: tone(value.or.balance ?? null, OR_WARN_USD, 0),
354
- note: value.or.error,
355
- },
356
- zen: value.zen && { figure: zen, tone: tone(zen ?? null, ZEN_WARN_USD, 0), note: value.zen.error },
357
- }
358
- }
359
-
360
- /** One window: its percent on its own colour, its reset on the right, as the block prints them. */
361
- const WindowRow = (props: { label: string; percent: number | null; reset: string }) => (
362
- <div class="ocb-line ocb-windows">
363
- <span class="ocb-dim">{props.label}</span>
364
- <span class={cls(windowTone(props.percent))}>{percent(props.percent)}</span>
365
- <Show when={props.reset}>
366
- <span class="ocb-dim ocb-figure">reset {props.reset}</span>
367
- </Show>
368
- </div>
369
- )
370
-
371
- const WalletLine = (props: { name: string; line: Line; mark?: string }) => (
372
- <>
373
- <div class="ocb-line">
374
- <span class={`ocb-name ${cls(props.line.tone)}`}>{props.name}</span>
375
- <span class={`ocb-figure ${cls(props.line.tone)}`}>{usd(props.line.figure)}</span>
376
- <Show when={props.mark}>
377
- <span class="ocb-bad ocb-mark">{props.mark}</span>
378
- </Show>
379
- </div>
380
- <Show when={props.line.note}>
381
- <div class="ocb-dim">{props.line.note}</div>
382
- </Show>
383
- </>
384
- )
385
-
386
- /** The block, wherever it is shown: the popover under the header figure. The same lines, the same
387
- * colours, the same order the terminal draws. */
388
- const Block = (props: { input: SurfaceInput }) => {
389
- const figures = createMemo(() => view(props.input))
390
- // The tick is five minutes, so open on a fresh read: one call, and the figure follows it, because
391
- // both surfaces read this one signal.
392
- onMount(() => {
393
- refreshNow?.()
394
- })
395
-
396
- return (
397
- <div class="ocb">
398
- <Show
399
- when={figures().ready ? figures() : undefined}
400
- fallback={<div class="ocb-dim">{figures().error ?? "reading…"}</div>}
401
- >
402
- {(f: () => Figures) => (
403
- <>
404
- <Show when={f().stale}>
405
- <div class="ocb-warn">stale {ageText(f().age)}</div>
406
- </Show>
407
- <Show when={f().error}>
408
- <div class="ocb-dim">{f().error}</div>
409
- </Show>
410
- <Show when={f().empty}>
411
- <div class="ocb-dim ocb-empty">no provider connected</div>
412
- <div class="ocb-dim ocb-empty">opencode auth login</div>
413
- </Show>
414
- <Show when={f().go}>
415
- {(g: () => NonNullable<Figures["go"]>) => (
416
- <>
417
- <div class="ocb-line">
418
- <span class={`ocb-name ${cls(windowTone(g().monthly))}`}>GO</span>
419
- <span
420
- class={`ocb-bar ${cls(windowTone(g().monthly))}`}
421
- role="progressbar"
422
- aria-valuenow={fill(g().monthly)}
423
- aria-valuemin={0}
424
- aria-valuemax={100}
425
- >
426
- <span
427
- class="ocb-fill"
428
- style={{ width: `${fill(g().monthly)}%` }}
429
- />
430
- </span>
431
- <span class={`ocb-figure ${cls(windowTone(g().monthly))}`}>
432
- {percent(g().monthly)}
433
- </span>
434
- <Show when={f().atPeak}>
435
- <span class="ocb-bad ocb-peak">{PEAK_MARK}</span>
436
- </Show>
437
- </div>
438
- <Show
439
- when={!g().error}
440
- fallback={<div class="ocb-dim">{g().error}</div>}
441
- >
442
- <WindowRow label="5h" percent={g().rolling} reset={g().resets.rolling} />
443
- <WindowRow label="wk" percent={g().weekly} reset={g().resets.weekly} />
444
- <WindowRow label="mo" percent={g().monthly} reset={g().resets.monthly} />
445
- </Show>
446
- </>
447
- )}
448
- </Show>
449
- <Show when={f().workspace}>
450
- <div class="ocb-line">
451
- <span class="ocb-dim">workspace</span>
452
- <span class="ocb-accent ocb-figure">{f().workspace}</span>
453
- </div>
454
- </Show>
455
- <div class="ocb-rule" />
456
- <Show when={f().ds}>
457
- {(line: () => Line) => (
458
- <WalletLine name="DS" line={line()} mark={f().atPeak ? PEAK_MARK : ""} />
459
- )}
460
- </Show>
461
- <Show when={f().or}>
462
- {(line: () => Line) => <WalletLine name="OR" line={line()} />}
463
- </Show>
464
- <Show when={f().zen}>
465
- {(line: () => Line) => <WalletLine name="ZEN" line={line()} />}
466
- </Show>
467
- </>
468
- )}
469
- </Show>
470
- </div>
471
- )
472
- }
473
-
474
- /** The month's consumption as a ring, at the app's own geometry: an indicator circle is 16 px wide with a
475
- * 2 px stroke and a radius of `8 - stroke/2`, so the two rings are the same circle and the same family. */
476
- const RING_STROKE = 2
477
- const RING_R = 8 - RING_STROKE / 2
478
- const RING_C = 2 * Math.PI * RING_R
479
- const Ring = (props: { percent: number | null }) => (
480
- <svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
481
- <circle
482
- cx="8"
483
- cy="8"
484
- r={RING_R}
485
- stroke="currentColor"
486
- stroke-opacity="0.18"
487
- stroke-width={RING_STROKE}
488
- />
489
- <circle
490
- cx="8"
491
- cy="8"
492
- r={RING_R}
493
- stroke="currentColor"
494
- stroke-width={RING_STROKE}
495
- stroke-dasharray={RING_C}
496
- stroke-dashoffset={RING_C * (1 - fill(props.percent) / 100)}
497
- transform="rotate(-90 8 8)"
498
- />
499
- </svg>
500
- )
501
-
502
- /** The mark for a wallet that is money: a card, at the ring's size so the row keeps one rhythm. It is not a
503
- * share of anything, and a ring around money would read as a percentage that does not exist. */
504
- const WalletMark = () => (
505
- <svg viewBox="0 0 16 16" width="16" height="16" fill="none" aria-hidden="true">
506
- <rect x="2" y="4.5" width="12" height="8" rx="2" stroke="currentColor" stroke-width="1.6" />
507
- <path d="M2 7h12" stroke="currentColor" stroke-width="1.6" />
508
- <circle cx="11" cy="9.6" r="1.1" fill="currentColor" />
509
- </svg>
510
- )
511
-
512
- /** Which figure draws, and what it says. */
513
- type Mark = "ring" | "card" | "error"
514
-
515
- /** The figure in the session header, beside the app's own context ring and cost. The mark carries the
516
- * colour: a ring where a percentage exists, a card where the wallet is money, the warning icon when
517
- * nothing could be read. The popover is the platform's — the top layer, the light dismiss and Escape
518
- * come with it. */
519
- const Tooltip = ctx.ui.Tooltip
520
- const HeaderBudget = (props: { input: SlotMap["session.header"] }) => {
521
- const figures = () => view(props.input)
522
- const [open, setOpen] = createSignal(false)
523
-
524
- /** The popover owns its dismissals — outside click, Escape — so this is the only place an open can
525
- * start, and it starts on a fresh read: the tick is five minutes. */
526
- const change = (next: boolean) => {
527
- if (next) refreshNow?.()
528
- setOpen(next)
529
- }
530
-
531
- /** Nothing read yet is not an error; a failure with nothing behind it is one, and hiding it is what
532
- * cost a whole night — `docs/gui-plugins.md`, *a pill never shows a stale value as fresh*. Stale wins
533
- * over the figure: what is on screen is history, and the mark says so. */
534
- const shown = () => {
535
- const f = figures()
536
- if (f.ready && f.label) {
537
- const mark: Mark = f.wallet === "go" && f.go ? "ring" : "card"
538
- const tone = f.stale ? "warning" : f.walletTone
539
- return { f, text: f.label, tone: tone as Tone | undefined, mark }
540
- }
541
- if (f.error) {
542
- return { f, text: "budget", tone: "danger" as Tone, mark: "error" as Mark }
543
- }
544
- // Nothing connected, and the figure still exists: the plugin is installed and running, so
545
- // vanishing would say it is not. The mark is the wallet's own, the tone absent, so it reads
546
- // as switched off rather than as an alarm.
547
- if (f.ready && f.empty) {
548
- return { f, text: "budget", tone: undefined as Tone | undefined, mark: "card" as Mark }
549
- }
550
- return undefined
551
- }
552
-
553
- const seat = (f: Figures) =>
554
- f.error ??
555
- (f.empty
556
- ? "Budget · no provider connected"
557
- : f.stale
558
- ? `Budget · last read ${ageText(f.age)} ago`
559
- : "Budget — what pays, and every wallet")
560
-
561
- // `shown()` can go falsy and come back while the panel is open — the server switches and returns, a
562
- // snapshot loses the paying wallet's label — and the menu would remount already open, with no gesture
563
- // behind it. Closing with the menu that vanishes is the only state that cannot lie.
564
- createKeyed(
565
- () => (shown() ? true : undefined),
566
- () => {},
567
- { otherwise: () => setOpen(false) },
568
- )
569
-
570
- return (
571
- <Show when={shown()}>
572
- {(s: () => NonNullable<ReturnType<typeof shown>>) => (
573
- <HeaderMenu
574
- label={s().f.stale ? `~${s().text}` : s().text}
575
- hint={seat(s().f)}
576
- tip={Tooltip}
577
- open={open()}
578
- onOpenChange={change}
579
- trigger={
580
- <>
581
- <span class={`ocb-mark ${cls(s().tone)}`}>
582
- <Show
583
- when={s().mark === "ring"}
584
- fallback={
585
- s().mark === "error" ? (
586
- <ctx.ui.Icon name="circle-exclamation" size="small" />
587
- ) : (
588
- <WalletMark />
589
- )
590
- }
591
- >
592
- <Show when={s().f.go}>
593
- {(g: () => NonNullable<Figures["go"]>) => <Ring percent={g().monthly} />}
594
- </Show>
595
- </Show>
596
- </span>
597
- <Show when={!props.input.compact}>
598
- <span class="ocb-head-label">{s().f.stale ? `~${s().text}` : s().text}</span>
599
- </Show>
600
- <Show when={s().f.marked}>
601
- <span class="ocb-head-label ocb-bad" aria-hidden="true">
602
- {PEAK_MARK}
603
- </span>
604
- </Show>
605
- </>
606
- }
607
- >
608
- <Block input={props.input} />
609
- </HeaderMenu>
610
- )}
611
- </Show>
612
- )
613
- }
614
-
615
- ctx.add(Style, CSS)
616
-
617
- // One surface: the figure in the session header, where the app already draws its context ring and the
618
- // session's cost — `docs/gui-plugins.md`, *Where a contribution lands, by layout*. Order 11 puts it after
619
- // the app's figures (10) and before its actions (20), and the tie at 10 would have decided it by luck.
620
- ctx.add(Slot, { at: "session.header", order: 11, render: (input) => <HeaderBudget input={input} /> })
621
- },
622
- })
package/header-menu.tsx DELETED
@@ -1,52 +0,0 @@
1
- // oc-budget/header-menu.tsx — the popover the header figure opens, and the only place this plugin decides how
2
- // a dropdown is built. The pattern is the app's own (`packages/gui-plugins/src/details/popover.tsx`): the open
3
- // state is ours, so a second click on the trigger closes what the first opened. The platform's
4
- // `popover="auto"` cannot do that — it dismisses on any click outside the panel, and the trigger is outside,
5
- // so the click that should close arrives at a button whose panel is already gone and reopens it.
6
- //
7
- // It sits inside this plugin, with one consumer: the second slot that needs a dropdown lifts it out as it is.
8
- // The library is Kobalte, pinned to the version the app itself ships — `docs/oc-budget.md`, *The header
9
- // popover*.
10
- import { Popover } from "@kobalte/core/popover"
11
-
12
- export function HeaderMenu(props: {
13
- /** The accessible name, on the trigger and on the panel. */
14
- readonly label: string
15
- /** The tooltip while the trigger is hovered; the host's component, so the shell stays free of the plugin. */
16
- readonly hint: string
17
- /** The host's Tooltip component, passed in so the shell stays free of the plugin context. */
18
- readonly tip: any
19
- readonly open: boolean
20
- readonly onOpenChange: (open: boolean) => void
21
- /** What the trigger draws; the button around it is the popover's, so press and focus are the host's. */
22
- readonly trigger: JSX.Element
23
- readonly children: JSX.Element
24
- }) {
25
- const Tip = props.tip
26
-
27
- return (
28
- <Popover
29
- open={props.open}
30
- onOpenChange={props.onOpenChange}
31
- placement="bottom-end"
32
- gutter={6}
33
- overflowPadding={12}
34
- >
35
- <Tip value={props.hint} placement="bottom">
36
- <Popover.Trigger
37
- class={`ocb-head ${props.open ? "ocb-head-on" : ""}`}
38
- aria-label={props.label}
39
- aria-haspopup="dialog"
40
- aria-expanded={props.open}
41
- >
42
- {props.trigger}
43
- </Popover.Trigger>
44
- </Tip>
45
- <Popover.Portal>
46
- <Popover.Content class="ocb-pop" role="dialog" aria-label={props.label}>
47
- {props.children}
48
- </Popover.Content>
49
- </Popover.Portal>
50
- </Popover>
51
- )
52
- }