shadok-ai 0.16.13 → 0.16.15

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/README.md CHANGED
@@ -294,7 +294,7 @@ Enter to skip; you can add it later from the web UI).
294
294
  card back. Desktop only: the agents column is hidden on phones.
295
295
  - **Guided tour** — on a browser's first visit, a welcome card explains what
296
296
  shadok-ai is, then a spotlight walks the real controls: the agents list, an
297
- agent's ⋯ menu, scheduled prompts, the toolbar, the quota dials. Skip and
297
+ agent's ⋯ menu, scheduled prompts, the toolbar, the quota bars. Skip and
298
298
  Escape end it at any point; ⋯ → *Guided tour* replays it. A step whose target
299
299
  isn't on screen is **dropped rather than faked** — an empty cockpit has no
300
300
  agent menu, so it gets a shorter tour instead of a spotlight on nothing. The
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shadok-ai",
3
- "version": "0.16.13",
3
+ "version": "0.16.15",
4
4
  "main": "dist/session.js",
5
5
  "scripts": {
6
6
  "test": "node --import tsx --test --test-timeout=120000 test/*.ts context/*/test/*.test.mjs",
@@ -1,79 +1,70 @@
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.
1
+ // The quota meters (5h / 7d): index.html renders a FLAT BAR whose length is the
2
+ // quota consumed, with a marker at the ideal pace (the share of the window's
3
+ // time already elapsed) — see docs/superpowers/specs/2026-10-30-quota-bar-design.md
4
+ // (it replaced the 240° dial of 2026-07-30-quota-dial-design.md).
3
5
  //
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 };
6
+ // This module is the PURE core (no DOM): it turns the usage numbers into the
7
+ // pace POSITION (−1 nothing / 0 on pace / +1 exhausted) and the COLOUR that
8
+ // encodes ahead-of / behind-the-clock, plus the multi-line tooltip. The bar's
9
+ // length and the marker's position are the raw percentages and need no maths —
10
+ // so all that lives here is the colour scale and the words. Loaded as-is by the
11
+ // browser (ESM) and imported by the node/tsx tests, like public/live-text.js,
12
+ // public/notify.js and public/profile-card.js.
14
13
 
15
14
  /**
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
15
+ * The ideal pace is clamped before it becomes the position's midpoint, because
16
+ * both ends of a window degenerate otherwise: just after a reset the pace is ~0
17
+ * and the "under pace" half collapses to a point (the first token spent would
18
+ * read as on pace); at the very end of a window it is ~100 and the "over pace"
20
19
  * 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.
20
+ * the clamp costs a sub-percent of accuracy — but *inside* the bands (pace < 2
21
+ * or > 98) the error is larger (see `dialTitle`'s unclamped `side`, and the two
22
+ * tests that cover it), because the colour is reading a clamped pace it does not
23
+ * have. The trade is still the right one there: the pace is too young or too old
24
+ * to mean much.
27
25
  *
28
26
  * `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.
27
+ * (`src/pace.ts`, currently both 2) for a property the meter depends on: the
28
+ * guard blocks a prompt at `used > pace + PACE_EPSILON`, and the fill turns red
29
+ * (crosses the on-pace point) at `used > clamp(pace, PACE_MIN, PACE_MAX)`. With
30
+ * `PACE_MIN <= PACE_EPSILON` the fill reddens at or before the guard blocks, so
31
+ * the gauge always warns before a send is refused, never after. Do not couple
32
+ * the two constants across files for this — just keep it in mind when either one
33
+ * changes, since nothing enforces it in code.
36
34
  */
37
35
  const PACE_MIN = 2;
38
36
  const PACE_MAX = 98;
39
37
 
40
38
  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
39
 
44
40
  /**
45
- * Position on the dial: -1 nothing consumed, 0 exactly on the ideal pace, +1
46
- * quota exhausted.
41
+ * The pace position that drives the fill COLOUR: -1 nothing consumed, 0 exactly
42
+ * on the ideal pace, +1 quota exhausted. (The fill's LENGTH and the marker's
43
+ * position are the raw `usedPercentage` / `idealPacePct` — not this.)
47
44
  *
48
45
  * The two halves have DIFFERENT scales, deliberately: the pace advances with
49
46
  * 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).
47
+ * right half maps used 25→100 %. That is what lets the single number answer both
48
+ * "am I on pace" (0) and "is it gone" (+1).
52
49
  *
53
50
  * `pace` is `idealPacePct` from /usage, NOT `ratioPct`: the latter carries
54
51
  * PACE_EPSILON in its denominator to keep the guardrail from tripping on the
55
52
  * 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.
53
+ * colour from the very pace the marker sits on.
57
54
  */
58
55
  export function dialPos(used, pace) {
59
56
  const u = clamp(used, 0, 100);
60
57
  // 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.
58
+ // back to a plain linear 0→100 scale. The caller hides the pace marker.
62
59
  if (pace == null) return u / 50 - 1;
63
60
  const p = clamp(pace, PACE_MIN, PACE_MAX);
64
61
  return clamp(u <= p ? u / p - 1 : (u - p) / (100 - p), -1, 1);
65
62
  }
66
63
 
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
64
  /**
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.
65
+ * Colour for a pace position. The accent at the on-pace point, green towards
66
+ * "under pace", red towards "exhausted" — an oklab mix keyed on the position so
67
+ * the fill's tint always agrees with where it sits relative to the pace marker.
77
68
  */
78
69
  export function dialColor(pos) {
79
70
  const t = Math.round(clamp(pos, -1, 1) * 100);
@@ -82,56 +73,9 @@ export function dialColor(pos) {
82
73
  : `color-mix(in oklab, var(--amber), var(--err) ${t}%)`;
83
74
  }
84
75
 
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
76
  /**
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
77
+ * The multi-line tooltip, reused verbatim as the gauge's aria-label. The third
78
+ * line is what makes the reading self-explanatory: it states whether
135
79
  * consumption is ahead of, behind, or on the ideal pace.
136
80
  *
137
81
  * `resetText` comes from the caller's fmtReset and is "" when resetsAt is null —
@@ -148,10 +92,10 @@ export function dialTitle(label, w, resetText) {
148
92
  .filter(Boolean)
149
93
  .join("\n");
150
94
  }
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.
95
+ // Unclamped on purpose: dialPos clamps the pace to keep the FILL COLOUR readable
96
+ // at the edges of a window, but the sentence must state what is actually
97
+ // happening. Inside the clamp bands the two can disagree — a true sentence
98
+ // beside a clamped tint beats a false one.
155
99
  const off = w.usedPercentage - w.idealPacePct;
156
100
  const side =
157
101
  off < -0.5
package/public/index.html CHANGED
@@ -350,8 +350,13 @@
350
350
  display: block; margin-top: 3px; opacity: 0.8;
351
351
  white-space: normal; line-height: 1.45;
352
352
  }
353
+ /* Top banners sit ABOVE the header, not over it: they are flow items at the
354
+ very top of the fixed flex chassis (inserted as body's first child), so they
355
+ push the header and everything below down instead of overlapping it. A
356
+ `position:fixed; top:0` strip used to float over the header and hide the
357
+ brand/gauges underneath. */
353
358
  #updateBanner {
354
- position: fixed; top: 0; left: 0; right: 0; z-index: 1000;
359
+ flex: 0 0 auto; width: 100%; z-index: 1000;
355
360
  background: var(--amber); color: var(--on-accent);
356
361
  font-family: var(--sans); font-size: 12px; text-align: center;
357
362
  padding: 6px 12px;
@@ -359,7 +364,7 @@
359
364
  /* The re-login reminder (sign-in within 48h / expired). Same top strip as the
360
365
  update banner; its own id so the two never share styling by accident. */
361
366
  #expiryBanner {
362
- position: fixed; top: 0; left: 0; right: 0; z-index: 1000;
367
+ flex: 0 0 auto; width: 100%; z-index: 1000;
363
368
  background: var(--amber); color: var(--on-accent);
364
369
  font-family: var(--sans); font-size: 12px;
365
370
  padding: 6px 12px;
@@ -396,48 +401,58 @@
396
401
  @keyframes sheet-up { from { transform: translateY(100%); } to { transform: translateY(0); } }
397
402
  @media (prefers-reduced-motion: reduce) { .led.busy { animation: none; } body.picker-open #tabbar { animation: none; } }
398
403
  header .spacer { flex: 1; }
399
- /* Quota dials (5h / 7d subscription usage).
400
- A 240° speedometer whose CENTRE is the ideal pace and whose RIGHT END is
401
- exhaustion — see docs/superpowers/specs/2026-07-30-quota-dial-design.md.
402
- Position AND colour carry the reading, never colour alone. */
403
- .quota { min-width: 56px; }
404
- .dial { display: block; width: 56px; height: 44px; }
405
- /* opacity on the GROUP, not each path: 24 overlapping-alpha strokes would
406
- composite twice at every join and brighten the seams instead of hiding
407
- them. One group opacity leaves the marks flat and evenly graduated. */
408
- .dial .arc { opacity: 0.6; }
409
- .dial .arc path { fill: none; stroke-width: 4; }
410
- /* No data: the scale stays, as a ghost, so the widget does not vanish. */
411
- .dial.nodata .arc { opacity: 0.18; }
412
- /* The "on pace" reference, just outside the arc. The needle's tip meets it
413
- exactly when the consumption is on pace — they read as one line. */
414
- .dial .tick { stroke: var(--text); stroke-width: 1.5; opacity: 0.75; }
415
- .dial .needle {
416
- stroke: var(--text);
417
- stroke-width: 2;
418
- stroke-linecap: round;
419
- /* Rotated through the CSS transform PROPERTY, not the SVG attribute: only
420
- the property transitions reliably. transform-box/-origin put the pivot on
421
- the hub in viewBox units. */
422
- transform-box: view-box;
423
- transform-origin: 28px 25px;
424
- transition: transform 0.4s ease;
425
- }
426
- .dial .pct {
427
- font-family: var(--mono);
428
- font-size: 12px;
429
- font-weight: 600;
430
- fill: var(--text);
431
- transition: fill 0.4s ease;
432
- }
433
- .dial .unit {
434
- font-family: var(--mono);
435
- font-size: 8px;
436
- fill: var(--text-dim);
437
- letter-spacing: 0.08em;
404
+ /* Quota meters (5h / 7d subscription usage).
405
+ A flat bar per window: its LENGTH is the quota consumed, and a white marker
406
+ sits at the ideal pace (the share of the window's time already elapsed).
407
+ Fill short of the marker = ahead of the clock (green); past it = burning
408
+ faster (red). Position AND colour carry the reading, never colour alone —
409
+ see docs/superpowers/specs/2026-10-30-quota-bar-design.md. */
410
+ .quotas { display: flex; flex-direction: column; gap: 5px; }
411
+ .quota {
412
+ display: grid; grid-template-columns: 18px 96px 32px;
413
+ align-items: center; column-gap: 9px; min-width: 0;
414
+ }
415
+ .quota .q-lbl {
416
+ font-family: var(--mono); font-size: 11px; font-weight: 600;
417
+ color: var(--text-dim); letter-spacing: 0.04em;
418
+ }
419
+ .quota .q-pct {
420
+ font-family: var(--mono); font-size: 12px; font-weight: 600;
421
+ text-align: right; color: var(--text);
422
+ font-variant-numeric: tabular-nums; transition: color 0.4s ease;
423
+ }
424
+ /* The track is theme-aware (a faint wash of the text ink) so it reads on both
425
+ the dark and the light palette; the fill colour is set per reading from JS. */
426
+ .q-track {
427
+ position: relative; height: 7px; border-radius: 999px;
428
+ background: color-mix(in oklab, var(--text) 12%, transparent);
429
+ }
430
+ .quota.nodata .q-track { background: color-mix(in oklab, var(--text) 7%, transparent); }
431
+ .q-fill {
432
+ position: absolute; left: 0; top: 0; height: 100%; width: 0;
433
+ border-radius: 999px;
434
+ /* A soft top sheen over the per-reading colour (`--c`), for a bit of depth. */
435
+ background: linear-gradient(180deg, rgba(255, 255, 255, 0.22), rgba(255, 255, 255, 0) 60%),
436
+ var(--c, var(--text-dim));
437
+ transition: width 0.4s ease, background-color 0.4s ease;
438
+ }
439
+ /* The ideal-pace marker: a line that stands proud of the bar with a small cap,
440
+ so it reads on any fill colour. `var(--text)` (near-white on dark, dark on
441
+ light) + a page-coloured outline keep it legible in both themes. Hidden when
442
+ there is no reset time, i.e. no pace to mark. */
443
+ .q-mark {
444
+ position: absolute; top: -4px; height: 15px; width: 2px; left: 0;
445
+ border-radius: 1px; background: var(--text); transform: translateX(-1px);
446
+ filter: drop-shadow(0 0 1px var(--page));
447
+ transition: left 0.4s ease;
448
+ }
449
+ .q-mark::before {
450
+ content: ""; position: absolute; top: -3px; left: 50%; transform: translateX(-50%);
451
+ border-left: 3px solid transparent; border-right: 3px solid transparent;
452
+ border-top: 3px solid var(--text);
438
453
  }
439
454
  @media (prefers-reduced-motion: reduce) {
440
- .dial .needle, .dial .pct { transition: none; }
455
+ .q-fill, .q-pct, .q-mark { transition: none; }
441
456
  }
442
457
  button {
443
458
  font-family: var(--sans);
@@ -1731,8 +1746,12 @@
1731
1746
  phone it moves into the ⋯ menu (#starMenuItem) instead. */
1732
1747
  #starBtn { display: none; }
1733
1748
  #moreMenu #starMenuItem { display: block; }
1734
- .quota { min-width: 46px; }
1735
- .dial { width: 46px; height: 36px; }
1749
+ /* Narrower bar + smaller label column so the two stacked meters fit the
1750
+ nowrap header beside the brand and the ⋯ menu. */
1751
+ .quotas { gap: 4px; }
1752
+ .quota { grid-template-columns: 16px 62px 28px; column-gap: 6px; }
1753
+ .quota .q-lbl { font-size: 10px; }
1754
+ .quota .q-pct { font-size: 11px; }
1736
1755
  /* A bit more room for a thumb, a bit less margin wasted on a narrow screen. */
1737
1756
  .transcript { padding: 14px 14px 6px; }
1738
1757
  #composer { padding: 10px 14px calc(12px + env(safe-area-inset-bottom, 0px)); gap: 8px; }
@@ -2423,23 +2442,17 @@
2423
2442
  <header>
2424
2443
  <span class="brand"><span class="brand-logo" aria-hidden="true"><svg viewBox="0 0 32 32"><g fill="currentColor" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"><g class="sk-leg sk-leg-l"><line x1="13" y1="23" x2="12.6" y2="30.6"/><line x1="11" y1="30.7" x2="14.1" y2="30.7"/></g><g class="sk-leg sk-leg-r"><line x1="18.6" y1="23" x2="19" y2="30.6"/><line x1="17.6" y1="30.7" x2="20.7" y2="30.7"/></g><g class="sk-bob"><line class="sk-arm" x1="21.6" y1="17.2" x2="26" y2="20.4"/><g class="sk-ant sk-ant-l" stroke-width="0.9"><line x1="13.8" y1="5.4" x2="11.8" y2="2.8"/><circle cx="11.8" cy="2.8" r="1" stroke="none"/></g><g class="sk-ant sk-ant-r" stroke-width="0.9"><line x1="18.2" y1="5.4" x2="20.2" y2="2.8"/><circle cx="20.2" cy="2.8" r="1" stroke="none"/></g><circle cx="16" cy="16.2" r="8.4" stroke="none"/><path d="M14.4 18.2 L17.6 18.2 L16 21.6 Z" stroke="none"/><g class="sk-eyes" stroke="none"><circle cx="13.1" cy="7.8" r="2.6" fill="#fff"/><circle cx="18.9" cy="7.8" r="2.6" fill="#fff"/><g class="sk-pupils"><circle cx="13.4" cy="8.1" r="1.3"/><circle cx="19.2" cy="8.1" r="1.3"/></g></g></g></g></svg></span><span class="brand-name" id="brandName" title="Rename this cockpit">shadok-ai</span><span class="brand-edit" id="brandEdit" title="Rename this cockpit" aria-hidden="true">✎</span> <span class="ver" id="verView" title="Running version"></span><span class="ver-chan" id="verChan" aria-hidden="true"></span></span>
2425
2444
  <span class="spacer"></span>
2426
- <div class="gauge quota" id="quota5h" data-window="5h" title="5h rolling limit">
2427
- <svg class="dial" viewBox="0 0 56 44" role="img" aria-label="5h rolling limit">
2428
- <g class="arc"></g>
2429
- <line class="tick" x1="28" y1="5" x2="28" y2="2" visibility="hidden"></line>
2430
- <line class="needle" x1="28" y1="13" x2="28" y2="5" visibility="hidden"></line>
2431
- <text class="pct" x="28" y="28" text-anchor="middle">—</text>
2432
- <text class="unit" x="28" y="41" text-anchor="middle">5h</text>
2433
- </svg>
2434
- </div>
2435
- <div class="gauge quota" id="quota7d" data-window="7d" title="7d rolling limit">
2436
- <svg class="dial" viewBox="0 0 56 44" role="img" aria-label="7d rolling limit">
2437
- <g class="arc"></g>
2438
- <line class="tick" x1="28" y1="5" x2="28" y2="2" visibility="hidden"></line>
2439
- <line class="needle" x1="28" y1="13" x2="28" y2="5" visibility="hidden"></line>
2440
- <text class="pct" x="28" y="28" text-anchor="middle">—</text>
2441
- <text class="unit" x="28" y="41" text-anchor="middle">7d</text>
2442
- </svg>
2445
+ <div class="quotas">
2446
+ <div class="quota" id="quota5h" data-window="5h" role="img" aria-label="5h rolling limit" title="5h rolling limit">
2447
+ <span class="q-lbl">5h</span>
2448
+ <div class="q-track"><div class="q-fill"></div><div class="q-mark" hidden></div></div>
2449
+ <span class="q-pct">—</span>
2450
+ </div>
2451
+ <div class="quota" id="quota7d" data-window="7d" role="img" aria-label="7d rolling limit" title="7d rolling limit">
2452
+ <span class="q-lbl">7d</span>
2453
+ <div class="q-track"><div class="q-fill"></div><div class="q-mark" hidden></div></div>
2454
+ <span class="q-pct">—</span>
2455
+ </div>
2443
2456
  </div>
2444
2457
  <!-- The global actions, in ONE container. `display: contents` on desktop: they
2445
2458
  stay direct items of the bar and the layout does not move a pixel. On a
@@ -3221,7 +3234,7 @@
3221
3234
  applyReadonlyPreset, isManagedProfile, profileSaveBody, secretUsers } from "/profile-card.js?v=__ASSET_V__";
3222
3235
  import { notifyState, BLINK_MS } from "/notify.js?v=__ASSET_V__";
3223
3236
  import { echoAuthor, isOwnPrompt } from "/echo-author.js?v=__ASSET_V__";
3224
- import { dialPos, dialColor, arcSegments, dialTitle, SWEEP_DEG } from "/gauge-dial.js?v=__ASSET_V__";
3237
+ import { dialPos, dialColor, dialTitle } from "/gauge-dial.js?v=__ASSET_V__";
3225
3238
  import { TOUR_STEPS, bubblePlacement, unionRect, visibleSteps } from "/tour-steps.js?v=__ASSET_V__";
3226
3239
  import { pickChannelSource, dirKey, serverSwapped } from "/channel-store.js?v=__ASSET_V__";
3227
3240
  import { MODEL_CHOICES, composeModel } from "/model-choice.js?v=__ASSET_V__";
@@ -3287,9 +3300,7 @@
3287
3300
  window.BLINK_MS = BLINK_MS;
3288
3301
  window.dialPos = dialPos;
3289
3302
  window.dialColor = dialColor;
3290
- window.arcSegments = arcSegments;
3291
3303
  window.dialTitle = dialTitle;
3292
- window.SWEEP_DEG = SWEEP_DEG;
3293
3304
  // Les bouchons du script classique sont maintenant remplacés par les vraies
3294
3305
  // fonctions : tout ce qui a pu être peint entre-temps est incomplet.
3295
3306
  window.dispatchEvent(new Event("shadok-bridge-ready"));
@@ -3336,7 +3347,6 @@
3336
3347
  notifyState: () => ({ color: null, badge: "", blink: false }),
3337
3348
  dialPos: () => 0,
3338
3349
  dialColor: () => "",
3339
- arcSegments: () => [],
3340
3350
  dialTitle: () => "",
3341
3351
  bubblePlacement: () => null,
3342
3352
  unionRect: () => null,
@@ -4562,8 +4572,10 @@
4562
4572
  swapped = true;
4563
4573
  restoring = true; // freezes pushChannels AND persistGroups (both bail on it)
4564
4574
  const bar = document.createElement("div");
4575
+ // A flow strip at the very top (first child) so it sits ABOVE the header
4576
+ // rather than floating over it — same placement as the update/expiry banners.
4565
4577
  bar.style.cssText =
4566
- "position:fixed;top:0;left:0;right:0;z-index:99999;background:#b23b3b;color:#fff;" +
4578
+ "flex:0 0 auto;width:100%;z-index:99999;background:#b23b3b;color:#fff;" +
4567
4579
  "padding:10px 14px;font:14px system-ui;text-align:center";
4568
4580
  bar.textContent = "This cockpit was replaced by another instance on this port — reload to reconnect. ";
4569
4581
  const btn = document.createElement("button");
@@ -4571,7 +4583,7 @@
4571
4583
  btn.style.cssText = "margin-left:8px;padding:3px 10px;cursor:pointer";
4572
4584
  btn.addEventListener("click", () => location.reload());
4573
4585
  bar.appendChild(btn);
4574
- document.body.appendChild(bar);
4586
+ document.body.insertBefore(bar, document.body.firstChild);
4575
4587
  }
4576
4588
  function pushChannels(list) {
4577
4589
  if (restoring || swapped) return; // never overwrite the server list with a partial one, nor a foreign instance's
@@ -7674,7 +7686,8 @@
7674
7686
  btn.textContent = "Re-login now";
7675
7687
  btn.addEventListener("click", () => openAuthCard(true));
7676
7688
  el.append(span, btn);
7677
- document.body.appendChild(el);
7689
+ // First child → above the header in the flex chassis, not floating over it.
7690
+ document.body.insertBefore(el, document.body.firstChild);
7678
7691
  }
7679
7692
  // Expired is an escalation, not just a reminder — turn the strip red.
7680
7693
  el.style.background = w.expired ? "var(--err)" : "";
@@ -7867,7 +7880,8 @@
7867
7880
  if (!el) {
7868
7881
  el = document.createElement("div");
7869
7882
  el.id = "updateBanner";
7870
- document.body.appendChild(el);
7883
+ // First child → above the header in the flex chassis, not floating over it.
7884
+ document.body.insertBefore(el, document.body.firstChild);
7871
7885
  }
7872
7886
  el.textContent = "Updating to v" + version + "… the page will reload automatically.";
7873
7887
  }
@@ -11196,55 +11210,42 @@
11196
11210
  /** Last verdict from /usage — read by the composer before sending. */
11197
11211
  let paceBlocked = false, paceReason = null;
11198
11212
 
11199
- /** Paints the static gradient arc of every dial. Runs once: the scale never
11200
- * changes, only the needle on top of it does. */
11201
- function buildDialArcs() {
11202
- if (!window.arcSegments) return;
11203
- const NS = "http://www.w3.org/2000/svg";
11204
- for (const g of document.querySelectorAll(".quota .dial .arc")) {
11205
- g.replaceChildren(
11206
- ...window.arcSegments().map(({ d, color }) => {
11207
- const p = document.createElementNS(NS, "path");
11208
- p.setAttribute("d", d);
11209
- // Via the style PROPERTY: color-mix() is not reliably parsed as an
11210
- // SVG presentation attribute.
11211
- p.style.stroke = color;
11212
- return p;
11213
- }),
11214
- );
11215
- }
11216
- }
11217
-
11218
11213
  function paintGauge(el, w) {
11219
- const svg = el.querySelector(".dial");
11220
- const needle = svg.querySelector(".needle");
11221
- const tick = svg.querySelector(".tick");
11222
- const pct = svg.querySelector(".pct");
11214
+ const fill = el.querySelector(".q-fill");
11215
+ const mark = el.querySelector(".q-mark");
11216
+ const pct = el.querySelector(".q-pct");
11223
11217
  const label = el.dataset.window;
11224
11218
  const title = window.dialTitle
11225
11219
  ? window.dialTitle(label, w, w ? fmtReset(w.resetsAt) : "")
11226
11220
  : label + " rolling limit";
11227
11221
  el.title = title;
11228
- svg.setAttribute("aria-label", title);
11229
- svg.classList.toggle("nodata", !w);
11230
- // Guard on the ESM bridge (invariant 10): this function can be reached
11231
- // before the module has run. Degrade to the number rather than paint a
11232
- // needle at a wrong angle.
11233
- if (!w || !window.dialPos) {
11234
- needle.setAttribute("visibility", "hidden");
11235
- tick.setAttribute("visibility", "hidden");
11236
- pct.textContent = w ? Math.round(w.usedPercentage) + "%" : "—";
11237
- pct.style.fill = "var(--text-dim)";
11222
+ el.setAttribute("aria-label", title);
11223
+ el.classList.toggle("nodata", !w);
11224
+ if (!w) {
11225
+ fill.style.width = "0%";
11226
+ mark.hidden = true;
11227
+ pct.textContent = "—";
11228
+ pct.style.color = "var(--text-dim)";
11238
11229
  return;
11239
11230
  }
11240
- const pos = window.dialPos(w.usedPercentage, w.idealPacePct);
11241
- needle.setAttribute("visibility", "visible");
11242
- needle.style.transform = "rotate(" + (pos * window.SWEEP_DEG).toFixed(1) + "deg)";
11243
- // Without a reset time there is no ideal pace, so there is nothing for the
11244
- // tick to mark — drawing it would assert a reference we do not have.
11245
- tick.setAttribute("visibility", w.idealPacePct == null ? "hidden" : "visible");
11231
+ // Bar LENGTH is the consumption, straight from the number (no maths).
11232
+ const used = Math.max(0, Math.min(100, w.usedPercentage));
11233
+ fill.style.width = used + "%";
11234
+ // The fill COLOUR encodes ahead-of / behind-the-clock. That needs the
11235
+ // piecewise pace position; without the ESM bridge (invariant 10) this paint
11236
+ // can run before the module lands — fall back to a neutral bar (still the
11237
+ // right length) rather than a wrong tint.
11238
+ const color = window.dialPos
11239
+ ? window.dialColor(window.dialPos(w.usedPercentage, w.idealPacePct))
11240
+ : "var(--text-dim)";
11241
+ fill.style.setProperty("--c", color);
11246
11242
  pct.textContent = Math.round(w.usedPercentage) + "%";
11247
- pct.style.fill = window.dialColor(pos);
11243
+ pct.style.color = color;
11244
+ // The marker is the ideal pace (the window's elapsed-time share). No reset
11245
+ // time → no pace → hide it rather than assert a reference we do not have.
11246
+ const hasPace = w.idealPacePct != null;
11247
+ mark.hidden = !hasPace;
11248
+ if (hasPace) mark.style.left = Math.max(0, Math.min(100, w.idealPacePct)) + "%";
11248
11249
  }
11249
11250
 
11250
11251
  async function refreshUsage() {
@@ -11263,10 +11264,11 @@
11263
11264
  paceReason = null;
11264
11265
  }
11265
11266
  }
11266
- // The ESM bridge (window.dialPos & co) runs AFTER this classic script — see
11267
- // invariant 10 in CLAUDE.md. Painting now would leave blank dials in silence.
11267
+ // A deferred module script runs after parsing but BEFORE DOMContentLoaded, so
11268
+ // window.dialPos & co (the ESM bridge, invariant 10) are ready by here and the
11269
+ // first paint gets the right colour; paintGauge still degrades to a neutral bar
11270
+ // if that ever fails to hold.
11268
11271
  document.addEventListener("DOMContentLoaded", () => {
11269
- buildDialArcs();
11270
11272
  refreshUsage();
11271
11273
  refreshAuthRows(); // paints the re-login reminder banner if the token is due
11272
11274
  maybeStartTour(); // the tour runs first; it calls checkAuth() when it ends
@@ -118,10 +118,10 @@ export const TOUR_STEPS = [
118
118
  id: "quota",
119
119
  title: "Watch your quota",
120
120
  body:
121
- "Your 5h and 7d subscription usage. The needle's centre is the pace that would " +
122
- "spend the window exactly on time, so leaning right means you're burning faster. " +
123
- "To the left, the cockpit's name is yours to change, and the version beside it opens " +
124
- "updates and the permission mode.",
121
+ "Your 5h and 7d subscription usage. Each bar fills with what you've spent, and the " +
122
+ "white marker is the pace that would spend the window exactly on time — a fill past " +
123
+ "the marker means you're burning faster. To the left, the cockpit's name is yours to " +
124
+ "change, and the version beside it opens updates and the permission mode.",
125
125
  target: ["#quota5h", "#quota7d"],
126
126
  },
127
127
  ];