shadok-ai 0.1.209 → 0.1.210
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/package.json +1 -1
- package/public/gauge-dial.js +170 -0
- package/public/index.html +111 -83
package/package.json
CHANGED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
// The 240° quota dial: its CENTRE is the ideal pace and its RIGHT END is
|
|
2
|
+
// exhaustion — see docs/superpowers/specs/2026-07-30-quota-dial-design.md.
|
|
3
|
+
//
|
|
4
|
+
// Loaded as-is by the browser (ESM) and imported by the node/tsx tests, like
|
|
5
|
+
// public/live-text.js, public/notify.js and public/profile-card.js. This module
|
|
6
|
+
// knows nothing about the DOM: it takes numbers and returns numbers, colour
|
|
7
|
+
// strings and SVG path data.
|
|
8
|
+
|
|
9
|
+
/** Half-sweep, in degrees. 0° is straight up and means "exactly on pace". */
|
|
10
|
+
export const SWEEP_DEG = 120;
|
|
11
|
+
|
|
12
|
+
/** The viewBox the SVG markup in index.html is drawn against. */
|
|
13
|
+
export const DIAL = { cx: 28, cy: 25, r: 17, w: 56, h: 44 };
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The ideal pace is clamped before it becomes the dial's midpoint, because both
|
|
17
|
+
* ends of a window degenerate otherwise: just after a reset the pace is ~0 and
|
|
18
|
+
* the left half collapses to a point (the first token spent would slam the
|
|
19
|
+
* needle to the centre); at the very end of a window it is ~100 and the right
|
|
20
|
+
* half collapses (any overage would read as "exhausted"). Away from the bands
|
|
21
|
+
* the clamp costs a sub-degree of accuracy, where accuracy matters — but
|
|
22
|
+
* *inside* the bands (pace < 2 or > 98) the error is not sub-degree at all: it
|
|
23
|
+
* can reach 60° (see `dialTitle`'s unclamped `side`, and the two tests that
|
|
24
|
+
* cover it), because the needle is reading a clamped pace it does not have.
|
|
25
|
+
* The trade is still the right one there: the pace is too young or too old to
|
|
26
|
+
* mean much, and the alternative is a needle pinned uselessly at an extreme.
|
|
27
|
+
*
|
|
28
|
+
* `PACE_MIN` also has to stay ≤ the pace guardrail's own `PACE_EPSILON`
|
|
29
|
+
* (`src/pace.ts`, currently both 2) for a property the dial depends on: the
|
|
30
|
+
* guard blocks a prompt at `used > pace + PACE_EPSILON`, and the dial goes
|
|
31
|
+
* right-of-centre at `used > clamp(pace, PACE_MIN, PACE_MAX)`. With
|
|
32
|
+
* `PACE_MIN <= PACE_EPSILON` the dial crosses centre at or before the guard
|
|
33
|
+
* blocks, so the gauge always warns before a send is refused, never after. Do
|
|
34
|
+
* not couple the two constants across files for this — just keep it in mind
|
|
35
|
+
* when either one changes, since nothing enforces it in code.
|
|
36
|
+
*/
|
|
37
|
+
const PACE_MIN = 2;
|
|
38
|
+
const PACE_MAX = 98;
|
|
39
|
+
|
|
40
|
+
const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
|
|
41
|
+
/** Two decimals is plenty for a 56 px viewBox, and keeps the path data short. */
|
|
42
|
+
const round2 = (v) => Math.round(v * 100) / 100;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Position on the dial: -1 nothing consumed, 0 exactly on the ideal pace, +1
|
|
46
|
+
* quota exhausted.
|
|
47
|
+
*
|
|
48
|
+
* The two halves have DIFFERENT scales, deliberately: the pace advances with
|
|
49
|
+
* the clock, so at 25 % into a window the left half maps used 0→25 % and the
|
|
50
|
+
* right half maps used 25→100 %. That is what lets one axis answer both "am I
|
|
51
|
+
* on pace" (the centre) and "is it gone" (the right end).
|
|
52
|
+
*
|
|
53
|
+
* `pace` is `idealPacePct` from /usage, NOT `ratioPct`: the latter carries
|
|
54
|
+
* PACE_EPSILON in its denominator to keep the guardrail from tripping on the
|
|
55
|
+
* first message after a reset, and baking that fudge in here would offset the
|
|
56
|
+
* needle from the very tick it is supposed to line up with.
|
|
57
|
+
*/
|
|
58
|
+
export function dialPos(used, pace) {
|
|
59
|
+
const u = clamp(used, 0, 100);
|
|
60
|
+
// No reset time → no pace → we still know the consumption honestly, so fall
|
|
61
|
+
// back to a plain linear 0→100 dial. The caller hides the pace tick.
|
|
62
|
+
if (pace == null) return u / 50 - 1;
|
|
63
|
+
const p = clamp(pace, PACE_MIN, PACE_MAX);
|
|
64
|
+
return clamp(u <= p ? u / p - 1 : (u - p) / (100 - p), -1, 1);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Needle angle in degrees, from straight up, clockwise positive. */
|
|
68
|
+
export function dialAngle(used, pace) {
|
|
69
|
+
return dialPos(used, pace) * SWEEP_DEG;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Colour for a dial position. Amber at the centre, green towards "under pace",
|
|
74
|
+
* red towards "exhausted" — the same oklab mix the divergent bar used, but keyed
|
|
75
|
+
* on the dial position so the needle's tint and the arc under it can never
|
|
76
|
+
* disagree.
|
|
77
|
+
*/
|
|
78
|
+
export function dialColor(pos) {
|
|
79
|
+
const t = Math.round(clamp(pos, -1, 1) * 100);
|
|
80
|
+
return t <= 0
|
|
81
|
+
? `color-mix(in oklab, var(--amber), var(--ok) ${-t}%)`
|
|
82
|
+
: `color-mix(in oklab, var(--amber), var(--err) ${t}%)`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** A point on the dial's rim, in viewBox units. `deg` is measured like dialAngle. */
|
|
86
|
+
export function dialPoint(deg) {
|
|
87
|
+
const a = (deg * Math.PI) / 180;
|
|
88
|
+
return {
|
|
89
|
+
x: round2(DIAL.cx + DIAL.r * Math.sin(a)),
|
|
90
|
+
y: round2(DIAL.cy - DIAL.r * Math.cos(a)),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Angular gap trimmed from each internal end of a segment, so the arc reads as
|
|
96
|
+
* 24 evenly spaced graduation marks — a deliberate instrument scale, not a
|
|
97
|
+
* smooth band. Trimmed only between segments: the sweep still spans the full
|
|
98
|
+
* ±SWEEP_DEG, so the outer marks line up with the needle at its extremes.
|
|
99
|
+
*/
|
|
100
|
+
const GAP_DEG = 0.8;
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The gradient arc, as `n` solid segments.
|
|
104
|
+
*
|
|
105
|
+
* An SVG gradient cannot follow a path, and a plain horizontal one is
|
|
106
|
+
* degenerate here: below ±90° the arc curls back inward, so sin(-120°) equals
|
|
107
|
+
* sin(-60°) and the outer 30° of each side would be painted with the colour of
|
|
108
|
+
* a completely different reading. Solid segments give a true along-the-sweep
|
|
109
|
+
* gradient, and the gap between them is the point: 24 flat-coloured marks read
|
|
110
|
+
* as an instrument scale, not as one smooth band pretending to be a gradient
|
|
111
|
+
* it structurally cannot render. They are built once and never repainted, so
|
|
112
|
+
* the extra nodes are free.
|
|
113
|
+
*/
|
|
114
|
+
export function arcSegments(n = 24) {
|
|
115
|
+
const step = (2 * SWEEP_DEG) / n;
|
|
116
|
+
const out = [];
|
|
117
|
+
for (let i = 0; i < n; i++) {
|
|
118
|
+
const a = -SWEEP_DEG + i * step + (i === 0 ? 0 : GAP_DEG / 2);
|
|
119
|
+
const b = -SWEEP_DEG + (i + 1) * step - (i === n - 1 ? 0 : GAP_DEG / 2);
|
|
120
|
+
const p1 = dialPoint(a);
|
|
121
|
+
const p2 = dialPoint(b);
|
|
122
|
+
out.push({
|
|
123
|
+
// large-arc-flag 0: every segment is well under 180°. sweep-flag 1:
|
|
124
|
+
// clockwise on screen, i.e. towards increasing consumption.
|
|
125
|
+
d: `M ${p1.x} ${p1.y} A ${DIAL.r} ${DIAL.r} 0 0 1 ${p2.x} ${p2.y}`,
|
|
126
|
+
color: dialColor(-1 + (2 * i + 1) / n),
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
return out;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* The multi-line tooltip, reused verbatim as the SVG's aria-label. The third
|
|
134
|
+
* line is what makes the piecewise scale self-explanatory: it states whether
|
|
135
|
+
* consumption is ahead of, behind, or on the ideal pace.
|
|
136
|
+
*
|
|
137
|
+
* `resetText` comes from the caller's fmtReset and is "" when resetsAt is null —
|
|
138
|
+
* it is filtered out rather than joined, otherwise the tooltip ends on a blank
|
|
139
|
+
* line.
|
|
140
|
+
*/
|
|
141
|
+
export function dialTitle(label, w, resetText) {
|
|
142
|
+
const head = `${label} rolling limit`;
|
|
143
|
+
if (!w) return `${head}\nno data`;
|
|
144
|
+
const r = Math.round;
|
|
145
|
+
const used = `${r(w.usedPercentage)}% consumed`;
|
|
146
|
+
if (w.idealPacePct == null) {
|
|
147
|
+
return [head, used, "No reset time known, so there is no ideal pace to compare against.", resetText]
|
|
148
|
+
.filter(Boolean)
|
|
149
|
+
.join("\n");
|
|
150
|
+
}
|
|
151
|
+
// Unclamped on purpose: dialPos clamps the pace to keep the NEEDLE readable at
|
|
152
|
+
// the edges of a window, but the sentence must state what is actually happening.
|
|
153
|
+
// Inside the clamp bands the two can disagree — a true sentence beside a clamped
|
|
154
|
+
// needle beats a false one.
|
|
155
|
+
const off = w.usedPercentage - w.idealPacePct;
|
|
156
|
+
const side =
|
|
157
|
+
off < -0.5
|
|
158
|
+
? "Consuming slower than the clock."
|
|
159
|
+
: off > 0.5
|
|
160
|
+
? "Burning faster than the clock."
|
|
161
|
+
: "Exactly on the ideal pace.";
|
|
162
|
+
return [
|
|
163
|
+
head,
|
|
164
|
+
`${used}, ideal pace ${r(w.idealPacePct)}%, ${r(w.ratioPct)}% of pace`,
|
|
165
|
+
side,
|
|
166
|
+
resetText,
|
|
167
|
+
]
|
|
168
|
+
.filter(Boolean)
|
|
169
|
+
.join("\n");
|
|
170
|
+
}
|
package/public/index.html
CHANGED
|
@@ -157,47 +157,48 @@
|
|
|
157
157
|
@keyframes breathe { 50% { opacity: 0.35; } }
|
|
158
158
|
@media (prefers-reduced-motion: reduce) { .led.busy { animation: none; } }
|
|
159
159
|
header .spacer { flex: 1; }
|
|
160
|
-
/* Quota
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
.
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
.
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
background: var(--ok);
|
|
194
|
-
transition: left 0.4s ease, width 0.4s ease, background-color 0.4s ease;
|
|
160
|
+
/* Quota dials (5h / 7d subscription usage).
|
|
161
|
+
A 240° speedometer whose CENTRE is the ideal pace and whose RIGHT END is
|
|
162
|
+
exhaustion — see docs/superpowers/specs/2026-07-30-quota-dial-design.md.
|
|
163
|
+
Position AND colour carry the reading, never colour alone. */
|
|
164
|
+
.quota { min-width: 56px; }
|
|
165
|
+
.dial { display: block; width: 56px; height: 44px; }
|
|
166
|
+
/* opacity on the GROUP, not each path: 24 overlapping-alpha strokes would
|
|
167
|
+
composite twice at every join and brighten the seams instead of hiding
|
|
168
|
+
them. One group opacity leaves the marks flat and evenly graduated. */
|
|
169
|
+
.dial .arc { opacity: 0.6; }
|
|
170
|
+
.dial .arc path { fill: none; stroke-width: 4; }
|
|
171
|
+
/* No data: the scale stays, as a ghost, so the widget does not vanish. */
|
|
172
|
+
.dial.nodata .arc { opacity: 0.18; }
|
|
173
|
+
/* The "on pace" reference, just outside the arc. The needle's tip meets it
|
|
174
|
+
exactly when the consumption is on pace — they read as one line. */
|
|
175
|
+
.dial .tick { stroke: var(--text); stroke-width: 1.5; opacity: 0.75; }
|
|
176
|
+
.dial .needle {
|
|
177
|
+
stroke: var(--text);
|
|
178
|
+
stroke-width: 2;
|
|
179
|
+
stroke-linecap: round;
|
|
180
|
+
/* Rotated through the CSS transform PROPERTY, not the SVG attribute: only
|
|
181
|
+
the property transitions reliably. transform-box/-origin put the pivot on
|
|
182
|
+
the hub in viewBox units. */
|
|
183
|
+
transform-box: view-box;
|
|
184
|
+
transform-origin: 28px 25px;
|
|
185
|
+
transition: transform 0.4s ease;
|
|
186
|
+
}
|
|
187
|
+
.dial .pct {
|
|
188
|
+
font-family: var(--mono);
|
|
189
|
+
font-size: 12px;
|
|
190
|
+
font-weight: 600;
|
|
191
|
+
fill: var(--text);
|
|
192
|
+
transition: fill 0.4s ease;
|
|
195
193
|
}
|
|
196
|
-
.
|
|
194
|
+
.dial .unit {
|
|
197
195
|
font-family: var(--mono);
|
|
198
|
-
font-size:
|
|
199
|
-
|
|
200
|
-
|
|
196
|
+
font-size: 8px;
|
|
197
|
+
fill: var(--text-dim);
|
|
198
|
+
letter-spacing: 0.08em;
|
|
199
|
+
}
|
|
200
|
+
@media (prefers-reduced-motion: reduce) {
|
|
201
|
+
.dial .needle, .dial .pct { transition: none; }
|
|
201
202
|
}
|
|
202
203
|
button {
|
|
203
204
|
font-family: var(--mono);
|
|
@@ -1091,15 +1092,23 @@
|
|
|
1091
1092
|
<header>
|
|
1092
1093
|
<span class="brand">shadok-ai <span class="ver" id="verView" title="running version"></span></span>
|
|
1093
1094
|
<span class="spacer"></span>
|
|
1094
|
-
<div class="gauge quota" id="quota5h" title="
|
|
1095
|
-
<
|
|
1096
|
-
|
|
1097
|
-
|
|
1095
|
+
<div class="gauge quota" id="quota5h" data-window="5h" title="5h rolling limit">
|
|
1096
|
+
<svg class="dial" viewBox="0 0 56 44" role="img" aria-label="5h rolling limit">
|
|
1097
|
+
<g class="arc"></g>
|
|
1098
|
+
<line class="tick" x1="28" y1="5" x2="28" y2="2" visibility="hidden"></line>
|
|
1099
|
+
<line class="needle" x1="28" y1="13" x2="28" y2="5" visibility="hidden"></line>
|
|
1100
|
+
<text class="pct" x="28" y="28" text-anchor="middle">—</text>
|
|
1101
|
+
<text class="unit" x="28" y="41" text-anchor="middle">5h</text>
|
|
1102
|
+
</svg>
|
|
1098
1103
|
</div>
|
|
1099
|
-
<div class="gauge quota" id="quota7d" title="
|
|
1100
|
-
<
|
|
1101
|
-
|
|
1102
|
-
|
|
1104
|
+
<div class="gauge quota" id="quota7d" data-window="7d" title="7d rolling limit">
|
|
1105
|
+
<svg class="dial" viewBox="0 0 56 44" role="img" aria-label="7d rolling limit">
|
|
1106
|
+
<g class="arc"></g>
|
|
1107
|
+
<line class="tick" x1="28" y1="5" x2="28" y2="2" visibility="hidden"></line>
|
|
1108
|
+
<line class="needle" x1="28" y1="13" x2="28" y2="5" visibility="hidden"></line>
|
|
1109
|
+
<text class="pct" x="28" y="28" text-anchor="middle">—</text>
|
|
1110
|
+
<text class="unit" x="28" y="41" text-anchor="middle">7d</text>
|
|
1111
|
+
</svg>
|
|
1103
1112
|
</div>
|
|
1104
1113
|
<button id="secretsBtn" class="icon" title="Repo secrets (injected as env)">🔑</button>
|
|
1105
1114
|
<button id="profilesBtn" class="icon" title="Agent profiles (role + guardrails + secrets)">👤</button>
|
|
@@ -1405,11 +1414,17 @@
|
|
|
1405
1414
|
import { extractLiveText } from "/live-text.js";
|
|
1406
1415
|
import { profileBlurb, profileBadges } from "/profile-card.js";
|
|
1407
1416
|
import { notifyState, BLINK_MS } from "/notify.js";
|
|
1417
|
+
import { dialPos, dialColor, arcSegments, dialTitle, SWEEP_DEG } from "/gauge-dial.js";
|
|
1408
1418
|
window.extractLiveText = extractLiveText;
|
|
1409
1419
|
window.profileBlurb = profileBlurb;
|
|
1410
1420
|
window.profileBadges = profileBadges;
|
|
1411
1421
|
window.notifyState = notifyState;
|
|
1412
1422
|
window.BLINK_MS = BLINK_MS;
|
|
1423
|
+
window.dialPos = dialPos;
|
|
1424
|
+
window.dialColor = dialColor;
|
|
1425
|
+
window.arcSegments = arcSegments;
|
|
1426
|
+
window.dialTitle = dialTitle;
|
|
1427
|
+
window.SWEEP_DEG = SWEEP_DEG;
|
|
1413
1428
|
</script>
|
|
1414
1429
|
<script nonce="__CSP_NONCE__">
|
|
1415
1430
|
const $ = (id) => document.getElementById(id);
|
|
@@ -4202,47 +4217,55 @@
|
|
|
4202
4217
|
/** Last verdict from /usage — read by the composer before sending. */
|
|
4203
4218
|
let paceBlocked = false, paceReason = null;
|
|
4204
4219
|
|
|
4205
|
-
/**
|
|
4206
|
-
*
|
|
4207
|
-
function
|
|
4208
|
-
if (
|
|
4209
|
-
|
|
4210
|
-
|
|
4220
|
+
/** Paints the static gradient arc of every dial. Runs once: the scale never
|
|
4221
|
+
* changes, only the needle on top of it does. */
|
|
4222
|
+
function buildDialArcs() {
|
|
4223
|
+
if (!window.arcSegments) return;
|
|
4224
|
+
const NS = "http://www.w3.org/2000/svg";
|
|
4225
|
+
for (const g of document.querySelectorAll(".quota .dial .arc")) {
|
|
4226
|
+
g.replaceChildren(
|
|
4227
|
+
...window.arcSegments().map(({ d, color }) => {
|
|
4228
|
+
const p = document.createElementNS(NS, "path");
|
|
4229
|
+
p.setAttribute("d", d);
|
|
4230
|
+
// Via the style PROPERTY: color-mix() is not reliably parsed as an
|
|
4231
|
+
// SVG presentation attribute.
|
|
4232
|
+
p.style.stroke = color;
|
|
4233
|
+
return p;
|
|
4234
|
+
}),
|
|
4235
|
+
);
|
|
4211
4236
|
}
|
|
4212
|
-
const t = Math.round(Math.min(100, ratio - 100)); // 100 → ambre, 200 → rouge
|
|
4213
|
-
return "color-mix(in oklab, var(--amber), var(--err) " + t + "%)";
|
|
4214
4237
|
}
|
|
4215
4238
|
|
|
4216
4239
|
function paintGauge(el, w) {
|
|
4217
|
-
const
|
|
4218
|
-
const
|
|
4219
|
-
const
|
|
4220
|
-
|
|
4221
|
-
|
|
4222
|
-
|
|
4223
|
-
|
|
4224
|
-
|
|
4225
|
-
|
|
4240
|
+
const svg = el.querySelector(".dial");
|
|
4241
|
+
const needle = svg.querySelector(".needle");
|
|
4242
|
+
const tick = svg.querySelector(".tick");
|
|
4243
|
+
const pct = svg.querySelector(".pct");
|
|
4244
|
+
const label = el.dataset.window;
|
|
4245
|
+
const title = window.dialTitle
|
|
4246
|
+
? window.dialTitle(label, w, w ? fmtReset(w.resetsAt) : "")
|
|
4247
|
+
: label + " rolling limit";
|
|
4248
|
+
el.title = title;
|
|
4249
|
+
svg.setAttribute("aria-label", title);
|
|
4250
|
+
svg.classList.toggle("nodata", !w);
|
|
4251
|
+
// Guard on the ESM bridge (invariant 10): this function can be reached
|
|
4252
|
+
// before the module has run. Degrade to the number rather than paint a
|
|
4253
|
+
// needle at a wrong angle.
|
|
4254
|
+
if (!w || !window.dialPos) {
|
|
4255
|
+
needle.setAttribute("visibility", "hidden");
|
|
4256
|
+
tick.setAttribute("visibility", "hidden");
|
|
4257
|
+
pct.textContent = w ? Math.round(w.usedPercentage) + "%" : "—";
|
|
4258
|
+
pct.style.fill = "var(--text-dim)";
|
|
4226
4259
|
return;
|
|
4227
4260
|
}
|
|
4228
|
-
const
|
|
4229
|
-
|
|
4230
|
-
|
|
4231
|
-
//
|
|
4232
|
-
|
|
4233
|
-
|
|
4234
|
-
|
|
4235
|
-
|
|
4236
|
-
fill.style.left = (pos < 0 ? 50 - half : 50) + "%";
|
|
4237
|
-
fill.style.width = half + "%";
|
|
4238
|
-
fill.style.background = paceColor(r);
|
|
4239
|
-
pct.textContent = used + "%";
|
|
4240
|
-
// fmtReset returns "" when resetsAt is null — the separator goes with it,
|
|
4241
|
-
// otherwise the tooltip ends on a dangling " · ".
|
|
4242
|
-
const reset = fmtReset(w.resetsAt);
|
|
4243
|
-
el.title = base + " — " + used + "% used"
|
|
4244
|
-
+ (pace === null ? "" : ", ideal pace " + pace + "% (" + ratio + "% of pace)")
|
|
4245
|
-
+ (reset ? " · " + reset : "");
|
|
4261
|
+
const pos = window.dialPos(w.usedPercentage, w.idealPacePct);
|
|
4262
|
+
needle.setAttribute("visibility", "visible");
|
|
4263
|
+
needle.style.transform = "rotate(" + (pos * window.SWEEP_DEG).toFixed(1) + "deg)";
|
|
4264
|
+
// Without a reset time there is no ideal pace, so there is nothing for the
|
|
4265
|
+
// tick to mark — drawing it would assert a reference we do not have.
|
|
4266
|
+
tick.setAttribute("visibility", w.idealPacePct == null ? "hidden" : "visible");
|
|
4267
|
+
pct.textContent = Math.round(w.usedPercentage) + "%";
|
|
4268
|
+
pct.style.fill = window.dialColor(pos);
|
|
4246
4269
|
}
|
|
4247
4270
|
|
|
4248
4271
|
async function refreshUsage() {
|
|
@@ -4261,7 +4284,12 @@
|
|
|
4261
4284
|
paceReason = null;
|
|
4262
4285
|
}
|
|
4263
4286
|
}
|
|
4264
|
-
|
|
4287
|
+
// The ESM bridge (window.dialPos & co) runs AFTER this classic script — see
|
|
4288
|
+
// invariant 10 in CLAUDE.md. Painting now would leave blank dials in silence.
|
|
4289
|
+
document.addEventListener("DOMContentLoaded", () => {
|
|
4290
|
+
buildDialArcs();
|
|
4291
|
+
refreshUsage();
|
|
4292
|
+
});
|
|
4265
4293
|
setInterval(refreshUsage, 60_000);
|
|
4266
4294
|
|
|
4267
4295
|
// On load: restore the remembered groups (server-side, keyed by launch
|