omp-llm-role 1.1.0 → 1.3.0
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 +75 -13
- package/docs/dev/architecture.md +35 -11
- package/docs/dev/ci.md +21 -1
- package/docs/dev/current-state.md +99 -27
- package/docs/dev/data-sources.md +33 -4
- package/docs/dev/explorer.md +126 -21
- package/docs/dev/quirks.md +32 -0
- package/docs/dev/releasing.md +63 -32
- package/docs/dev/scoring.md +153 -39
- package/docs/dev/testing.md +112 -24
- package/docs/dev/writes-and-state.md +86 -0
- package/docs/llm-role-rankings.md +126 -126
- package/package.json +92 -22
- package/src/agent-create.ts +4 -2
- package/src/availability.ts +87 -9
- package/src/cli/create-role.ts +1 -1
- package/src/engine.ts +190 -78
- package/src/explorer/boot.ts +43 -1
- package/src/explorer/explain.ts +113 -10
- package/src/explorer/levers.ts +100 -0
- package/src/explorer/pool.ts +46 -0
- package/src/explorer/server.ts +107 -23
- package/src/extension.ts +189 -16
- package/src/features.ts +9 -7
- package/src/provider-display.ts +67 -0
- package/src/settings.ts +39 -2
- package/src/updater.ts +97 -14
- package/src/usage-ledger.ts +127 -0
- package/web/app.js +156 -1
- package/web/style.css +3 -0
package/web/app.js
CHANGED
|
@@ -23,6 +23,11 @@ const state = {
|
|
|
23
23
|
lockPath: "",
|
|
24
24
|
scopes: [],
|
|
25
25
|
activeScope: "",
|
|
26
|
+
// The ranked pool the server is serving (raw | as-written) and the user-set
|
|
27
|
+
// token profile the per-task price falls back to (null = unset). Both come
|
|
28
|
+
// from the bootstrap payload, so a refresh keeps them.
|
|
29
|
+
poolMode: "raw",
|
|
30
|
+
tokenProfile: null,
|
|
26
31
|
features: {},
|
|
27
32
|
featureRegistry: [],
|
|
28
33
|
filter: "",
|
|
@@ -82,6 +87,26 @@ function fmtCtx(value) {
|
|
|
82
87
|
return value >= 1000 ? Math.round(value / 1000) + "K" : String(value);
|
|
83
88
|
}
|
|
84
89
|
|
|
90
|
+
/** Dollars per task, at a precision that keeps the small values readable: a
|
|
91
|
+
* per-task price spans roughly $0.0001 (a cheap model, a short task) to $1+. */
|
|
92
|
+
function fmtUsd(value) {
|
|
93
|
+
if (value === null || value === undefined) return "—";
|
|
94
|
+
const abs = Math.abs(value);
|
|
95
|
+
if (abs >= 1) return "$" + value.toFixed(2);
|
|
96
|
+
if (abs >= 0.01) return "$" + value.toFixed(3);
|
|
97
|
+
if (abs > 0) return "$" + value.toFixed(4);
|
|
98
|
+
return "$0";
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Token counts, compact: 20000 → "20K", 1500 → "1.5K", 800 → "800". */
|
|
102
|
+
function fmtTokens(value) {
|
|
103
|
+
if (value >= 1000) {
|
|
104
|
+
const k = value / 1000;
|
|
105
|
+
return (k >= 10 ? Math.round(k) : Math.round(k * 10) / 10) + "K";
|
|
106
|
+
}
|
|
107
|
+
return String(value);
|
|
108
|
+
}
|
|
109
|
+
|
|
85
110
|
function deltaText(delta) {
|
|
86
111
|
if (delta === null || delta === undefined || delta === 0) return "·";
|
|
87
112
|
return delta > 0 ? "+" + delta : "−" + Math.abs(delta);
|
|
@@ -121,6 +146,7 @@ const TIPS = {
|
|
|
121
146
|
hideBlocked: "Drop rows your OpenRouter key cannot run (key = blocked). Applied after the text filter and the top-n slice.",
|
|
122
147
|
lock: "Plugin settings lock file that Export writes to (the active scope's lock file).",
|
|
123
148
|
scope: "Role-config source: the user-level scope, or a project where /project-roles was used. Switching re-reads that scope's roles and universe from disk and points Export at its lock file. A project whose lock file is gone is unavailable.",
|
|
149
|
+
pool: "Ranked pool. raw = the full cached dataset (account-agnostic). as-written = the same catalog/tier gate and allowed-providers whitelist prune the updater applies before writing, so the leader you see is the model you will get. Best-effort: a failed whitelist fetch degrades to the raw pool. Export is unaffected — it always writes the authored role.",
|
|
124
150
|
refresh: "Refetch llm-stats and OpenRouter data (hits the network).",
|
|
125
151
|
rank: "Rank among this role's eligible models, by value, descending.",
|
|
126
152
|
delta: "Rank change against the role's saved (effective) definition: + means this definition ranks the model higher than the plugin does today.",
|
|
@@ -128,6 +154,7 @@ const TIPS = {
|
|
|
128
154
|
value: "Sort key: value = q − λ·$/M on the thinking-adjusted effective price. λ is the price of one quality point, in $/M.",
|
|
129
155
|
q: "Quality q = Σ (wᵢ/(1−w_price))·tᵢ over the weighted quality metrics. Price is not blended in — it is the penalty axis.",
|
|
130
156
|
price: "Effective price in $/M: OpenRouter standard-route blend (3:1 in:out) scaled by the role's thinking factor when the model supports thinking; unadjusted for bare/off roles.",
|
|
157
|
+
perTask: "Dollars per task: your own token mix (the role's recorded usage, else the tokenProfile setting) × the role's priced route, thinking-adjusted. Display-only — the ranking still sorts on $/M. — when no token profile is available (no recorded usage and no tokenProfile set).",
|
|
131
158
|
throughput: "Output throughput in tok/s (OpenRouter p50 when matched, else llm-stats).",
|
|
132
159
|
context: "Context window in tokens.",
|
|
133
160
|
explainSub: "value = q − λ·$/M (effective price), where q is the weighted quality sum and λ the price of one quality point.",
|
|
@@ -141,6 +168,9 @@ const TIPS = {
|
|
|
141
168
|
whyNotHigher: "The value gap to the model ranked above, and the per-metric raw target that would close it (inverse of the cardinal transform).",
|
|
142
169
|
dominated: "Models that are both cheaper and at least as good on q.",
|
|
143
170
|
lambda: "λ = price of one quality point, in $/M. Derived as " + LAMBDA_DERIVATION + " unless the role overrides lambda.",
|
|
171
|
+
leaderFlip: "The price weight w* at which a cheaper challenger overtakes the value leader: λ* = min over cheaper challengers X of (q_L − q_X)/(pEff_L − pEff_X), then w* = 20λ*/(1+20λ*). Absent when no cheaper challenger exists. Exact for the 1/price² blend or a hard provider pin; a first-order estimate under the soft best-route basis (the route choice itself moves with the price weight).",
|
|
172
|
+
levers: "Two price-lever readouts, computed on the pool you are viewing: the leader-flip threshold (the price weight at which a cheaper challenger overtakes the leader) and the thinking level's effect on the leader. Each is absent when the server sends null — no cheaper challenger, or a bare role.",
|
|
173
|
+
thinkingLevels: "The leader at each thinking level, with the level's price factor (3ρ+1+T)/(3ρ+1). The factor multiplies the price axis, so it penalizes expensive models more in absolute λ-units — that is why auto favours the cheap model. Absent for a bare role (no thinking level).",
|
|
144
174
|
sum: "Weights must sum to 1.0 (±0.01) or the plugin rejects the role.",
|
|
145
175
|
required: "Eligibility gate, not a weight: a model missing this metric is not ranked at all for the role.",
|
|
146
176
|
focus: "Nine-axis audit of each weighted benchmark/percentile metric, over the same pool the updater ranks on. A below-bar axis is warned; unknown means the metric's scores were not loaded (never a silent ok).",
|
|
@@ -154,6 +184,7 @@ const TIPS = {
|
|
|
154
184
|
focusProvenance: "Provenance: the benchmark's owner (the catalog row's dataset_org_id, falling back to dataset_slug) and the source's own per-entry org mix (the dominant lab and its share). A cross-check on the pool-derived composition axis: agree/disagree records whether the two concur. An annotation, never a gate — a vendor-populated source is warned but never drops a candidate; unknown means there is neither an owner nor a payload org mix.",
|
|
155
185
|
focusCrossSource: "Cross-source: the zeroeval per-entry price/context compared against the plugin's own price/context for the models carrying the metric (the median relative divergence, within a 0.5 tolerance). The speed figure is informational only (rps vs tok/s). An annotation, never a gate — it never feeds the 1/price² blend; unknown means no model joined or the payload carried none of the four fields.",
|
|
156
186
|
imageFilter: "Require image input (filters.image) — the gate that shrinks the vision role's eligible set.",
|
|
187
|
+
providerPin: "Hard provider pin: the OpenRouter provider slug this role's requests are routed to (a tiered slug like deepinfra/fp8 is accepted verbatim). Setting it opts the role out of the automatic best-route pin; empty = default OpenRouter routing.",
|
|
157
188
|
features: "Opt-in capability presets (issue #40). Each flag applies the plugin's recommended settings for that capability to this role; an explicit knob value always wins. Export writes the flag, not the expanded values, so a future change to the recommendation keeps reaching the role.",
|
|
158
189
|
featureState: "inherit = use the global flag (features.<id> in the lock file); on/off = a per-role override (roles.<role>.features.<id>) that beats the global in either direction.",
|
|
159
190
|
featureKnob: "An individual knob the capability's recommended bundle fills. Leave it at inherit to take the recommendation; set a value to override it (an explicit value always wins over the flag).",
|
|
@@ -266,6 +297,20 @@ function scopeSelect() {
|
|
|
266
297
|
return sel;
|
|
267
298
|
}
|
|
268
299
|
|
|
300
|
+
/** The pool selector: the raw dataset (account-agnostic) or the as-written pool
|
|
301
|
+
* (the catalog/tier gate + allowed-providers prune the updater ranks on). The
|
|
302
|
+
* value is the server's mode, so the header always names the active pool. */
|
|
303
|
+
function poolSelect() {
|
|
304
|
+
const sel = el("select", { id: "pool", "data-tip": TIPS.pool });
|
|
305
|
+
sel.append(
|
|
306
|
+
el("option", { value: "raw", text: "raw pool" }),
|
|
307
|
+
el("option", { value: "as-written", text: "as-written pool" }),
|
|
308
|
+
);
|
|
309
|
+
sel.value = state.poolMode;
|
|
310
|
+
sel.addEventListener("change", onPoolChange);
|
|
311
|
+
return sel;
|
|
312
|
+
}
|
|
313
|
+
|
|
269
314
|
function renderMeta(data) {
|
|
270
315
|
const meta = document.getElementById("meta");
|
|
271
316
|
meta.textContent = "";
|
|
@@ -274,6 +319,8 @@ function renderMeta(data) {
|
|
|
274
319
|
el("span", { class: "sep", text: "·" }),
|
|
275
320
|
scopeSelect(),
|
|
276
321
|
el("span", { class: "sep", text: "·" }),
|
|
322
|
+
poolSelect(),
|
|
323
|
+
el("span", { class: "sep", text: "·" }),
|
|
277
324
|
el("span", { "data-tip": TIPS.models, text: data.modelCount + " models" }),
|
|
278
325
|
el("span", { class: "sep", text: "·" }),
|
|
279
326
|
el("span", { "data-tip": TIPS.fetched, text: "fetched " + data.fetchedAt.slice(0, 10) }),
|
|
@@ -425,6 +472,7 @@ function renderTable() {
|
|
|
425
472
|
el("th", { class: "num", "data-tip": TIPS.value, text: "value" }),
|
|
426
473
|
el("th", { class: "num", "data-tip": TIPS.q, text: "q" }),
|
|
427
474
|
el("th", { class: "num", "data-tip": TIPS.price, text: "$/M" }),
|
|
475
|
+
el("th", { class: "num", "data-tip": TIPS.perTask, text: "$/task" }),
|
|
428
476
|
el("th", { class: "num", "data-tip": TIPS.throughput, text: "tok/s" }),
|
|
429
477
|
el("th", { class: "num", "data-tip": TIPS.context, text: "ctx" }),
|
|
430
478
|
])),
|
|
@@ -449,6 +497,7 @@ function renderTable() {
|
|
|
449
497
|
el("td", { class: "num", text: fmt(r.value, 3) }),
|
|
450
498
|
el("td", { class: "num", text: fmt(r.q, 3) }),
|
|
451
499
|
el("td", { class: "num", text: fmt(r.priceEff, 2) }),
|
|
500
|
+
el("td", { class: "num", text: fmtUsd(r.perTask) }),
|
|
452
501
|
el("td", { class: "num", text: r.throughput === null ? "—" : fmt(r.throughput, 0) }),
|
|
453
502
|
el("td", { class: "num", text: r.context === null ? "—" : fmtCtx(r.context) }),
|
|
454
503
|
]),
|
|
@@ -507,6 +556,15 @@ function renderExplain() {
|
|
|
507
556
|
if (ex.keyReason !== "active") keyLine += " (reason: " + ex.keyReason + ")";
|
|
508
557
|
panel.append(el("p", { class: "sub key-line", "data-tip": TIPS.key, text: keyLine }));
|
|
509
558
|
|
|
559
|
+
// The pin basis: a manual pin (from the edited def) overrides the automatic
|
|
560
|
+
// best-route pin the updater would write. Read-only here.
|
|
561
|
+
const manualPin = state.defs[state.role]?.providerPin;
|
|
562
|
+
if (manualPin) {
|
|
563
|
+
panel.append(el("p", { class: "sub pin-line", "data-tip": TIPS.providerPin, text: "pin: " + manualPin + " (manual)" }));
|
|
564
|
+
} else if (ex.autoPin) {
|
|
565
|
+
panel.append(el("p", { class: "sub pin-line", "data-tip": TIPS.providerPin, text: "auto pin: " + ex.autoPin }));
|
|
566
|
+
}
|
|
567
|
+
|
|
510
568
|
panel.append(el("h3", { "data-tip": TIPS.composition, text: "Value composition" }));
|
|
511
569
|
const comp = el("table", { class: "comp" });
|
|
512
570
|
comp.append(
|
|
@@ -549,9 +607,26 @@ function renderExplain() {
|
|
|
549
607
|
panel.append(el("p", { class: "cost", "data-tip": TIPS.lambda, text: "λ = " + ex.role.lambda.toFixed(5) + " $/quality-point" + (derived ? " (derived = " + LAMBDA_DERIVATION + ")" : " (override)") }));
|
|
550
608
|
const thinkNote = ex.role.thinking === undefined ? "bare" : ":" + ex.role.thinking;
|
|
551
609
|
const cacheNote = ex.role.cacheHitRate > 0 ? " · cache " + ex.role.cacheHitRate : "";
|
|
552
|
-
const ownNote = ex.ownProvider ? " ·
|
|
610
|
+
const ownNote = ex.ownProvider ? " · best route" : "";
|
|
553
611
|
panel.append(el("p", { class: "cost", "data-tip": TIPS.costPenalty, text: "eff price $" + fmt(ex.cost.priceEff, 2) + "/M" + (ex.cost.priceEff !== ex.cost.billedPrice ? " (billed $" + fmt(ex.cost.billedPrice, 2) + " × " + thinkNote + ")" : " (" + thinkNote + ")") + cacheNote + ownNote + " · penalty = λ·price = " + fmt(ex.cost.penalty, 4) + " · value = q − penalty = " + fmt(ex.cost.value, 4) }));
|
|
554
612
|
|
|
613
|
+
// The per-task price breakdown: display-only, so it sits apart from the
|
|
614
|
+
// ranking's cost line. A muted note when no token profile resolved (never a
|
|
615
|
+
// silent zero).
|
|
616
|
+
panel.append(el("h3", { "data-tip": TIPS.perTask, text: "Per-task price" }));
|
|
617
|
+
if (ex.perTask) {
|
|
618
|
+
const p = ex.perTask;
|
|
619
|
+
panel.append(
|
|
620
|
+
el("p", {
|
|
621
|
+
class: "cost",
|
|
622
|
+
"data-tip": TIPS.perTask,
|
|
623
|
+
text: "per task: " + fmtTokens(p.inputTokens) + " in × $" + fmt(p.inputPrice, 3) + "/M + " + fmtTokens(p.outputTokens) + " out × $" + fmt(p.outputPrice, 3) + "/M × " + p.thinkingFactor.toFixed(3) + " = " + fmtUsd(p.total),
|
|
624
|
+
}),
|
|
625
|
+
);
|
|
626
|
+
} else {
|
|
627
|
+
panel.append(el("p", { class: "cost muted", "data-tip": TIPS.perTask, text: "per task: — (no token profile — record usage or set tokenProfile)" }));
|
|
628
|
+
}
|
|
629
|
+
|
|
555
630
|
panel.append(el("h3", { "data-tip": TIPS.whyNotHigher, text: "Why not higher" }));
|
|
556
631
|
if (ex.gapAbove === null) {
|
|
557
632
|
panel.append(el("p", { class: "muted", text: "Already #1 for this role." }));
|
|
@@ -583,6 +658,53 @@ function renderExplain() {
|
|
|
583
658
|
for (const d of ex.dominators) ul.append(el("li", { text: d.name + " — $" + fmt(d.priceEff, 2) + "/M, q " + fmt(d.q, 3) }));
|
|
584
659
|
panel.append(ul);
|
|
585
660
|
}
|
|
661
|
+
|
|
662
|
+
// The two price-lever readouts are role-level (computed on the pool the
|
|
663
|
+
// ranking above uses), so they close the panel. Each is absent when the
|
|
664
|
+
// server sends null: no cheaper challenger, or a bare role.
|
|
665
|
+
const flip = ex.leaderFlip;
|
|
666
|
+
const levels = ex.thinkingLevels;
|
|
667
|
+
if (flip || levels) {
|
|
668
|
+
panel.append(el("h3", { "data-tip": TIPS.levers, text: "Price levers" }));
|
|
669
|
+
if (flip) {
|
|
670
|
+
const ratio = flip.wPrice > 0 ? (flip.flipW / flip.wPrice).toFixed(2) + "×" : "—";
|
|
671
|
+
panel.append(
|
|
672
|
+
el("p", {
|
|
673
|
+
class: "cost",
|
|
674
|
+
"data-tip": TIPS.leaderFlip,
|
|
675
|
+
text: "leader flip: w* = " + flip.flipW.toFixed(3) + " · now " + flip.wPrice.toFixed(3) + " · distance " + (flip.distance >= 0 ? "+" : "−") + Math.abs(flip.distance).toFixed(3) + " (" + ratio + ")",
|
|
676
|
+
}),
|
|
677
|
+
);
|
|
678
|
+
panel.append(
|
|
679
|
+
el("p", {
|
|
680
|
+
class: "cost",
|
|
681
|
+
text: "challenger: " + flip.challenger.name + " — $" + fmt(flip.challenger.priceEff, 2) + "/M, value " + fmt(flip.challenger.value, 3),
|
|
682
|
+
}),
|
|
683
|
+
);
|
|
684
|
+
}
|
|
685
|
+
if (levels) {
|
|
686
|
+
const t = el("table", { class: "comp" });
|
|
687
|
+
t.append(
|
|
688
|
+
el("thead", {}, el("tr", {}, [
|
|
689
|
+
el("th", { text: "level" }),
|
|
690
|
+
el("th", { class: "num", "data-tip": TIPS.thinking, text: "×price" }),
|
|
691
|
+
el("th", { "data-tip": TIPS.thinkingLevels, text: "leader" }),
|
|
692
|
+
])),
|
|
693
|
+
);
|
|
694
|
+
const tb = el("tbody");
|
|
695
|
+
for (const row of levels) {
|
|
696
|
+
tb.append(
|
|
697
|
+
el("tr", { class: row.level === ex.role.thinking ? "active-level" : "" }, [
|
|
698
|
+
el("td", { text: row.level }),
|
|
699
|
+
el("td", { class: "num", text: "×" + row.factor.toFixed(3) }),
|
|
700
|
+
el("td", { text: row.leader ? row.leader.name + " (" + fmt(row.leader.value, 3) + ")" : "—" }),
|
|
701
|
+
]),
|
|
702
|
+
);
|
|
703
|
+
}
|
|
704
|
+
t.append(tb);
|
|
705
|
+
panel.append(t);
|
|
706
|
+
}
|
|
707
|
+
}
|
|
586
708
|
}
|
|
587
709
|
|
|
588
710
|
// ---------------------------------------------------------------------------
|
|
@@ -1011,6 +1133,19 @@ function renderEditor() {
|
|
|
1011
1133
|
});
|
|
1012
1134
|
panel.append(el("label", { class: "check" }, [imgCb, el("span", { "data-tip": TIPS.imageFilter, text: "requires image input (filters.image)" })]));
|
|
1013
1135
|
|
|
1136
|
+
panel.append(el("h3", { "data-tip": TIPS.providerPin, text: "Provider pin" }));
|
|
1137
|
+
const pin = el("input", { type: "text", class: "desc", "data-tip": TIPS.providerPin, value: def.providerPin || "", placeholder: "provider slug, e.g. deepinfra/fp8" });
|
|
1138
|
+
pin.addEventListener("change", () => {
|
|
1139
|
+
// Empty clears the key (default routing); a tiered slug is kept verbatim.
|
|
1140
|
+
const value = pin.value.trim();
|
|
1141
|
+
if (value === "") delete def.providerPin;
|
|
1142
|
+
else def.providerPin = value;
|
|
1143
|
+
renderEditor();
|
|
1144
|
+
renderRoles();
|
|
1145
|
+
scheduleRecompute();
|
|
1146
|
+
});
|
|
1147
|
+
panel.append(el("div", { class: "row-controls" }, [pin]));
|
|
1148
|
+
|
|
1014
1149
|
renderFeatures(panel);
|
|
1015
1150
|
|
|
1016
1151
|
panel.append(el("h3", { "data-tip": TIPS.thinking, text: "Thinking level" }));
|
|
@@ -1162,6 +1297,24 @@ async function onScopeChange(event) {
|
|
|
1162
1297
|
}
|
|
1163
1298
|
}
|
|
1164
1299
|
|
|
1300
|
+
/** Switch the ranked pool (raw ↔ as-written). Unsaved edits are confirmed first
|
|
1301
|
+
* (mirroring the scope switcher); the returned payload rebuilds every panel, so
|
|
1302
|
+
* the header's model count and the table describe the new pool. */
|
|
1303
|
+
async function onPoolChange(event) {
|
|
1304
|
+
const mode = event.target.value;
|
|
1305
|
+
if (mode === state.poolMode) return;
|
|
1306
|
+
if (Object.keys(dirtyRoles()).length > 0 && !confirm("Discard unsaved role edits and switch pool?")) {
|
|
1307
|
+
event.target.value = state.poolMode;
|
|
1308
|
+
return;
|
|
1309
|
+
}
|
|
1310
|
+
try {
|
|
1311
|
+
applyBootstrap(await api("/api/pool", { mode }));
|
|
1312
|
+
} catch (err) {
|
|
1313
|
+
alert("Pool switch failed: " + err.message);
|
|
1314
|
+
event.target.value = state.poolMode;
|
|
1315
|
+
}
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1165
1318
|
// ---------------------------------------------------------------------------
|
|
1166
1319
|
// Boot
|
|
1167
1320
|
// ---------------------------------------------------------------------------
|
|
@@ -1173,6 +1326,8 @@ function applyBootstrap(data) {
|
|
|
1173
1326
|
const previousRole = state.role;
|
|
1174
1327
|
state.scopes = data.scopes || [];
|
|
1175
1328
|
state.activeScope = data.activeScope;
|
|
1329
|
+
state.poolMode = data.poolMode || "raw";
|
|
1330
|
+
state.tokenProfile = data.tokenProfile ?? null;
|
|
1176
1331
|
state.universe = data.universe;
|
|
1177
1332
|
state.effective = {};
|
|
1178
1333
|
for (const [name, entry] of Object.entries(state.universe)) state.effective[name] = entry.def;
|
package/web/style.css
CHANGED
|
@@ -39,6 +39,7 @@ body {
|
|
|
39
39
|
#meta .sep { color: var(--line); }
|
|
40
40
|
#meta .lock { color: var(--muted); font-family: var(--mono); font-size: 11px; }
|
|
41
41
|
#meta #scope { max-width: 340px; }
|
|
42
|
+
#meta #pool { max-width: 200px; }
|
|
42
43
|
#meta button { margin-left: auto; }
|
|
43
44
|
|
|
44
45
|
#roles {
|
|
@@ -189,6 +190,8 @@ table.comp th { text-align: left; color: var(--muted); font-weight: 500; font-si
|
|
|
189
190
|
table.comp td.num, table.comp th.num { text-align: right; font-family: var(--mono); font-variant-numeric: tabular-nums; }
|
|
190
191
|
table.comp tr.missing-row td { color: var(--muted); }
|
|
191
192
|
table.comp tr.note-row td { border-bottom: none; font-size: 11px; padding-top: 0; }
|
|
193
|
+
/* The thinking-level readout: the role's current level is the row that matters. */
|
|
194
|
+
table.comp tr.active-level td { color: var(--accent); }
|
|
192
195
|
|
|
193
196
|
/* Focus-metric audit: one row per weighted benchmark/percentile metric, four
|
|
194
197
|
signal cells. ok = up, below-bar = warn + glyph, unknown = muted (never ok). */
|