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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shadok-ai",
3
- "version": "0.1.209",
3
+ "version": "0.1.210",
4
4
  "main": "dist/session.js",
5
5
  "scripts": {
6
6
  "test": "node --import tsx --test test/*.ts .claude/skills/shadok-ai-agents/test/*.test.mjs",
@@ -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 gauges (5h / 7d subscription usage).
161
- Une barre divergente par fenêtre : le remplissage part du centre
162
- (= « au pace », ratio 100 %), vert vers la gauche quand on est SOUS le
163
- pace, rouge vers la droite quand on est AU-DESSUS. Position ET couleur
164
- portent l'info (accessibilité). */
165
- .quota { min-width: 92px; }
166
- .quota .meter {
167
- position: relative;
168
- height: 6px;
169
- background: var(--bg-inset);
170
- border: 1px solid var(--line);
171
- border-radius: 3px;
172
- overflow: hidden;
173
- margin: 2px 0;
174
- }
175
- /* Tick central « au pace ». */
176
- .quota .meter::before {
177
- content: "";
178
- position: absolute;
179
- left: 50%;
180
- top: 0; bottom: 0;
181
- width: 1px;
182
- margin-left: -0.5px;
183
- background: var(--text-dim);
184
- opacity: 0.55;
185
- z-index: 1;
186
- }
187
- /* Remplissage : left + width + background-color posés en inline par paintGauge. */
188
- .quota .fill {
189
- position: absolute;
190
- top: 0; bottom: 0;
191
- left: 50%;
192
- width: 0;
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
- .quota .qpct {
194
+ .dial .unit {
197
195
  font-family: var(--mono);
198
- font-size: 11px;
199
- color: var(--text-dim);
200
- white-space: nowrap;
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="5-hour rolling limit">
1095
- <span class="label">5h</span>
1096
- <div class="meter"><span class="fill"></span></div>
1097
- <span class="qpct">—</span>
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="7-day rolling limit">
1100
- <span class="label">7d</span>
1101
- <div class="meter"><span class="fill"></span></div>
1102
- <span class="qpct">—</span>
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
- /** Couleur du remplissage : dégradé continu vert → ambre → rouge selon le
4206
- * ratio usage/pace (0 → 100 → 200). L'ambre entoure le centre « au pace ». */
4207
- function paceColor(ratio) {
4208
- if (ratio <= 100) {
4209
- const t = Math.round(Math.max(0, ratio)); // 0 → vert, 100 → ambre
4210
- return "color-mix(in oklab, var(--ok), var(--amber) " + t + "%)";
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 fill = el.querySelector(".meter .fill");
4218
- const pct = el.querySelector(".qpct");
4219
- const base = el.id === "quota5h" ? "5-hour rolling limit" : "7-day rolling limit";
4220
- if (!w) {
4221
- fill.style.width = "0%";
4222
- fill.style.left = "50%";
4223
- fill.style.background = "var(--ok)";
4224
- pct.textContent = "—";
4225
- el.title = base;
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 used = Math.round(w.usedPercentage);
4229
- const pace = w.idealPacePct == null ? null : Math.round(w.idealPacePct);
4230
- const ratio = w.ratioPct == null ? null : Math.round(w.ratioPct);
4231
- // Sans ratio (pas de pace calculable), on centre : barre vide + tick seul.
4232
- const r = ratio == null ? 100 : ratio;
4233
- // Ratio → position [-1, +1] ; 50 % (centre) = au pace (ratio 100).
4234
- const pos = Math.max(-1, Math.min(1, (r - 100) / 100));
4235
- const half = Math.abs(pos) * 50; // % de la demi-largeur
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
- refreshUsage();
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