codeburn/windows/src/dockGeometry.ts
ozymandiashh 59ed66b977
feat(dock): one Claude ring per config directory, with its own sessions and today (#1570)
* feat(dock): one Claude ring per config directory, with its own sessions and today (#1523)

Rate limits are per account, so a machine with a work and a personal
~/.claude cannot be served by one Claude ring. When the CLI knows two or
more Claude config directories:

- `codeburn quota --format json` adds a top-level `claudeProfiles` array,
  one entry per directory (`providers[]` unchanged). Each entry carries
  the directory's own windows, read from its own `<dir>/.credentials.json`
  (the macOS Keychain fallback stays reserved for the default ~/.claude),
  plus that directory's own today totals from a single Claude-only,
  cache-backed parse of today's range, filtered per source id exactly like
  the menubar's per-config payloads.
- Live sessions carry `claudeConfigSourceId`, so a per-profile ring claims
  only its own sessions. Claude Desktop sessions keep their own bucket id
  and belong to no profile.
- The Windows Capacity Dock gains a `claudeProfiles` preference (combined,
  default; separate draws one captioned ring per directory). Profile rings
  keep addressing the `claude` provider id for the provider set, glyphs,
  colors and the connect button; the settings row appears only when the
  CLI reports more than one profile.
- Captioned rows are taller by two new metrics (caption line + gap),
  mirrored in TS and Rust: the page tells Rust the rail is captioned in
  the layout request, and the frame carries the effective row extent the
  hit-test uses. Non-captioned rails are pixel-identical to before.
- The desktop app's shared dock-prefs schema parses and sanitizes the new
  key, so its pane cannot strip it from windows-dock.json.

Closes #1523

* review: opt-in profile reads, today timeout, missing-dir filter (#1570)

- `quota --claude-profiles` gates the per-directory reads: without it the
  payload lists profiles ({id,label,path}) only, so a combined rail and
  WSL users pay nothing; the Windows dock passes the flag only while its
  claudeProfiles preference is separate, and an unknown option from an
  older CLI reads as CliOutdated.
- The default ~/.claude profile reuses the claude provider row's result —
  no second request, no second macOS Keychain read.
- claudeTodayBySource races the same timeoutMs and answers an empty map
  on loss, so a silent child cannot cost the dock every provider's quota.
- Missing config directories are filtered before the >1 check.
- `captioned` applies only while a Claude ring is in the selected set;
  profile rows tolerate a list-mode answer; comments trimmed; merge with
  #1560 keeps both keepExpanded and claudeProfiles.

* test(quota): set USERPROFILE for the default-dir check; drop issue refs

---------

Co-authored-by: Resham Joshi <65915470+iamtoruk@users.noreply.github.com>
2026-09-28 19:06:53 +02:00

478 lines
17 KiB
TypeScript

/// Pure geometry and presentation rules for the Capacity Dock, ported from
/// mac/Sources/CodeBurnMenubar/Views/CapacityDockView.swift, CapacityDockMotion.swift and
/// Data/QuotaSummary.swift. Every metric is a mac base value times the settings window's size
/// scale, rounded to a whole pixel the way CapacityDockMetrics.points does;
/// src-tauri/src/dock.rs rebuilds the same numbers from the same scale and the two must stay
/// in step.
import type { Severity } from './lib/quota'
/// CapacityDockPreferences.scaleRange and its default.
export const MIN_SCALE = 0.6
export const MAX_SCALE = 1.2
export const DEFAULT_SCALE = MIN_SCALE
/// The bubble never shrinks with the rail: below 90% its type stops being readable.
const MIN_DETAIL_SCALE = 0.9
/// Fractional sizes cost the mac 5 to 7 percent idle CPU by making the hosting view re-lay
/// itself out forever, so every metric lands on a whole pixel.
function points(base: number, scale: number): number {
return Math.max(1, Math.round(base * scale))
}
export type Metrics = {
scale: number
detailScale: number
railWidth: number
horizontalRailWidth: number
rowHeight: number
rowSpacing: number
/// A profile row's caption line box and gap, added to every row's height when the rail
/// draws one ring per Claude config directory. Mirrored in src-tauri dock.rs.
captionLine: number
captionGap: number
railAlongPad: number
flareCompensation: number
railCrossPad: number
shoulderDepth: number
ringSize: number
ringStroke: number
ringMargin: number
ringLabelSpacing: number
providerIconSize: number
percentTextSize: number
captionTextSize: number
alertSize: number
alertOffset: number
detailWidth: number
detailGap: number
detailGlyphSize: number
rowReveal: number
}
function build(scale: number): Metrics {
const detailScale = Math.max(scale, MIN_DETAIL_SCALE)
return {
scale,
detailScale,
railWidth: points(88, scale),
horizontalRailWidth: points(106, scale),
rowHeight: points(84, scale),
rowSpacing: points(12, scale),
captionLine: points(13, scale),
captionGap: points(4, scale),
railAlongPad: points(20, scale),
// Docked rails add 60% of the shoulder depth so content never crowds the concave flare.
flareCompensation: Math.round(points(52, scale) * 0.6),
railCrossPad: points(12, scale),
shoulderDepth: points(52, scale),
ringSize: points(52, scale),
ringStroke: points(4, scale),
// SVG strokes are centred on the path, so the ring box carries a margin for half of one.
ringMargin: points(5, scale),
ringLabelSpacing: points(6, scale),
providerIconSize: points(26, scale),
percentTextSize: points(17, scale),
captionTextSize: points(11, scale),
alertSize: points(12, scale),
alertOffset: points(19, scale),
detailWidth: points(350, detailScale),
// The gap between rail and bubble is a placement constant on the mac, not a scaled metric.
detailGap: 10,
// The glance header's mark matches the 17pt title's cap height plus a little, so it reads
// as the title's equal rather than as a bullet.
detailGlyphSize: points(18, detailScale),
rowReveal: -8 * scale,
}
}
const CACHE = new Map<number, Metrics>()
/// Metrics for a size scale. Cached because the dock passes them through effect dependencies,
/// where a fresh object on every render would restart the layout round trip forever.
export function metrics(scale: number): Metrics {
const value = Number.isFinite(scale) ? scale : DEFAULT_SCALE
const key = Math.round(Math.min(MAX_SCALE, Math.max(MIN_SCALE, value)) * 100) / 100
let found = CACHE.get(key)
if (!found) {
found = build(key)
CACHE.set(key, found)
}
return found
}
/// The glance bubble's block heights, ported from the CapacityDockGlance enum. The mac sums
/// them into a panel frame it computes, because its hosting view does not size itself; here
/// the page measures the bubble it has drawn and Rust lays the window out around it, so the
/// same numbers are set as explicit heights and the measured total is their sum. Every one is
/// a whole pixel at any detail scale, which is what keeps the window metrics whole.
export type GlanceMetrics = {
/// The bubble's padding on all four sides, applied by each block rather than by the bubble
/// so a block's own fill can still run edge to edge.
inset: number
tail: number
headerTitle: number
headerPadBottom: number
sectionPadTop: number
sectionPadBottom: number
/// A 10.5pt caption's line box, shared by every section header.
captionLine: number
headGap: number
lineGap: number
blockGap: number
/// 8 padding + a 12pt line + 2 + a 10.5pt line + 8 padding.
pillHeight: number
pillGap: number
pillRadius: number
pillPadX: number
pillTitleLine: number
pillSubLine: number
/// The tint's fade tail, so a short band fades over its whole length instead of inverting.
pillFade: number
/// Three stacked lines, 13 + 13 + 12, with two 3pt gaps. Taller than the 17pt burned figure
/// beside it, so it sets the row.
todayContent: number
todayLine: number
todayCallsLine: number
todayLineGap: number
todayCostGap: number
windowPercent: number
windowReset: number
windowGap: number
connectHeight: number
/// A 10pt refreshing/retrying line, an 11pt state title, a 10pt guidance line, and the
/// mac's fixed allowance for the whole reconnect card.
connLoading: number
connTitle: number
connLine: number
connTerminal: number
}
/// The list scrolls past this many pills rather than truncating.
export const MAX_VISIBLE_SESSION_ROWS = 4
export const MAX_WINDOW_COLUMNS = 4
function buildGlance(detailScale: number): GlanceMetrics {
const p = (base: number) => points(base, detailScale)
return {
inset: p(16),
tail: p(18),
headerTitle: p(20),
headerPadBottom: p(8),
sectionPadTop: p(8),
sectionPadBottom: p(10),
captionLine: p(13),
headGap: p(8),
lineGap: p(2),
blockGap: p(3),
pillHeight: p(46),
pillGap: p(6),
pillRadius: p(8),
pillPadX: p(12),
pillTitleLine: p(15),
pillSubLine: p(13),
pillFade: p(12),
todayContent: p(44),
todayLine: p(13),
todayCallsLine: p(12),
todayLineGap: p(3),
todayCostGap: p(5),
windowPercent: p(24),
windowReset: p(12),
windowGap: p(2),
connectHeight: p(22),
connLoading: p(16),
connTitle: p(18),
connLine: p(13),
connTerminal: p(90),
}
}
const GLANCE_CACHE = new Map<number, GlanceMetrics>()
export function glanceMetrics(detailScale: number): GlanceMetrics {
const key = Math.round(detailScale * 100) / 100
let found = GLANCE_CACHE.get(key)
if (!found) {
found = buildGlance(key)
GLANCE_CACHE.set(key, found)
}
return found
}
/// Height of the scrolling pill list: every pill up to the visible cap, then it scrolls.
export function sessionListHeight(g: GlanceMetrics, count: number): number {
const visible = Math.min(count, MAX_VISIBLE_SESSION_ROWS)
if (visible <= 0) return 0
return visible * g.pillHeight + (visible - 1) * g.pillGap
}
/// Four bands, matching the rail's own sense of escalation: comfortable, watch it, nearly
/// out, over. The glance ramps later than a rail row does, so a healthy row of window
/// percentages reads as text rather than as a wall of colour.
export function glanceSeverity(fraction: number): Severity {
if (fraction >= 0.9) return 'danger'
if (fraction >= 0.8) return 'critical'
if (fraction >= 0.7) return 'warning'
return 'normal'
}
/// Anything outside 0...1 is a bad reading, not a reason to paint past the box.
export function clampFraction(fraction: number): number {
return Math.min(Math.max(fraction, 0), 1)
}
/// How far the reveal edge leans, as a share of the text's height.
export const GAUGE_SLANT_RATIO = 0.35
/// The slanted wipe over a percentage's glyphs (PercentGaugeReveal). The mac solves the edge
/// in points against a measured text box; here the box is the element, so the same edge is
/// written as a share of its width plus the fixed slant in pixels. Both ends are clamped into
/// the box, which turns the parallelogram into a triangle at the two extremes rather than
/// letting it fold over itself.
export function gaugeClipPath(fraction: number, lineHeight: number): string {
const f = clampFraction(fraction)
const slant = lineHeight * GAUGE_SLANT_RATIO
const edge = (offset: number) =>
`max(0px, min(100%, calc(${(f * 100).toFixed(3)}% + ${offset.toFixed(2)}px)))`
return `polygon(0 0, ${edge(slant * f - slant)} 0, ${edge(slant * f)} 100%, 0 100%)`
}
export type Edge = 'left' | 'right' | 'top' | 'bottom'
export type GaugeShape = 'circle' | 'squircle'
export function isVertical(edge: Edge): boolean {
return edge === 'left' || edge === 'right'
}
export function opposite(edge: Edge): Edge {
switch (edge) {
case 'left':
return 'right'
case 'right':
return 'left'
case 'top':
return 'bottom'
case 'bottom':
return 'top'
}
}
function smoothstep(p: number): number {
const t = Math.min(Math.max(p, 0), 1)
return t * t * (3 - 2 * t)
}
/// Along-axis padding for a given attachment progress, the mac's railAlongPad.
export function alongPad(m: Metrics, attachment: number): number {
return m.railAlongPad + m.flareCompensation * smoothstep(attachment)
}
export function railLength(m: Metrics, rows: number, attachment: number): number {
const count = Math.max(rows, 1)
return alongPad(m, attachment) * 2 + count * m.rowHeight + (count - 1) * m.rowSpacing
}
/// Motion: durations in ms and cubic-bezier control points, verbatim from CapacityDockMotion.
export const MOTION = {
railExpand: { duration: 520, curve: [0.22, 1, 0.36, 1] as const },
railCollapse: { duration: 440, curve: [0.32, 0, 0.2, 1] as const },
dockAttach: { duration: 280, curve: [0.16, 1, 0.3, 1] as const },
dockDetach: { duration: 240, curve: [0.2, 0.8, 0.2, 1] as const },
detailPresent: { duration: 200, curve: [0.16, 1, 0.3, 1] as const },
detailFollow: { duration: 220, curve: [0.25, 0.1, 0.25, 1] as const },
detailDismiss: { duration: 140, curve: [0.4, 0, 1, 1] as const },
railHoverOpenDelay: 80,
railHoverCloseDelay: 180,
detailShowDelay: 180,
detailExitDelay: 240,
detailAppearOffset: 10,
dragThreshold: 3,
} as const
export type Curve = readonly [number, number, number, number]
/// Cubic bezier easing solved by Newton iteration, matching CapacityDockMotion.cubicBezier.
export function bezier(curve: Curve, x: number): number {
const [p1x, p1y, p2x, p2y] = curve
const sampleX = (t: number) => ((1 - 3 * p2x + 3 * p1x) * t + (3 * p2x - 6 * p1x)) * t * t + 3 * p1x * t
const sampleY = (t: number) => ((1 - 3 * p2y + 3 * p1y) * t + (3 * p2y - 6 * p1y)) * t * t + 3 * p1y * t
const sampleDx = (t: number) => (3 * (1 - 3 * p2x + 3 * p1x) * t + 2 * (3 * p2x - 6 * p1x)) * t + 3 * p1x
if (x <= 0) return 0
if (x >= 1) return 1
let t = x
for (let i = 0; i < 8; i += 1) {
const dx = sampleX(t) - x
if (Math.abs(dx) < 1e-5) break
const d = sampleDx(t)
if (Math.abs(d) < 1e-6) break
t -= dx / d
}
return sampleY(Math.min(1, Math.max(0, t)))
}
// The quota vocabulary the dock shares with the popover now lives in lib/quota, beside the
// store that fetches it. Re-exported so the dock still has one import for a row's geometry.
export {
displayLabel,
headlineWindow,
pct,
resetsIn,
severity,
type QuotaWindow,
type Severity,
} from './lib/quota'
/// Shapes are drawn once for a right-edge rail in a canonical box (cross by along) and mapped
/// to the other edges with the same affine transforms the mac applies. `cross` is the
/// canonical width.
type Pt = readonly [number, number]
function mapper(edge: Edge, cross: number): (p: Pt) => Pt {
switch (edge) {
case 'right':
return (p) => p
case 'left':
return ([x, y]) => [cross - x, y]
case 'bottom':
return ([x, y]) => [y, x]
case 'top':
return ([x, y]) => [y, cross - x]
}
}
class PathBuilder {
private parts: string[] = []
constructor(private map: (p: Pt) => Pt) {}
private f(p: Pt): string {
const [x, y] = this.map(p)
return `${x.toFixed(2)} ${y.toFixed(2)}`
}
move(p: Pt) {
this.parts.push(`M ${this.f(p)}`)
return this
}
line(p: Pt) {
this.parts.push(`L ${this.f(p)}`)
return this
}
quad(control: Pt, to: Pt) {
this.parts.push(`Q ${this.f(control)} ${this.f(to)}`)
return this
}
cubic(c1: Pt, c2: Pt, to: Pt) {
this.parts.push(`C ${this.f(c1)} ${this.f(c2)} ${this.f(to)}`)
return this
}
close(): string {
this.parts.push('Z')
return this.parts.join(' ')
}
}
function roundedRect(b: PathBuilder, w: number, h: number, r: number): string {
return b
.move([r, 0])
.line([w - r, 0])
.quad([w, 0], [w, r])
.line([w, h - r])
.quad([w, h], [w - r, h])
.line([r, h])
.quad([0, h], [0, h - r])
.line([0, r])
.quad([0, 0], [r, 0])
.close()
}
/// The gauge outline (CapacityDockGaugePath), drawn inside `box` at `size` across. Both
/// shapes start at twelve o'clock and run clockwise, so a usage arc trimmed from the start
/// fills the way the mac's does after its -90 degree rotation.
export function gaugePath(kind: GaugeShape, box: number, size: number): string {
const min = (box - size) / 2
const max = min + size
const mid = box / 2
const r = size / 2
if (kind === 'circle') {
return `M ${mid} ${min} A ${r} ${r} 0 1 1 ${mid} ${max} A ${r} ${r} 0 1 1 ${mid} ${min} Z`
}
// The mac's continuous rounded rectangle at a 30 percent corner radius. A continuous corner
// reaches about one and a half radii along each edge and is drawn as one cubic whose control
// points sit a further 55 percent of that reach toward the corner; a circular quarter-arc
// would kink where it meets the straight edge, which is the whole difference from a circle.
const reach = Math.min(size * 0.3 * 1.5, size / 2)
const pull = reach * 0.45
const f = (n: number) => n.toFixed(2)
return [
`M ${f(mid)} ${f(min)}`,
`L ${f(max - reach)} ${f(min)}`,
`C ${f(max - pull)} ${f(min)} ${f(max)} ${f(min + pull)} ${f(max)} ${f(min + reach)}`,
`L ${f(max)} ${f(max - reach)}`,
`C ${f(max)} ${f(max - pull)} ${f(max - pull)} ${f(max)} ${f(max - reach)} ${f(max)}`,
`L ${f(min + reach)} ${f(max)}`,
`C ${f(min + pull)} ${f(max)} ${f(min)} ${f(max - pull)} ${f(min)} ${f(max - reach)}`,
`L ${f(min)} ${f(min + reach)}`,
`C ${f(min)} ${f(min + pull)} ${f(min + pull)} ${f(min)} ${f(min + reach)} ${f(min)}`,
'Z',
].join(' ')
}
/// The rail outline (CapacityDockRailShape): a plain rounded pill while loose, and once more
/// than half attached, convex corners on the free side with concave shoulders necking into the
/// flush contact edge, the system-notch technique. `len` runs along the edge.
export function railPath(m: Metrics, edge: Edge, cross: number, len: number, attachment: number): string {
const eased = smoothstep(attachment)
const b = new PathBuilder(mapper(edge, cross))
const freeR = Math.min(22, len / 2, cross * 0.45)
if (eased < 0.5) return roundedRect(b, cross, len, freeR)
const contactR = Math.min(m.shoulderDepth * 0.6, len * 0.22, Math.max(0, len / 2 - freeR)) * eased
return b
.move([cross, 0])
.quad([cross, contactR], [cross - contactR, contactR])
.line([freeR, contactR])
.quad([0, contactR], [0, contactR + freeR])
.line([0, len - contactR - freeR])
.quad([0, len - contactR], [freeR, len - contactR])
.line([cross - contactR, len - contactR])
.quad([cross, len - contactR], [cross, len])
.close()
}
/// Detail bubble (CapacityDockBubbleShape) with its tail on `tailEdge`, pointing at `tail`
/// measured along that edge. `w` and `h` are the bubble's final box.
export function bubblePath(tailEdge: Edge, w: number, h: number, tail: number): string {
const vertical = isVertical(tailEdge)
const cross = vertical ? w : h
const along = vertical ? h : w
const b = new PathBuilder(mapper(tailEdge, cross))
const tailWidth = Math.min(22, Math.max(14, cross * 0.055))
const bodyRight = cross - tailWidth
const radius = Math.min(20, along * 0.18)
const midY = along * Math.min(Math.max(tail / Math.max(along, 1), 0.18), 0.82)
const neck = Math.min(32, along * 0.19)
return b
.move([radius, 0])
.line([bodyRight - radius, 0])
.quad([bodyRight, 0], [bodyRight, radius])
.line([bodyRight, midY - neck])
.cubic([bodyRight, midY - neck * 0.55], [cross, midY - tailWidth * 0.42], [cross, midY])
.cubic([cross, midY + tailWidth * 0.42], [bodyRight, midY + neck * 0.55], [bodyRight, midY + neck])
.line([bodyRight, along - radius])
.quad([bodyRight, along], [bodyRight - radius, along])
.line([radius, along])
.quad([0, along], [0, along - radius])
.line([0, radius])
.quad([0, 0], [radius, 0])
.close()
}
/// Bubble content insets. Every glance block carries its own padding, so the bubble adds only
/// the tail's allowance on whichever side the tail points (the mac's `detailInsets`).
export function detailPadding(m: Metrics, tailEdge: Edge): string {
const t = glanceMetrics(m.detailScale).tail
const top = tailEdge === 'top' ? t : 0
const right = tailEdge === 'right' ? t : 0
const bottom = tailEdge === 'bottom' ? t : 0
const left = tailEdge === 'left' ? t : 0
return `${top}px ${right}px ${bottom}px ${left}px`
}