shadok-ai 0.16.14 → 0.16.16
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 +1 -1
- package/package.json +1 -1
- package/public/gauge-dial.js +43 -99
- package/public/index.html +179 -106
- package/public/tour-steps.js +4 -4
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
|
|
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
package/public/gauge-dial.js
CHANGED
|
@@ -1,79 +1,70 @@
|
|
|
1
|
-
// The
|
|
2
|
-
//
|
|
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
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
|
17
|
-
* ends of a window degenerate otherwise: just after a reset the pace is ~0
|
|
18
|
-
* the
|
|
19
|
-
*
|
|
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-
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
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
|
|
30
|
-
* guard blocks a prompt at `used > pace + PACE_EPSILON`, and the
|
|
31
|
-
*
|
|
32
|
-
* `PACE_MIN <= PACE_EPSILON` the
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
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
|
-
*
|
|
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
|
|
51
|
-
* on pace" (
|
|
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
|
-
*
|
|
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
|
|
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
|
|
74
|
-
* red towards "exhausted" —
|
|
75
|
-
*
|
|
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
|
|
134
|
-
* line is what makes the
|
|
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
|
|
152
|
-
// the edges of a window, but the sentence must state what is actually
|
|
153
|
-
// Inside the clamp bands the two can disagree — a true sentence
|
|
154
|
-
//
|
|
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
|
@@ -401,48 +401,58 @@
|
|
|
401
401
|
@keyframes sheet-up { from { transform: translateY(100%); } to { transform: translateY(0); } }
|
|
402
402
|
@media (prefers-reduced-motion: reduce) { .led.busy { animation: none; } body.picker-open #tabbar { animation: none; } }
|
|
403
403
|
header .spacer { flex: 1; }
|
|
404
|
-
/* Quota
|
|
405
|
-
A
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
.
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
}
|
|
431
|
-
.
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
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);
|
|
443
453
|
}
|
|
444
454
|
@media (prefers-reduced-motion: reduce) {
|
|
445
|
-
.
|
|
455
|
+
.q-fill, .q-pct, .q-mark { transition: none; }
|
|
446
456
|
}
|
|
447
457
|
button {
|
|
448
458
|
font-family: var(--sans);
|
|
@@ -1211,6 +1221,38 @@
|
|
|
1211
1221
|
padding: 6px 10px; background: var(--bg-inset); border: 1px solid var(--line); border-radius: 8px;
|
|
1212
1222
|
color: var(--text-dim); text-decoration: none; font-family: var(--mono); font-size: 12px; }
|
|
1213
1223
|
.att-file:hover { color: var(--amber); border-color: var(--amber); }
|
|
1224
|
+
/* Any image in the chat (agent file card, user attachment, markdown image)
|
|
1225
|
+
opens full-size in the lightbox on click. */
|
|
1226
|
+
.file-img img, .att-img img, .bubble.md img { cursor: zoom-in; }
|
|
1227
|
+
#lightbox {
|
|
1228
|
+
position: fixed; inset: 0; z-index: 5000;
|
|
1229
|
+
display: flex; align-items: center; justify-content: center;
|
|
1230
|
+
padding: 40px; background: rgba(6, 7, 10, 0.85);
|
|
1231
|
+
-webkit-backdrop-filter: blur(2px); backdrop-filter: blur(2px);
|
|
1232
|
+
}
|
|
1233
|
+
#lightbox[hidden] { display: none; }
|
|
1234
|
+
#lightbox img {
|
|
1235
|
+
max-width: 94vw; max-height: 86vh; object-fit: contain;
|
|
1236
|
+
border-radius: 8px; box-shadow: 0 16px 50px rgba(0, 0, 0, 0.6);
|
|
1237
|
+
cursor: default; background: var(--bg-raised);
|
|
1238
|
+
}
|
|
1239
|
+
#lightboxClose {
|
|
1240
|
+
position: absolute; top: 14px; right: 16px;
|
|
1241
|
+
width: 40px; height: 40px; border-radius: 50%;
|
|
1242
|
+
display: flex; align-items: center; justify-content: center;
|
|
1243
|
+
font-size: 22px; line-height: 1; color: #fff; cursor: pointer;
|
|
1244
|
+
background: rgba(0, 0, 0, 0.45); border: 1px solid rgba(255, 255, 255, 0.3);
|
|
1245
|
+
}
|
|
1246
|
+
#lightboxClose:hover { background: rgba(0, 0, 0, 0.7); }
|
|
1247
|
+
#lightboxBar { position: absolute; left: 50%; bottom: 20px; transform: translateX(-50%); max-width: 90vw; }
|
|
1248
|
+
#lightboxDl {
|
|
1249
|
+
display: inline-block; max-width: 100%; overflow: hidden; text-overflow: ellipsis;
|
|
1250
|
+
white-space: nowrap; color: #fff; text-decoration: none;
|
|
1251
|
+
font-family: var(--mono); font-size: 12px;
|
|
1252
|
+
background: rgba(0, 0, 0, 0.5); border: 1px solid rgba(255, 255, 255, 0.25);
|
|
1253
|
+
border-radius: 8px; padding: 7px 14px;
|
|
1254
|
+
}
|
|
1255
|
+
#lightboxDl:hover { background: rgba(0, 0, 0, 0.75); }
|
|
1214
1256
|
.att-file .att-ic { color: var(--amber); flex: none; }
|
|
1215
1257
|
.att-file .att-nm { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
1216
1258
|
/* Composer attachment strip: the SAME dropped/pasted files, previewed above the
|
|
@@ -1736,8 +1778,12 @@
|
|
|
1736
1778
|
phone it moves into the ⋯ menu (#starMenuItem) instead. */
|
|
1737
1779
|
#starBtn { display: none; }
|
|
1738
1780
|
#moreMenu #starMenuItem { display: block; }
|
|
1739
|
-
|
|
1740
|
-
|
|
1781
|
+
/* Narrower bar + smaller label column so the two stacked meters fit the
|
|
1782
|
+
nowrap header beside the brand and the ⋯ menu. */
|
|
1783
|
+
.quotas { gap: 4px; }
|
|
1784
|
+
.quota { grid-template-columns: 16px 62px 28px; column-gap: 6px; }
|
|
1785
|
+
.quota .q-lbl { font-size: 10px; }
|
|
1786
|
+
.quota .q-pct { font-size: 11px; }
|
|
1741
1787
|
/* A bit more room for a thumb, a bit less margin wasted on a narrow screen. */
|
|
1742
1788
|
.transcript { padding: 14px 14px 6px; }
|
|
1743
1789
|
#composer { padding: 10px 14px calc(12px + env(safe-area-inset-bottom, 0px)); gap: 8px; }
|
|
@@ -2428,23 +2474,17 @@
|
|
|
2428
2474
|
<header>
|
|
2429
2475
|
<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>
|
|
2430
2476
|
<span class="spacer"></span>
|
|
2431
|
-
<div class="
|
|
2432
|
-
<
|
|
2433
|
-
<
|
|
2434
|
-
<
|
|
2435
|
-
<
|
|
2436
|
-
|
|
2437
|
-
|
|
2438
|
-
|
|
2439
|
-
|
|
2440
|
-
|
|
2441
|
-
|
|
2442
|
-
<g class="arc"></g>
|
|
2443
|
-
<line class="tick" x1="28" y1="5" x2="28" y2="2" visibility="hidden"></line>
|
|
2444
|
-
<line class="needle" x1="28" y1="13" x2="28" y2="5" visibility="hidden"></line>
|
|
2445
|
-
<text class="pct" x="28" y="28" text-anchor="middle">—</text>
|
|
2446
|
-
<text class="unit" x="28" y="41" text-anchor="middle">7d</text>
|
|
2447
|
-
</svg>
|
|
2477
|
+
<div class="quotas">
|
|
2478
|
+
<div class="quota" id="quota5h" data-window="5h" role="img" aria-label="5h rolling limit" title="5h rolling limit">
|
|
2479
|
+
<span class="q-lbl">5h</span>
|
|
2480
|
+
<div class="q-track"><div class="q-fill"></div><div class="q-mark" hidden></div></div>
|
|
2481
|
+
<span class="q-pct">—</span>
|
|
2482
|
+
</div>
|
|
2483
|
+
<div class="quota" id="quota7d" data-window="7d" role="img" aria-label="7d rolling limit" title="7d rolling limit">
|
|
2484
|
+
<span class="q-lbl">7d</span>
|
|
2485
|
+
<div class="q-track"><div class="q-fill"></div><div class="q-mark" hidden></div></div>
|
|
2486
|
+
<span class="q-pct">—</span>
|
|
2487
|
+
</div>
|
|
2448
2488
|
</div>
|
|
2449
2489
|
<!-- The global actions, in ONE container. `display: contents` on desktop: they
|
|
2450
2490
|
stay direct items of the bar and the layout does not move a pixel. On a
|
|
@@ -3194,6 +3234,13 @@
|
|
|
3194
3234
|
</aside>
|
|
3195
3235
|
</main>
|
|
3196
3236
|
|
|
3237
|
+
<!-- Full-size image viewer: click any chat image to open it here. -->
|
|
3238
|
+
<div id="lightbox" role="dialog" aria-modal="true" aria-label="Image preview" hidden>
|
|
3239
|
+
<button id="lightboxClose" type="button" aria-label="Close image preview">×</button>
|
|
3240
|
+
<img id="lightboxImg" alt="">
|
|
3241
|
+
<div id="lightboxBar"><a id="lightboxDl" download>⭳ Download</a></div>
|
|
3242
|
+
</div>
|
|
3243
|
+
|
|
3197
3244
|
<link rel="stylesheet" href="/vendor/xterm.css">
|
|
3198
3245
|
<script src="/vendor/xterm.js"></script>
|
|
3199
3246
|
<script src="/vendor/addon-fit.js"></script>
|
|
@@ -3226,7 +3273,7 @@
|
|
|
3226
3273
|
applyReadonlyPreset, isManagedProfile, profileSaveBody, secretUsers } from "/profile-card.js?v=__ASSET_V__";
|
|
3227
3274
|
import { notifyState, BLINK_MS } from "/notify.js?v=__ASSET_V__";
|
|
3228
3275
|
import { echoAuthor, isOwnPrompt } from "/echo-author.js?v=__ASSET_V__";
|
|
3229
|
-
import { dialPos, dialColor,
|
|
3276
|
+
import { dialPos, dialColor, dialTitle } from "/gauge-dial.js?v=__ASSET_V__";
|
|
3230
3277
|
import { TOUR_STEPS, bubblePlacement, unionRect, visibleSteps } from "/tour-steps.js?v=__ASSET_V__";
|
|
3231
3278
|
import { pickChannelSource, dirKey, serverSwapped } from "/channel-store.js?v=__ASSET_V__";
|
|
3232
3279
|
import { MODEL_CHOICES, composeModel } from "/model-choice.js?v=__ASSET_V__";
|
|
@@ -3292,9 +3339,7 @@
|
|
|
3292
3339
|
window.BLINK_MS = BLINK_MS;
|
|
3293
3340
|
window.dialPos = dialPos;
|
|
3294
3341
|
window.dialColor = dialColor;
|
|
3295
|
-
window.arcSegments = arcSegments;
|
|
3296
3342
|
window.dialTitle = dialTitle;
|
|
3297
|
-
window.SWEEP_DEG = SWEEP_DEG;
|
|
3298
3343
|
// Les bouchons du script classique sont maintenant remplacés par les vraies
|
|
3299
3344
|
// fonctions : tout ce qui a pu être peint entre-temps est incomplet.
|
|
3300
3345
|
window.dispatchEvent(new Event("shadok-bridge-ready"));
|
|
@@ -3341,7 +3386,6 @@
|
|
|
3341
3386
|
notifyState: () => ({ color: null, badge: "", blink: false }),
|
|
3342
3387
|
dialPos: () => 0,
|
|
3343
3388
|
dialColor: () => "",
|
|
3344
|
-
arcSegments: () => [],
|
|
3345
3389
|
dialTitle: () => "",
|
|
3346
3390
|
bubblePlacement: () => null,
|
|
3347
3391
|
unionRect: () => null,
|
|
@@ -9072,6 +9116,44 @@
|
|
|
9072
9116
|
sendTo(active, { type: "key", key: b.dataset.key });
|
|
9073
9117
|
})
|
|
9074
9118
|
);
|
|
9119
|
+
// ── Image lightbox: click any chat image to view it full-size ───────────
|
|
9120
|
+
// One viewer for every image in the chat — agent file cards (`.file-img`), the
|
|
9121
|
+
// user's own attachments (`.att-img`, whose <a> would otherwise open a new
|
|
9122
|
+
// tab) and images inside rendered markdown (`.bubble.md img`). A delegated
|
|
9123
|
+
// click on the transcripts container covers every tab with one listener.
|
|
9124
|
+
const lightbox = $("lightbox"), lightboxImg = $("lightboxImg"), lightboxDl = $("lightboxDl");
|
|
9125
|
+
let lightboxReturnFocus = null;
|
|
9126
|
+
function openLightbox(src, name) {
|
|
9127
|
+
if (!src) return;
|
|
9128
|
+
lightboxImg.src = src;
|
|
9129
|
+
lightboxImg.alt = name || "";
|
|
9130
|
+
lightboxDl.href = src;
|
|
9131
|
+
lightboxDl.download = name || "image";
|
|
9132
|
+
lightboxDl.textContent = name ? "⭳ " + name : "⭳ Download";
|
|
9133
|
+
lightboxReturnFocus = document.activeElement;
|
|
9134
|
+
lightbox.hidden = false;
|
|
9135
|
+
$("lightboxClose").focus();
|
|
9136
|
+
}
|
|
9137
|
+
function closeLightbox() {
|
|
9138
|
+
if (lightbox.hidden) return;
|
|
9139
|
+
lightbox.hidden = true;
|
|
9140
|
+
lightboxImg.removeAttribute("src"); // stop the load, drop the decode
|
|
9141
|
+
if (lightboxReturnFocus && lightboxReturnFocus.focus) lightboxReturnFocus.focus();
|
|
9142
|
+
lightboxReturnFocus = null;
|
|
9143
|
+
}
|
|
9144
|
+
transcripts.addEventListener("click", (e) => {
|
|
9145
|
+
const img = e.target.closest("img");
|
|
9146
|
+
if (!img || !img.closest(".file-img, .att-img, .bubble.md")) return;
|
|
9147
|
+
e.preventDefault(); // an .att-img image sits in an <a target="_blank">
|
|
9148
|
+
openLightbox(img.currentSrc || img.src, img.alt || "");
|
|
9149
|
+
});
|
|
9150
|
+
lightbox.addEventListener("click", (e) => {
|
|
9151
|
+
// A click anywhere but the image itself (or the download link) closes it.
|
|
9152
|
+
if (e.target === lightboxImg || e.target.closest("#lightboxDl")) return;
|
|
9153
|
+
closeLightbox();
|
|
9154
|
+
});
|
|
9155
|
+
$("lightboxClose").addEventListener("click", closeLightbox);
|
|
9156
|
+
|
|
9075
9157
|
// The Escape key sends esc to the active session (like the engine room's Esc
|
|
9076
9158
|
// button). Precedence: an open overlay/panel is closed first (Escape does not
|
|
9077
9159
|
// reach down to the session); a pending clickable dialog is left intact;
|
|
@@ -9080,6 +9162,9 @@
|
|
|
9080
9162
|
// The inline edit calls stopPropagation, so this handler never sees it.
|
|
9081
9163
|
document.addEventListener("keydown", (e) => {
|
|
9082
9164
|
if (e.key !== "Escape") return;
|
|
9165
|
+
// The image viewer sits on top of everything, including the terminal, so it
|
|
9166
|
+
// is closed before any other Escape handling.
|
|
9167
|
+
if (!lightbox.hidden) { closeLightbox(); e.preventDefault(); return; }
|
|
9083
9168
|
// In the interactive terminal, xterm owns every key (incl. Esc) — the raw
|
|
9084
9169
|
// keystroke already flows to the pane via term.onData; don't double-handle.
|
|
9085
9170
|
if (active && active.termOn) return;
|
|
@@ -11205,55 +11290,42 @@
|
|
|
11205
11290
|
/** Last verdict from /usage — read by the composer before sending. */
|
|
11206
11291
|
let paceBlocked = false, paceReason = null;
|
|
11207
11292
|
|
|
11208
|
-
/** Paints the static gradient arc of every dial. Runs once: the scale never
|
|
11209
|
-
* changes, only the needle on top of it does. */
|
|
11210
|
-
function buildDialArcs() {
|
|
11211
|
-
if (!window.arcSegments) return;
|
|
11212
|
-
const NS = "http://www.w3.org/2000/svg";
|
|
11213
|
-
for (const g of document.querySelectorAll(".quota .dial .arc")) {
|
|
11214
|
-
g.replaceChildren(
|
|
11215
|
-
...window.arcSegments().map(({ d, color }) => {
|
|
11216
|
-
const p = document.createElementNS(NS, "path");
|
|
11217
|
-
p.setAttribute("d", d);
|
|
11218
|
-
// Via the style PROPERTY: color-mix() is not reliably parsed as an
|
|
11219
|
-
// SVG presentation attribute.
|
|
11220
|
-
p.style.stroke = color;
|
|
11221
|
-
return p;
|
|
11222
|
-
}),
|
|
11223
|
-
);
|
|
11224
|
-
}
|
|
11225
|
-
}
|
|
11226
|
-
|
|
11227
11293
|
function paintGauge(el, w) {
|
|
11228
|
-
const
|
|
11229
|
-
const
|
|
11230
|
-
const
|
|
11231
|
-
const pct = svg.querySelector(".pct");
|
|
11294
|
+
const fill = el.querySelector(".q-fill");
|
|
11295
|
+
const mark = el.querySelector(".q-mark");
|
|
11296
|
+
const pct = el.querySelector(".q-pct");
|
|
11232
11297
|
const label = el.dataset.window;
|
|
11233
11298
|
const title = window.dialTitle
|
|
11234
11299
|
? window.dialTitle(label, w, w ? fmtReset(w.resetsAt) : "")
|
|
11235
11300
|
: label + " rolling limit";
|
|
11236
11301
|
el.title = title;
|
|
11237
|
-
|
|
11238
|
-
|
|
11239
|
-
|
|
11240
|
-
|
|
11241
|
-
|
|
11242
|
-
|
|
11243
|
-
|
|
11244
|
-
tick.setAttribute("visibility", "hidden");
|
|
11245
|
-
pct.textContent = w ? Math.round(w.usedPercentage) + "%" : "—";
|
|
11246
|
-
pct.style.fill = "var(--text-dim)";
|
|
11302
|
+
el.setAttribute("aria-label", title);
|
|
11303
|
+
el.classList.toggle("nodata", !w);
|
|
11304
|
+
if (!w) {
|
|
11305
|
+
fill.style.width = "0%";
|
|
11306
|
+
mark.hidden = true;
|
|
11307
|
+
pct.textContent = "—";
|
|
11308
|
+
pct.style.color = "var(--text-dim)";
|
|
11247
11309
|
return;
|
|
11248
11310
|
}
|
|
11249
|
-
|
|
11250
|
-
|
|
11251
|
-
|
|
11252
|
-
//
|
|
11253
|
-
//
|
|
11254
|
-
|
|
11311
|
+
// Bar LENGTH is the consumption, straight from the number (no maths).
|
|
11312
|
+
const used = Math.max(0, Math.min(100, w.usedPercentage));
|
|
11313
|
+
fill.style.width = used + "%";
|
|
11314
|
+
// The fill COLOUR encodes ahead-of / behind-the-clock. That needs the
|
|
11315
|
+
// piecewise pace position; without the ESM bridge (invariant 10) this paint
|
|
11316
|
+
// can run before the module lands — fall back to a neutral bar (still the
|
|
11317
|
+
// right length) rather than a wrong tint.
|
|
11318
|
+
const color = window.dialPos
|
|
11319
|
+
? window.dialColor(window.dialPos(w.usedPercentage, w.idealPacePct))
|
|
11320
|
+
: "var(--text-dim)";
|
|
11321
|
+
fill.style.setProperty("--c", color);
|
|
11255
11322
|
pct.textContent = Math.round(w.usedPercentage) + "%";
|
|
11256
|
-
pct.style.
|
|
11323
|
+
pct.style.color = color;
|
|
11324
|
+
// The marker is the ideal pace (the window's elapsed-time share). No reset
|
|
11325
|
+
// time → no pace → hide it rather than assert a reference we do not have.
|
|
11326
|
+
const hasPace = w.idealPacePct != null;
|
|
11327
|
+
mark.hidden = !hasPace;
|
|
11328
|
+
if (hasPace) mark.style.left = Math.max(0, Math.min(100, w.idealPacePct)) + "%";
|
|
11257
11329
|
}
|
|
11258
11330
|
|
|
11259
11331
|
async function refreshUsage() {
|
|
@@ -11272,10 +11344,11 @@
|
|
|
11272
11344
|
paceReason = null;
|
|
11273
11345
|
}
|
|
11274
11346
|
}
|
|
11275
|
-
//
|
|
11276
|
-
//
|
|
11347
|
+
// A deferred module script runs after parsing but BEFORE DOMContentLoaded, so
|
|
11348
|
+
// window.dialPos & co (the ESM bridge, invariant 10) are ready by here and the
|
|
11349
|
+
// first paint gets the right colour; paintGauge still degrades to a neutral bar
|
|
11350
|
+
// if that ever fails to hold.
|
|
11277
11351
|
document.addEventListener("DOMContentLoaded", () => {
|
|
11278
|
-
buildDialArcs();
|
|
11279
11352
|
refreshUsage();
|
|
11280
11353
|
refreshAuthRows(); // paints the re-login reminder banner if the token is due
|
|
11281
11354
|
maybeStartTour(); // the tour runs first; it calls checkAuth() when it ends
|
package/public/tour-steps.js
CHANGED
|
@@ -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.
|
|
122
|
-
"spend the window exactly on time
|
|
123
|
-
"To the left, the cockpit's name is yours to
|
|
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
|
];
|