claude-finops 0.11.0 → 0.13.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/web/app.js CHANGED
@@ -61,7 +61,7 @@ const BADGE = {actual: '<span class="badge">Actual</span>',
61
61
  estimated: '<span class="badge est">Estimated</span>',
62
62
  forecast: '<span class="badge fc">Forecast</span>',
63
63
  recommendation: '<span class="badge rec">Recommendation</span>'};
64
- const statusGlyph = s => ({healthy: '🟢', high: '🟡', approaching: '🟠', critical: '🔴'}[s] || '⚪');
64
+ const statusGlyph = s => dot({healthy: 'green', high: 'yellow', approaching: 'orange', critical: 'red'}[s] || 'grey');
65
65
  const statusChip = (s, txt) => `<span class="status ${s}"><span class="glyph">${statusGlyph(s)}</span>${esc(txt || s)}</span>`;
66
66
  const modelColor = m => {
67
67
  const list = (S.opts?.models || []).map(x => x.model);
@@ -82,23 +82,23 @@ const dur = s => s == null ? '—' : s < 60 ? `${Math.round(s)}s`
82
82
  const shortDay = d => (d || '').slice(5);
83
83
  const shortId = s => (s || '').slice(0, 8);
84
84
 
85
- // "(▲12% vs yesterday)"; nothing when yesterday had no spend to compare against.
85
+ // "(up 12% vs yesterday)", with a small up/down triangle icon; nothing when yesterday had no spend to compare against.
86
86
  const vsYesterday = o => {
87
87
  const y = o.cost_yesterday?.c, t = o.cost_today?.c;
88
88
  if (!y || t == null) return '';
89
89
  const d = (t - y) / y * 100;
90
- return ` (${d >= 0 ? '▲' : '▼'}${Math.abs(d).toFixed(0)}% vs yesterday)`;
90
+ return ` (${I(d >= 0 ? 'triUp' : 'triDown')}${Math.abs(d).toFixed(0)}% vs yesterday)`;
91
91
  };
92
92
  function kpi(label, value, detail, opts = {}) {
93
93
  const na = value == null;
94
- return `<div class="kpi${na ? ' na' : ''}">
94
+ return `<div class="kpi${na ? ' na' : ''}${opts.tone && !na ? ' tone-' + opts.tone : ''}"${opts.title ? ` title="${esc(opts.title)}"` : ''}>
95
95
  <div class="l">${esc(label)}${opts.badge ? ' ' + opts.badge : ''}</div>
96
96
  <div class="v${opts.small ? ' sm' : ''}">${na ? esc(naLabel()) : value}</div>
97
97
  ${detail ? `<div class="d">${detail}</div>` : ''}</div>`;
98
98
  }
99
99
  function card(title, bodyHtml, opts = {}) {
100
100
  return `<section class="card"${opts.style ? ` style="${opts.style}"` : ''}>
101
- <header><h3>${esc(title)}</h3>${opts.badge || ''}
101
+ <header><h3>${opts.icon || ''}${esc(title)}</h3>${opts.badge || ''}
102
102
  ${opts.hint ? `<span class="hint">${esc(opts.hint)}</span>` : ''}
103
103
  <span class="spacer"></span>${opts.actions || ''}</header>
104
104
  <div class="body${opts.flush ? ' flush' : ''}">${bodyHtml}</div>
@@ -122,45 +122,141 @@ function wireTable(host, rows, onRow) {
122
122
  /* ============================ chrome ============================ */
123
123
  const NAV = [
124
124
  ['Command center', [
125
- ['hygiene', '◫', 'Context hygiene'],
126
- ['overview', '◧', 'Executive overview'],
127
- ['advisor', '✦', 'What should I do?'],
128
- ['agents', '◎', 'Agents'],
129
- ['live', '⏻', 'Running sessions'],
130
- ['scorecard', '◉', 'FinOps scorecard'],
125
+ ['hygiene', 'gauge', 'Context hygiene'],
126
+ ['overview', 'dashboard', 'Executive overview'],
127
+ ['advisor', 'bulb', 'What should I do?'],
128
+ ['agents', 'bot', 'Agents'],
129
+ ['live', 'activity', 'Running sessions'],
130
+ ['scorecard', 'award', 'FinOps scorecard'],
131
131
  ]],
132
132
  ['Usage', [
133
- ['usage', '▤', 'Usage timeline'],
134
- ['burn', '◑', 'Burn rate & limits', 'priced'],
135
- ['models', '◈', 'Model analysis'],
136
- ['context', '▭', 'Context & cache'],
133
+ ['usage', 'barChart', 'Usage timeline'],
134
+ ['burn', 'flame', 'Burn rate & limits', 'priced'],
135
+ ['models', 'cpu', 'Model analysis'],
136
+ ['context', 'layers', 'Context & cache'],
137
137
  ]],
138
138
  ['Drill-down', [
139
- ['projects', '▣', 'Projects'],
140
- ['sessions', '▦', 'Sessions'],
141
- ['prompts', '☰', 'Prompt explorer'],
142
- ['rankings', '↕', 'Cost rankings'],
143
- ['categories', '◐', 'Prompt intelligence'],
144
- ['developer', '⌘', 'Developer activity', 'priced'],
139
+ ['projects', 'folder', 'Projects'],
140
+ ['sessions', 'terminal', 'Sessions'],
141
+ ['prompts', 'message', 'Prompt explorer'],
142
+ ['rankings', 'sortDesc', 'Cost rankings'],
143
+ ['categories', 'tag', 'Prompt intelligence'],
144
+ ['developer', 'code', 'Developer activity', 'priced'],
145
145
  ]],
146
146
  ['Optimize', [
147
- ['diagnose', '✚', 'Why so many tokens?', 'priced'],
148
- ['attribution', '⧉', 'Who used the tokens', 'priced'],
149
- ['waste', '⚠', 'Waste detection'],
150
- ['freemodels', '◇', 'Free models', 'claude'],
151
- ['compare', '⚖', 'Compare models', 'priced'],
152
- ['toolkit', '✎', 'Skills & MCP', 'claude'],
153
- ['recommendations', '↯', 'Recommendations'],
154
- ['anomalies', '⚡', 'Anomalies'],
147
+ ['diagnose', 'scan', 'Why so many tokens?', 'priced'],
148
+ ['attribution', 'users', 'Who used the tokens', 'priced'],
149
+ ['waste', 'trash', 'Waste detection'],
150
+ ['freemodels', 'gift', 'Free models', 'claude'],
151
+ ['compare', 'scale', 'Compare models', 'priced'],
152
+ ['toolkit', 'wrench', 'Skills & MCP', 'claude'],
153
+ ['recommendations', 'listChecks', 'Recommendations'],
154
+ ['anomalies', 'zap', 'Anomalies'],
155
155
  ]],
156
156
  ['Plan', [
157
- ['forecast', '◭', 'Forecast', 'priced'],
158
- ['budgets', '⊞', 'Budgets', 'priced'],
159
- ['cloud', '☁', 'Billed vs local', 'cloud'],
160
- ['exports', '⇩', 'Export & data'],
157
+ ['forecast', 'trendUp', 'Forecast', 'priced'],
158
+ ['budgets', 'wallet', 'Budgets', 'priced'],
159
+ ['cloud', 'cloud', 'Billed vs local', 'cloud'],
160
+ ['exports', 'download', 'Export & data'],
161
161
  ]],
162
162
  ];
163
163
 
164
+ // Toolbar icons: 24px line icons drawn in currentColor, so they follow the theme.
165
+ const toolIcon = d => `<svg viewBox="0 0 24 24" aria-hidden="true">${d}</svg>`;
166
+ const ICON = {
167
+ tour: toolIcon('<circle cx="12" cy="12" r="10"/><path d="m16.2 7.8-2.1 6.3-6.3 2.1 2.1-6.3z"/>'),
168
+ recent: toolIcon('<path d="M3 12a9 9 0 1 0 3-6.7L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l3 2"/>'),
169
+ eye: toolIcon('<path d="M2 12s3.6-7 10-7 10 7 10 7-3.6 7-10 7S2 12 2 12z"/><circle cx="12" cy="12" r="3"/>'),
170
+ eyeOff: toolIcon('<path d="M10.7 5.1A10.4 10.4 0 0 1 12 5c6.4 0 10 7 10 7a18 18 0 0 1-2.2 3.2"/><path d="M6.6 6.6A17.6 17.6 0 0 0 2 12s3.6 7 10 7a9.7 9.7 0 0 0 5.4-1.6"/><path d="M9.9 9.9a3 3 0 0 0 4.2 4.2"/><path d="m2 2 20 20"/>'),
171
+ moon: toolIcon('<path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/>'),
172
+ sun: toolIcon('<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/>'),
173
+ reload: toolIcon('<path d="M21 12a9 9 0 1 1-2.6-6.4L21 8"/><path d="M21 3v5h-5"/>'),
174
+ // Sync pulls transcripts into the warehouse: a database with an arrow going in.
175
+ sync: toolIcon('<ellipse cx="11" cy="5" rx="7" ry="3"/><path d="M4 5v6c0 1.66 3.13 3 7 3"/><path d="M4 11v6c0 1.66 3.13 3 7 3"/><path d="M18 5v5"/><path d="M19 13v8"/><path d="m16 18 3 3 3-3"/>'),
176
+ };
177
+
178
+ // Every other icon in the app: flat 24px line icons in the same style, drawn in
179
+ // currentColor so they follow the theme and whatever tone they sit in.
180
+ const IP = {
181
+ search: '<circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/>',
182
+ chevDown: '<path d="m6 9 6 6 6-6"/>',
183
+ chevRight: '<path d="m9 6 6 6-6 6"/>',
184
+ x: '<path d="M18 6 6 18M6 6l12 12"/>',
185
+ check: '<path d="M20 6 9 17l-5-5"/>',
186
+ checkCircle: '<circle cx="12" cy="12" r="10"/><path d="m8 12 3 3 5-6"/>',
187
+ xCircle: '<circle cx="12" cy="12" r="10"/><path d="m15 9-6 6M9 9l6 6"/>',
188
+ circle: '<circle cx="12" cy="12" r="9"/>',
189
+ alert: '<path d="M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0z"/><path d="M12 9v4M12 17h.01"/>',
190
+ siren: '<path d="M7.9 2h8.2L22 7.9v8.2L16.1 22H7.9L2 16.1V7.9z"/><path d="M12 8v4M12 16h.01"/>',
191
+ target: '<circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="6"/><circle cx="12" cy="12" r="2"/>',
192
+ sparkles: '<path d="m12 3 1.9 5.1L19 10l-5.1 1.9L12 17l-1.9-5.1L5 10l5.1-1.9z"/><path d="M19 3v4M17 5h4M5 17v4M3 19h4"/>',
193
+ compact: '<path d="m15 15 6 6M15 21v-6h6M9 9 3 3M9 3v6H3M15 9l6-6M15 3v6h6M9 15l-6 6M9 21v-6H3"/>',
194
+ copy: '<rect x="9" y="9" width="12" height="12" rx="2"/><path d="M5 15H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v1"/>',
195
+ handover: '<path d="m15 17 5-5-5-5"/><path d="M4 18v-2a4 4 0 0 1 4-4h12"/>',
196
+ plus: '<path d="M12 5v14M5 12h14"/>',
197
+ pause: '<rect x="6" y="4" width="4" height="16" rx="1"/><rect x="14" y="4" width="4" height="16" rx="1"/>',
198
+ stop: '<rect x="5" y="5" width="14" height="14" rx="2"/>',
199
+ play: '<path d="M7 4v16l13-8z"/>',
200
+ cloudDown: '<path d="M20 16.6A5 5 0 0 0 18 7h-1.3A8 8 0 1 0 4 15.2"/><path d="M12 12v9M8 17l4 4 4-4"/>',
201
+ arrowUp: '<path d="M12 19V5M5 12l7-7 7 7"/>',
202
+ triUp: '<path d="M12 6l7 11H5z"/>',
203
+ triDown: '<path d="M12 18 5 7h14z"/>',
204
+ external: '<path d="M15 3h6v6M10 14 21 3"/><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>',
205
+ star: '<path d="m12 2.5 2.9 6 6.6.9-4.8 4.6 1.2 6.5L12 17.4l-5.9 3.1 1.2-6.5-4.8-4.6 6.6-.9z"/>',
206
+ starHalf: '<path d="M12 2.5v14.9l-5.9 3.1 1.2-6.5-4.8-4.6 6.6-.9z"/>',
207
+ lock: '<rect x="4" y="11" width="16" height="10" rx="2"/><path d="M8 11V7a4 4 0 0 1 8 0v4"/>',
208
+ link: '<path d="M10 13a5 5 0 0 0 7.5.5l3-3a5 5 0 0 0-7-7l-1.7 1.7"/><path d="M14 11a5 5 0 0 0-7.5-.5l-3 3a5 5 0 0 0 7 7l1.7-1.7"/>',
209
+ note: '<path d="M14 3H6a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"/><path d="M14 3v6h6M8 13h8M8 17h5"/>',
210
+ cog: '<circle cx="12" cy="12" r="3"/><path d="M12 2v3M12 19v3M4.9 4.9 7 7M17 17l2.1 2.1M2 12h3M19 12h3M4.9 19.1 7 17M17 7l2.1-2.1"/>',
211
+ panel: '<rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 15h18"/>',
212
+ zap: '<path d="M13 2 3 14h9l-1 8 10-12h-9z"/>',
213
+ // sidebar
214
+ gauge: '<path d="m12 14 4-4"/><path d="M3.3 19a10 10 0 1 1 17.4 0"/>',
215
+ dashboard: '<rect x="3" y="3" width="7" height="9" rx="1"/><rect x="14" y="3" width="7" height="5" rx="1"/><rect x="14" y="12" width="7" height="9" rx="1"/><rect x="3" y="16" width="7" height="5" rx="1"/>',
216
+ bulb: '<path d="M15 14c.2-1 .7-1.7 1.5-2.5 1-.9 1.5-2.2 1.5-3.5A6 6 0 0 0 6 8c0 1 .2 2.2 1.5 3.5.7.7 1.3 1.5 1.5 2.5"/><path d="M9 18h6M10 22h4"/>',
217
+ bot: '<rect x="4" y="8" width="16" height="12" rx="2"/><path d="M12 8V4H8M2 14h2M20 14h2M9 13v2M15 13v2"/>',
218
+ activity: '<path d="M22 12h-4l-3 9L9 3l-3 9H2"/>',
219
+ award: '<circle cx="12" cy="8" r="6"/><path d="M15.5 12.9 17 22l-5-3-5 3 1.5-9.1"/>',
220
+ barChart: '<path d="M3 3v18h18"/><path d="M8 17v-5M13 17V7M18 17v-8"/>',
221
+ flame: '<path d="M8.5 14.5A2.5 2.5 0 0 0 11 12c0-1.4-.5-2-1-3-1.1-2.1-.2-4 2-6 .5 2.5 2 4.9 4 6.5 2 1.6 3 3.5 3 5.5a7 7 0 1 1-14 0c0-1.2.4-2.3 1-3.3.2 1.7 1.2 2.8 2.5 2.8z"/>',
222
+ cpu: '<rect x="6" y="6" width="12" height="12" rx="2"/><rect x="9" y="9" width="6" height="6"/><path d="M9 2v4M15 2v4M9 18v4M15 18v4M2 9h4M2 15h4M18 9h4M18 15h4"/>',
223
+ layers: '<path d="m12 2 10 5-10 5L2 7z"/><path d="m2 12 10 5 10-5M2 17l10 5 10-5"/>',
224
+ folder: '<path d="M4 20h16a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.7-.9l-.8-1.2A2 2 0 0 0 7.9 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2z"/>',
225
+ terminal: '<rect x="2" y="4" width="20" height="16" rx="2"/><path d="m6 9 3 3-3 3M12 15h6"/>',
226
+ message: '<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>',
227
+ sortDesc: '<path d="M11 5h10M11 9h7M11 13h4M3 17l3 3 3-3M6 18V4"/>',
228
+ tag: '<path d="M12.6 2.6A2 2 0 0 0 11.2 2H4a2 2 0 0 0-2 2v7.2a2 2 0 0 0 .6 1.4l8.7 8.7a2.4 2.4 0 0 0 3.4 0l6.6-6.6a2.4 2.4 0 0 0 0-3.4z"/><circle cx="7.5" cy="7.5" r="1.5"/>',
229
+ code: '<path d="m16 18 6-6-6-6M8 6l-6 6 6 6"/>',
230
+ scan: '<path d="M3 7V5a2 2 0 0 1 2-2h2M17 3h2a2 2 0 0 1 2 2v2M21 17v2a2 2 0 0 1-2 2h-2M7 21H5a2 2 0 0 1-2-2v-2"/><circle cx="11.5" cy="11.5" r="3.5"/><path d="m17 17-3-3"/>',
231
+ users: '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.9M16 3.1a4 4 0 0 1 0 7.8"/>',
232
+ trash: '<path d="M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>',
233
+ gift: '<rect x="3" y="8" width="18" height="4" rx="1"/><path d="M12 8v13M19 12v7a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2v-7"/><path d="M7.5 8a2.5 2.5 0 0 1 0-5C10 3 12 8 12 8s2-5 4.5-5a2.5 2.5 0 0 1 0 5"/>',
234
+ scale: '<path d="M12 3v18M7 21h10M3 7h18"/><path d="m6 7-3 7a3 3 0 0 0 6 0zM18 7l-3 7a3 3 0 0 0 6 0z"/>',
235
+ wrench: '<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.8-3.8a6 6 0 0 1-7.9 7.9l-6.9 6.9a2.1 2.1 0 0 1-3-3l6.9-6.9a6 6 0 0 1 7.9-7.9z"/>',
236
+ listChecks: '<path d="m3 7 2 2 4-4M3 17l2 2 4-4M13 6h8M13 12h8M13 18h8"/>',
237
+ trendUp: '<path d="m22 7-8.5 8.5-5-5L2 17"/><path d="M16 7h6v6"/>',
238
+ wallet: '<path d="M19 7V4a1 1 0 0 0-1-1H5a2 2 0 0 0 0 4h15a1 1 0 0 1 1 1v4h-3a2 2 0 0 0 0 4h3a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1"/><path d="M3 5v14a2 2 0 0 0 2 2h15a1 1 0 0 0 1-1v-4"/>',
239
+ cloud: '<path d="M17.5 19H9a7 7 0 1 1 6.7-9h1.8a4.5 4.5 0 1 1 0 9z"/>',
240
+ download: '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="m7 10 5 5 5-5M12 15V3"/>',
241
+ };
242
+ const FILLED = new Set(['play', 'triUp', 'triDown', 'star', 'starHalf']);
243
+ // I('name'): the icon as inline SVG. Toolbar icons are shared from ICON above.
244
+ const I = n => ICON[n] ? ICON[n].replace('<svg ', '<svg class="i" ')
245
+ : `<svg class="i${FILLED.has(n) ? ' fill' : ''}" viewBox="0 0 24 24" aria-hidden="true">${IP[n]}</svg>`;
246
+ // Status and severity: a flat filled dot in the matching tone.
247
+ const dot = tone => `<svg class="i dot ${tone}" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="6"/></svg>`;
248
+
249
+ // The theme button shows the theme it switches to: a moon in light mode, a sun in dark.
250
+ function themeLabel() {
251
+ const b = $('#theme'); if (!b) return;
252
+ const cur = document.documentElement.getAttribute('data-theme')
253
+ || (matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
254
+ const to = cur === 'dark' ? 'light' : 'dark';
255
+ b.innerHTML = to === 'dark' ? ICON.moon : ICON.sun;
256
+ b.title = `Switch to ${to} theme`;
257
+ b.setAttribute('aria-label', b.title);
258
+ }
259
+
164
260
  function shell() {
165
261
  document.body.innerHTML = `<div class="app">
166
262
  <aside class="sidebar">
@@ -169,24 +265,27 @@ function shell() {
169
265
  <div class="sub">Command Center</div></div>
170
266
  <nav class="nav">${NAV.map(([g, items], gi) => `<div class="group g${gi}">${g}</div>` +
171
267
  items.map(([id, ic, label]) =>
172
- `<a data-view="${id}" class="g${gi}${id === 'diagnose' ? ' start' : ''}"><span class="ic">${ic}</span>${label}<span class="nb" data-nb="${id}"></span></a>`).join('')).join('')}
268
+ `<a data-view="${id}" class="g${gi}${id === 'diagnose' ? ' start' : ''}"><span class="ic">${I(ic)}</span>${label}<span class="nb" data-nb="${id}"></span></a>`).join('')).join('')}
173
269
  </nav>
174
270
  <div class="who" id="who"></div></aside>
175
271
  <div class="main">
176
272
  <header class="topbar">
177
273
  <div class="r1">
178
- <h1 id="ttl">Executive overview</h1>
179
- <div class="crumbs" id="crumbs"></div>
274
+ <div class="ttl-block"><h1 id="ttl">Executive overview</h1>
275
+ <div class="crumbs" id="crumbs"></div></div>
276
+ <div class="scope" id="scope" aria-label="Totals for the current filters"></div>
180
277
  <span class="spacer"></span>
181
- <div class="search"><span class="mag">⌕</span>
182
- <input id="gsearch" placeholder="Search prompts, sessions, models, dates…"></div>
278
+ <div class="search"><span class="mag">${I('search')}</span>
279
+ <input id="gsearch" placeholder="Search prompts, sessions, models…"></div>
183
280
  <button class="iconbtn upd" id="upd" hidden></button>
184
- <button class="iconbtn" id="tour-btn" title="Walk through this dashboard">? Tour</button>
185
- <button class="iconbtn" id="recent-btn" title="Past sessions and prompts, from any page (R)">☰ Recent</button>
186
- <button class="iconbtn" id="mask"></button>
187
- <button class="iconbtn" id="theme" title="Toggle theme">◐</button>
188
- <button class="iconbtn" id="refresh" title="Reload data">↻</button>
189
- <button class="act" id="sync" title="Re-read every agent's local data so the dashboard is current">⟳ Sync</button>
281
+ <div class="tools">
282
+ <button class="iconbtn" id="tour-btn" title="Walk through this dashboard">${ICON.tour}Tour</button>
283
+ <button class="iconbtn sq" id="recent-btn" title="Recent sessions and prompts, from any page (R)" aria-label="Recent">${ICON.recent}</button>
284
+ <button class="iconbtn sq" id="mask"></button>
285
+ <button class="iconbtn sq" id="theme"></button>
286
+ <button class="iconbtn sq" id="refresh" title="Reload this page's numbers from the warehouse. To pick up new sessions, use Sync" aria-label="Reload view">${ICON.reload}</button>
287
+ </div>
288
+ <button class="act sync" id="sync" title="Re-read every agent's transcripts from disk into the warehouse">${ICON.sync}Sync</button>
190
289
  </div>
191
290
  <div class="filters" id="filters"></div>
192
291
  </header>
@@ -202,8 +301,10 @@ function shell() {
202
301
  : (matchMedia('(prefers-color-scheme: dark)').matches ? 'light' : 'dark');
203
302
  document.documentElement.setAttribute('data-theme', next);
204
303
  localStorage.setItem('finops-theme', next);
304
+ themeLabel();
205
305
  render();
206
306
  };
307
+ themeLabel();
207
308
  $('#refresh').onclick = () => { bust(); render(); };
208
309
  maskLabel();
209
310
  $('#mask').onclick = () => {
@@ -222,11 +323,73 @@ function shell() {
222
323
  else if (S.view === 'search') go('overview'); }, 260); };
223
324
  }
224
325
 
326
+ // The page loader: a skeleton in the shape of a dashboard (KPI row, two charts, a table)
327
+ // so the layout doesn't jump when data lands, plus a status line that says what is
328
+ // happening and, on a slow query, how long it has taken.
329
+ const LOADER_STEPS = ['Reading the warehouse', 'Pricing every request', 'Adding up tokens and cache',
330
+ 'Grouping by session and project', 'Drawing the charts'];
331
+ const LOADER_TIPS = ['Press R anywhere to open Recent sessions.',
332
+ 'Click any row in a table to open its details.',
333
+ 'The eye button hides prompt text before you share your screen.',
334
+ '"Actual" numbers come from transcripts; "Estimated" ones use list prices.',
335
+ 'Tour walks you through whichever dashboard you are on.'];
336
+ function pageLoader() {
337
+ const bone = (w, h = 10) => `<i class="bone" style="width:${w};height:${h}px"></i>`;
338
+ const bars = [38, 62, 45, 80, 56, 90, 70, 48, 66, 84, 58, 74]
339
+ .map((v, i) => `<i style="height:${v}%;animation-delay:${i * 70}ms"></i>`).join('');
340
+ return `<div class="pl" role="status" aria-live="polite">
341
+ <div class="pl-status"><span class="pl-mark"><i></i><i></i><i></i></span>
342
+ <span class="pl-msg">${LOADER_STEPS[0]}…</span><span class="pl-time"></span></div>
343
+ <div class="grid g4">${Array.from({length: 4}, () =>
344
+ `<div class="kpi skel">${bone('55%', 8)}${bone('45%', 22)}${bone('70%', 8)}</div>`).join('')}</div>
345
+ <div class="grid g2">
346
+ <div class="card skel"><div class="body">${bone('35%', 10)}<div class="pl-bars">${bars}</div></div></div>
347
+ <div class="card skel"><div class="body">${bone('30%', 10)}<div class="pl-bars alt">${bars}</div></div></div>
348
+ </div>
349
+ <div class="card skel"><div class="body">${bone('25%', 10)}
350
+ ${Array.from({length: 4}, (_, i) => `<div class="pl-row">${bone(`${60 - i * 8}%`)}${bone('12%')}${bone('9%')}</div>`).join('')}
351
+ </div></div>
352
+ <div class="pl-tip">Tip: ${esc(LOADER_TIPS[Math.floor(Math.random() * LOADER_TIPS.length)])}</div>
353
+ </div>`;
354
+ }
355
+ function loaderTicker(page) {
356
+ const t0 = Date.now();
357
+ let i = 0;
358
+ const id = setInterval(() => {
359
+ const msg = page.querySelector('.pl-msg');
360
+ if (!msg || !page.isConnected) return clearInterval(id); // the view has rendered
361
+ i = Math.min(i + 1, LOADER_STEPS.length - 1);
362
+ msg.textContent = LOADER_STEPS[i] + '…';
363
+ const s = Math.round((Date.now() - t0) / 1000);
364
+ if (s >= 3) page.querySelector('.pl-time').textContent = `${s}s · large ranges take a little longer`;
365
+ }, 1100);
366
+ }
367
+
368
+ // Headline totals for whatever the filters select, on every page, so a number
369
+ // on the page can always be read against the whole it came from.
370
+ async function scopeStrip() {
371
+ const el = $('#scope'); if (!el) return;
372
+ const key = qs();
373
+ el.dataset.key = key;
374
+ let o;
375
+ try { o = await api('overview'); } catch { el.innerHTML = ''; return; }
376
+ if (el.dataset.key !== key) return; // the filters moved on while this loaded
377
+ const cell = (l, v, t) => `<div class="sc"${t ? ` title="${esc(t)}"` : ''}><span>${l}</span><b>${v}</b></div>`;
378
+ el.innerHTML =
379
+ cell('Est. spend', fmtUSD(o.est_cost_usd), 'Estimated at list prices for the current filters') +
380
+ cell('Sessions', fmtInt(o.sessions)) +
381
+ cell('Prompts', fmtInt(o.prompts)) +
382
+ cell('Requests', fmtNum(o.requests), fmtInt(o.requests) + ' model requests') +
383
+ cell('Active days', fmtInt(o.active_days));
384
+ }
385
+
225
386
  function maskLabel() {
226
387
  const b = $('#mask');
227
- b.textContent = MASKED ? '◌ Show prompts' : '◉ Hide prompts';
388
+ // The icon shows the current state: an open eye while prompts are visible.
389
+ b.innerHTML = MASKED ? ICON.eyeOff : ICON.eye;
228
390
  b.title = MASKED ? 'Prompt text is hidden. Click to show it'
229
391
  : 'Hide prompt text and session titles, e.g. before sharing your screen';
392
+ b.setAttribute('aria-label', MASKED ? 'Show prompts' : 'Hide prompts');
230
393
  b.setAttribute('aria-pressed', MASKED);
231
394
  b.classList.toggle('on', MASKED);
232
395
  }
@@ -242,13 +405,13 @@ async function updateChip() {
242
405
  try { u = await fetch('/api/update').then(r => r.json()); } catch { return; }
243
406
  if (!u || !u.update_available) return;
244
407
  el.hidden = false;
245
- el.textContent = `↑ v${u.latest} available`;
408
+ el.innerHTML = `${I('arrowUp')} v${u.latest} available`;
246
409
  el.title = `You are on ${u.current}. Click to copy: ${u.command}`;
247
410
  el.onclick = async () => {
248
411
  try {
249
412
  await navigator.clipboard.writeText(u.command);
250
- el.textContent = '✓ command copied';
251
- setTimeout(() => { el.textContent = `↑ v${u.latest} available`; }, 2200);
413
+ el.innerHTML = `${I('check')} command copied`;
414
+ setTimeout(() => { el.innerHTML = `${I('arrowUp')} v${u.latest} available`; }, 2200);
252
415
  } catch { prompt('Run this to upgrade:', u.command); }
253
416
  };
254
417
  }
@@ -275,29 +438,29 @@ function filterBar() {
275
438
  const nSel = a => a.length ? `<span class="n">${a.length}</span>` : '';
276
439
  const ag = o.agents || [];
277
440
  $('#filters').innerHTML = `
278
- ${ag.length > 1 ? `<span class="note" style="margin-right:2px">Agent</span>
279
- ${ag.map(a => `<button class="chip ${f.agents.includes(a.id) ? 'on' : ''}" data-agent="${esc(a.id)}"
280
- title="${esc(a.note)}${a.requests ? '' : ' (no usage recorded)'}">${esc(a.name)}${a.requests ? '' : ' ·'}</button>`).join('')}
281
- <button class="chip ${f.agents.length === ag.length ? 'on' : ''}" data-agent="*" title="Every agent together">All</button>
282
- <span class="divider"></span>` : ''}
283
- ${RANGES.map(([k, l]) => `<button class="chip ${S.range === k ? 'on' : ''}"
284
- data-range="${k}">${l}</button>`).join('')}
285
- <span class="divider"></span>
441
+ ${ag.length > 1 ? `<div class="seg" role="group" aria-label="Agent"><span class="seg-l">Agent</span>
442
+ ${ag.map(a => `<button class="${f.agents.includes(a.id) ? 'on' : ''}" data-agent="${esc(a.id)}"
443
+ title="${esc(a.note)}${a.requests ? '' : ' (no usage recorded)'}. Cmd/Shift-click to combine">${esc(a.name)}${a.requests ? '' : ' ·'}</button>`).join('')}
444
+ <button class="${f.agents.length === ag.length ? 'on' : ''}" data-agent="*" title="Every agent together">All</button></div>` : ''}
445
+ <div class="seg" role="group" aria-label="Date range"><span class="seg-l">Range</span>
446
+ ${RANGES.map(([k, l]) => `<button class="${S.range === k ? 'on' : ''}"
447
+ data-range="${k}">${l}</button>`).join('')}</div>
286
448
  <div class="chipsel"><button class="chip ${f.models.length ? 'on' : ''}" data-pop="models">
287
- Model ${nSel(f.models)} ▾</button></div>
449
+ Model ${nSel(f.models)} ${I('chevDown')}</button></div>
288
450
  <div class="chipsel"><button class="chip ${f.projects.length ? 'on' : ''}" data-pop="projects">
289
- Project ${nSel(f.projects)} ▾</button></div>
451
+ Project ${nSel(f.projects)} ${I('chevDown')}</button></div>
290
452
  <div class="chipsel"><button class="chip ${f.categories.length ? 'on' : ''}" data-pop="categories">
291
- Category ${nSel(f.categories)} ▾</button></div>
453
+ Category ${nSel(f.categories)} ${I('chevDown')}</button></div>
292
454
  <div class="chipsel"><button class="chip ${(f.min_cost || f.min_tokens) ? 'on' : ''}"
293
- data-pop="thresholds">Thresholds ▾</button></div>
294
- <button class="chip ${f.include_sandbox ? '' : 'on'}" id="sbx">
295
- ${f.include_sandbox ? 'Including' : 'Excluding'} sandbox agents</button>
455
+ data-pop="thresholds">Thresholds ${I('chevDown')}</button></div>
456
+ <button class="chip toggle ${f.include_sandbox ? '' : 'on'}" id="sbx" role="switch" aria-checked="${!f.include_sandbox}"
457
+ title="Sandbox agents are throwaway projects. ${f.include_sandbox ? 'They are included; click to leave them out' : 'They are left out; click to include them'}">
458
+ <i></i>Hide sandbox</button>
296
459
  ${(f.models.length || f.projects.length || f.categories.length || f.min_cost || f.min_tokens)
297
- ? '<button class="chip" id="clr">Clear ✕</button>' : ''}
460
+ ? `<button class="chip clr" id="clr">Clear filters ${I('x')}</button>` : ''}
298
461
  <span class="spacer"></span>
299
- <span class="note">${o.date_range.first} → ${o.date_range.last} ·
300
- ${esc(o.meta.transcript_files)} transcripts · built ${esc((o.meta.built_at||'').slice(0,16).replace('T',' '))}</span>`;
462
+ <span class="coverage" title="Data in the warehouse · last rebuilt ${esc((o.meta.built_at||'').slice(0,16).replace('T',' '))}">
463
+ ${esc(o.meta.transcript_files)} transcripts · since ${esc(o.date_range.first)}</span>`;
301
464
 
302
465
  $('#filters').querySelectorAll('[data-range]').forEach(b =>
303
466
  b.onclick = () => { if (b.dataset.range === 'custom') return openCustom(b);
@@ -388,7 +551,7 @@ function drawer(title, bodyHtml, sub) {
388
551
  const d = h(`<aside class="drawer"><header>
389
552
  <h2>${esc(title)}</h2>${sub ? `<span class="crumbs">${sub}</span>` : ''}
390
553
  <span class="spacer"></span>
391
- <button class="iconbtn" data-x>Close ✕</button></header>
554
+ <button class="iconbtn" data-x>Close ${I('x')}</button></header>
392
555
  <div class="content">${bodyHtml}</div></aside>`);
393
556
  scrim.onclick = closeDrawer;
394
557
  d.querySelector('[data-x]').onclick = closeDrawer;
@@ -732,19 +895,19 @@ VIEWS.overview = async (page) => {
732
895
  };
733
896
 
734
897
  /* ---------- act now: one-click actions, on the page everyone opens first ---------- */
735
- const ACT_ICON = {session: '🗜', skill: '⚙', memory: '📝', statusline: '▭'};
898
+ const ACT_ICON = {session: 'compact', skill: 'cog', memory: 'note', statusline: 'panel'};
736
899
  const codeTicks = t => esc(t).replace(/`([^`]+)`/g, '<code>$1</code>');
737
900
  function actNowStrip(items) {
738
901
  const btn = it => it.kind === 'session'
739
- ? '<button class="act" data-do="compact" title="Types /compact into that session\'s terminal">🗜 Compact</button>'
740
- + '<button class="act ghost" data-do="handover">⇢ Hand over</button>'
741
- : it.kind === 'skill' ? '<button class="act" data-do="skill">+ Create skill</button>'
902
+ ? '<button class="act" data-do="compact" title="Types /compact into that session\'s terminal">' + I('compact') + ' Compact</button>'
903
+ + '<button class="act ghost" data-do="handover">' + I('handover') + ' Hand over</button>'
904
+ : it.kind === 'skill' ? '<button class="act" data-do="skill">' + I('plus') + ' Create skill</button>'
742
905
  : it.kind === 'memory' ? '<button class="act ghost" data-do="memory">Show examples</button>'
743
906
  : '<button class="act" data-do="statusline">Install statusline</button>';
744
- return `<section class="actnow"><div class="focus-hd">⚡ Act now
907
+ return `<section class="actnow"><div class="focus-hd">${I('zap')} Act now
745
908
  <span class="note">one click each · nothing changes until you click</span></div>
746
909
  ${items.map((it, i) => `<div class="actnow-it" data-i="${i}">
747
- <span class="ai">${ACT_ICON[it.kind] || '•'}</span>
910
+ <span class="ai">${ACT_ICON[it.kind] ? I(ACT_ICON[it.kind]) : '•'}</span>
748
911
  <div class="ft"><b>${codeTicks(MASKED && it.private ? it.title.split(it.private).join(pt(it.private)) : it.title)}</b><div class="note">${esc(it.detail)}</div></div>
749
912
  <div class="live-actions">${btn(it)}<span class="live-msg"></span></div></div>`).join('')}</section>`;
750
913
  }
@@ -800,16 +963,16 @@ async function liveSessions() {
800
963
  const rowActs = (r, live) => {
801
964
  const pid = live && live.get(r.session_id);
802
965
  return `<span class="row-acts">${r.resume ? `<button class="act ghost" data-resume="${esc(r.resume)}"
803
- title="Copy: ${esc(r.resume)}">⧉ Resume</button>` : ''}${pid ? ` <button class="act" data-compact="${pid}"
804
- title="Running now: types /compact into its terminal">🗜 Compact</button>` : ''}</span>`;
966
+ title="Copy: ${esc(r.resume)}">${I('copy')} Resume</button>` : ''}${pid ? ` <button class="act" data-compact="${pid}"
967
+ title="Running now: types /compact into its terminal">${I('compact')} Compact</button>` : ''}</span>`;
805
968
  };
806
969
  function wireRowActs(host) {
807
970
  if (!host) return;
808
971
  host.querySelectorAll('[data-resume]').forEach(b => b.onclick = async e => {
809
972
  e.stopPropagation();
810
- try { await navigator.clipboard.writeText(b.dataset.resume); b.textContent = '✓ Copied'; }
973
+ try { await navigator.clipboard.writeText(b.dataset.resume); b.innerHTML = `${I('check')} Copied`; }
811
974
  catch { b.textContent = b.dataset.resume; }
812
- setTimeout(() => { b.textContent = '⧉ Resume'; }, 1800);
975
+ setTimeout(() => { b.innerHTML = `${I('copy')} Resume`; }, 1800);
813
976
  });
814
977
  host.querySelectorAll('[data-compact]').forEach(b => b.onclick = async e => {
815
978
  e.stopPropagation();
@@ -818,15 +981,15 @@ function wireRowActs(host) {
818
981
  try {
819
982
  const r = await sessionAction(+b.dataset.compact, 'compact', {agent: 'claude'});
820
983
  if (!r.ok && r.copy) { try { await navigator.clipboard.writeText(r.copy); } catch {} }
821
- b.textContent = r.ok ? '✓ Sent' : 'Failed'; b.title = r.ok ? r.message : r.error;
984
+ b.innerHTML = r.ok ? `${I('check')} Sent` : 'Failed'; b.title = r.ok ? r.message : r.error;
822
985
  } catch (err) { b.textContent = 'Failed'; b.title = err.message; }
823
- setTimeout(() => { b.disabled = false; b.textContent = '🗜 Compact'; }, 2500);
986
+ setTimeout(() => { b.disabled = false; b.innerHTML = `${I('compact')} Compact`; }, 2500);
824
987
  });
825
988
  }
826
989
 
827
990
  function renderAdvisorHero(page, advisor) {
828
991
  if (!page || !advisor || !Array.isArray(advisor.actions)) return;
829
- const hero = h(`<div class="hero"><h3>✦ AI FinOps Advisor — ${esc(advisor.question)}
992
+ const hero = h(`<div class="hero"><h3>${I('sparkles')} AI FinOps Advisor — ${esc(advisor.question)}
830
993
  <span class="spacer"></span>
831
994
  <span class="note" style="font-weight:400">${esc(advisor.generated_from)}</span></h3>
832
995
  <div class="advisor-list">${advisor.actions.length ? advisor.actions.map((a, i) =>
@@ -883,7 +1046,7 @@ VIEWS.advisor = async (page) => {
883
1046
  footer: 'These are observations from your own usage. No dollar saving is attached unless the method can support one — a guessed percentage of spend is not a saving.'})}
884
1047
  ${card('Anomalies to inspect', `<div class="stack" id="anolist">${anos.anomalies.map((a, i) =>
885
1048
  `<div class="item sev-${a.severity} clickable" data-i="${i}"><div class="hd">
886
- ${a.severity === 'high' ? '🚨' : '⚠️'} ${esc(a.title)}</div>
1049
+ ${I(a.severity === 'high' ? 'siren' : 'alert')} ${esc(a.title)}</div>
887
1050
  <div class="dt">${esc(a.detail)}</div>
888
1051
  <div class="mt"><span>ratio ${a.ratio}×</span><span>click to inspect</span></div></div>`).join('')
889
1052
  || '<div class="empty">No anomalies detected</div>'}</div>`, {badge: BADGE.estimated})}`;
@@ -1091,7 +1254,7 @@ VIEWS.models = async (page) => {
1091
1254
  const find = k => rows.find(r => r.model === sup[k]);
1092
1255
  page.innerHTML = `
1093
1256
  ${lc.unpriced_requests ? `<div class="hero">
1094
- <h3>⚠️ Some long-context requests are priced low</h3>
1257
+ <h3>${I('alert')} Some long-context requests are priced low</h3>
1095
1258
  <div class="stack">
1096
1259
  <div class="item sev-high">
1097
1260
  <div class="hd">${fmtInt(lc.unpriced_requests)} requests sent more context than their
@@ -1253,7 +1416,7 @@ VIEWS.sessions = async (page) => {
1253
1416
  if (sessMore) sessMore.onclick = () => { sp.offset += sp.limit; bust(); render(); };
1254
1417
  $('#st', page).innerHTML = table([
1255
1418
  {h: 'Session', trunc: 1, title: r => r.session_id,
1256
- f: r => `${r.tokens > avgTok * 3 ? '🔴 ' : ''}${esc(stitle(r))}
1419
+ f: r => `${r.tokens > avgTok * 3 ? dot('red') + ' ' : ''}${esc(stitle(r))}
1257
1420
  <div class="sub mono">${esc(shortId(r.session_id))}</div>`},
1258
1421
  {h: '', f: r => rowActs(r, live)},
1259
1422
  {h: 'Project', f: r => esc(r.project)},
@@ -1301,7 +1464,7 @@ VIEWS.prompts = async (page) => {
1301
1464
  page.innerHTML = `
1302
1465
  ${card('Prompt explorer', `
1303
1466
  <div class="filters" style="margin:0 0 8px">
1304
- <div class="search"><span class="mag">⌕</span>
1467
+ <div class="search"><span class="mag">${I('search')}</span>
1305
1468
  <input id="pq" value="${esc(q)}" placeholder="Search prompt text…"></div>
1306
1469
  <span class="divider"></span>
1307
1470
  ${[['cost', 'Most expensive'], ['tokens', 'Most tokens'], ['length', 'Longest'],
@@ -1450,6 +1613,12 @@ VIEWS.hygiene = async (page) => {
1450
1613
  const T = hy.thresholds.map(String), kT = String(hy.rank_threshold);
1451
1614
  const K = t => `${Math.round(+t / 1000)}K`;
1452
1615
  const ab = t => hy.above[t];
1616
+ // Colour grade by how much of the spend went to re-sending a large prefix. The bands are
1617
+ // a reading aid, not a verdict: long agentic work legitimately runs at high context.
1618
+ const grade = (v, bands) => v == null ? null
1619
+ : v < bands[0] ? 'good' : v < bands[1] ? 'warning' : v < bands[2] ? 'serious' : 'critical';
1620
+ const SHARE = [25, 50, 75], FIT = [5, 15, 30];
1621
+ const bandTip = b => `Green under ${b[0]}%, amber ${b[0]}–${b[1]}%, orange ${b[1]}–${b[2]}%, red ${b[2]}% and up`;
1453
1622
  page.innerHTML = `
1454
1623
  <div class="note" style="margin:0 0 10px">Everything on this page is observed from your transcripts.
1455
1624
  It shows where spend sat while a large prefix was being re-sent on every turn. It does
@@ -1459,16 +1628,18 @@ VIEWS.hygiene = async (page) => {
1459
1628
  recorded and also counts.</div>
1460
1629
  <div class="grid g4">
1461
1630
  ${T.map(t => kpi(`Spend in requests ≥ ${K(t)} context`, fmtPct(ab(t).share_pct),
1462
- `${fmtUSD(ab(t).cost_usd)} · ${fmtInt(ab(t).requests)} requests`, {badge: BADGE.actual})).join('')}
1631
+ `${fmtUSD(ab(t).cost_usd)} · ${fmtInt(ab(t).requests)} requests`,
1632
+ {badge: BADGE.actual, tone: grade(ab(t).share_pct, SHARE), title: bandTip(SHARE)})).join('')}
1463
1633
  ${T.map(t => kpi(`Spend after a session first crossed ${K(t)}`, fmtPct(ab(t).share_after_first_cross_pct),
1464
- `${fmtInt(ab(t).sessions)} of ${fmtInt(hy.sessions)} sessions crossed it`, {badge: BADGE.actual})).join('')}
1634
+ `${fmtInt(ab(t).sessions)} of ${fmtInt(hy.sessions)} sessions crossed it`,
1635
+ {badge: BADGE.actual, tone: grade(ab(t).share_after_first_cross_pct, SHARE), title: bandTip(SHARE)})).join('')}
1465
1636
  </div>
1466
1637
  <div class="grid g3">
1467
1638
  ${kpi('Spend near or over the context window', fmtPct(fit.near_or_over_cost_pct),
1468
1639
  `≥ ${fit.threshold_pct}% of the window in use · ${fmtInt(fit.near_requests + fit.over_requests)} requests`,
1469
- {badge: BADGE.actual})}
1470
- ${kpi('Sessions in range', fmtInt(hy.sessions), `${fmtInt(hy.requests)} main-thread requests`, {badge: BADGE.actual})}
1471
- ${kpi('Spend in range', fmtUSD(hy.cost_usd), 'subagent turns excluded (own prefix)', {badge: BADGE.estimated})}
1640
+ {badge: BADGE.actual, tone: grade(fit.near_or_over_cost_pct, FIT), title: bandTip(FIT)})}
1641
+ ${kpi('Sessions in range', fmtInt(hy.sessions), `${fmtInt(hy.requests)} main-thread requests`, {badge: BADGE.actual, tone: 'info'})}
1642
+ ${kpi('Spend in range', fmtUSD(hy.cost_usd), 'subagent turns excluded (own prefix)', {badge: BADGE.estimated, tone: 'info'})}
1472
1643
  </div>
1473
1644
  ${card('Context per request, most expensive session after ' + K(kT), '<div class="chart" id="hy-traj"></div>',
1474
1645
  {badge: BADGE.actual, hint: 'prompt-side tokens on each request, in order · click a row below to change session'})}
@@ -1586,7 +1757,7 @@ VIEWS.context = async (page) => {
1586
1757
  footer: 'Component costs are re-derived per model from config/pricing.json, since a request stores one blended cost.'});
1587
1758
  })()}
1588
1759
  ${ctx.large_context_cost_pct > 0 ? `<div class="hero">
1589
- <h3>⚠️ Context warnings</h3>
1760
+ <h3>${I('alert')} Context warnings</h3>
1590
1761
  <div class="stack">
1591
1762
  <div class="item sev-${ctx.large_context_cost_pct > 30 ? 'high' : 'medium'}">
1592
1763
  <div class="hd">${fmtPct(ctx.large_context_cost_pct)} of your estimated spend came from
@@ -1665,8 +1836,9 @@ VIEWS.waste = async (page) => {
1665
1836
  ${['high', 'medium', 'low'].map(sev => {
1666
1837
  const list = w.findings.filter(f => f.severity === sev);
1667
1838
  if (!list.length) return '';
1668
- const title = sev === 'high' ? '🔴 High waste'
1669
- : sev === 'medium' ? '🟡 Optimization opportunities' : '⚪ Low-priority observations';
1839
+ const title = sev === 'high' ? 'High waste'
1840
+ : sev === 'medium' ? 'Optimization opportunities' : 'Low-priority observations';
1841
+ const icon = dot(sev === 'high' ? 'red' : sev === 'medium' ? 'yellow' : 'grey');
1670
1842
  return card(title, `<div class="stack">${list.map((f, i) => `
1671
1843
  <div class="item sev-${sev}">
1672
1844
  <div class="hd">${esc(f.title)}<span class="spacer"></span>
@@ -1678,7 +1850,7 @@ VIEWS.waste = async (page) => {
1678
1850
  <details style="margin-top:5px"><summary style="cursor:pointer;font-size:11.5px;color:var(--s1)">
1679
1851
  Show ${f.evidence.length} flagged items</summary>
1680
1852
  <div class="ev" data-kind="${esc(f.kind)}" style="margin-top:6px"></div></details>
1681
- </div>`).join('')}</div>`, {badge: BADGE.estimated});
1853
+ </div>`).join('')}</div>`, {badge: BADGE.estimated, icon});
1682
1854
  }).join('')}
1683
1855
  <div class="note">${esc(w.note)}</div>`;
1684
1856
  // fill evidence tables
@@ -1836,7 +2008,7 @@ VIEWS.live = async (page) => {
1836
2008
  page.innerHTML = `
1837
2009
  ${card('Plan usage right now', `<div id="live-usage">${usageBody(usage0)}</div>`,
1838
2010
  {badge: BADGE.actual, hint: 'Claude Code\'s own /usage: plan limits, not cost. Costs no tokens.',
1839
- actions: '<button class="btn pb-copy" id="usage-refresh">↻ Refresh usage</button>'})}
2011
+ actions: `<button class="btn pb-copy" id="usage-refresh">${I('reload')} Refresh usage</button>`})}
1840
2012
  <div class="grid g4">
1841
2013
  ${kpi('Running sessions', fmtInt(list.length), `${busy.length} working right now`, {badge: BADGE.actual})}
1842
2014
  ${kpi('Working (spending now)', fmtInt(busy.length), 'Only these consume tokens right now')}
@@ -1851,7 +2023,7 @@ VIEWS.live = async (page) => {
1851
2023
  <b>Force kill</b> is for a session that won't close. Each button asks you to click twice.
1852
2024
  Codex, Gemini and Cursor keep no session registry, so they count as running when their transcript was
1853
2025
  written in the last 20 minutes. Cursor sessions live inside the IDE and can't be stopped from here.
1854
- <span class="spacer"></span><button class="btn pb-copy" id="live-refresh">↻ Refresh</button></div>
2026
+ <span class="spacer"></span><button class="btn pb-copy" id="live-refresh">${I('reload')} Refresh</button></div>
1855
2027
  <div class="stack">${list.map((x, i) => `
1856
2028
  <div class="item sev-${sevOf(x)}" data-i="${i}">
1857
2029
  <div class="hd">${x.status === 'busy' ? '<span class="live-dot"></span>' : '<span class="idle-dot"></span>'}
@@ -1867,12 +2039,12 @@ VIEWS.live = async (page) => {
1867
2039
  ? 'Very large context. Use <b>Hand over</b> to continue in a fresh session, or split the remaining work into sub-sessions.'
1868
2040
  : 'Getting heavy. Hit <b>Compact</b> at the next break, or close it if the task is done.'}</div>` : ''}
1869
2041
  <div class="live-actions">
1870
- ${x.signalable ? `${canInterrupt ? `<button class="act" data-a="interrupt" ${x.status !== 'busy' ? 'disabled title="Nothing running"' : ''}>⏸ Interrupt</button>` : ''}
1871
- ${x.agent === 'claude' ? '<button class="act" data-a="compact" title="Types /compact into that session\'s terminal">🗜 Compact</button>' : ''}
1872
- <button class="act warn" data-a="close">⏹ Close session</button>
1873
- <button class="act danger" data-a="kill">✖ Force kill</button>` : `<span class="note">${x.agent === 'cursor' ? 'Runs inside the Cursor IDE: stop it there.' : 'No matching process found: stop it in its terminal.'}</span>`}
2042
+ ${x.signalable ? `${canInterrupt ? `<button class="act" data-a="interrupt" ${x.status !== 'busy' ? 'disabled title="Nothing running"' : ''}>${I('pause')} Interrupt</button>` : ''}
2043
+ ${x.agent === 'claude' ? '<button class="act" data-a="compact" title="Types /compact into that session\'s terminal">' + I('compact') + ' Compact</button>' : ''}
2044
+ <button class="act warn" data-a="close">${I('stop')} Close session</button>
2045
+ <button class="act danger" data-a="kill">${I('x')} Force kill</button>` : `<span class="note">${x.agent === 'cursor' ? 'Runs inside the Cursor IDE: stop it there.' : 'No matching process found: stop it in its terminal.'}</span>`}
1874
2046
  ${x.resume ? `<button class="act ghost" data-copy="${esc(x.resume)}">Copy resume command</button>` : ''}
1875
- ${x.agent === 'claude' ? '<button class="act ghost" data-ho="1">⇢ Hand over / split</button>' : ''}
2047
+ ${x.agent === 'claude' ? '<button class="act ghost" data-ho="1">' + I('handover') + ' Hand over / split</button>' : ''}
1876
2048
  <span class="live-msg"></span>
1877
2049
  </div>
1878
2050
  ${x.agent !== 'claude' ? '' : `<div class="handover" hidden>
@@ -1896,7 +2068,7 @@ VIEWS.live = async (page) => {
1896
2068
  const u = await fetch('/api/usage?refresh=1').then(r => r.json());
1897
2069
  $('#live-usage', page).innerHTML = usageBody(u);
1898
2070
  } catch (e) { $('#live-usage', page).innerHTML = `<div class="empty">${esc(e.message)}</div>`; }
1899
- ub.disabled = false; ub.textContent = '↻ Refresh usage';
2071
+ ub.disabled = false; ub.innerHTML = `${I('reload')} Refresh usage`;
1900
2072
  };
1901
2073
  page.querySelectorAll('.item[data-i]').forEach(row => {
1902
2074
  const x = list[+row.dataset.i], pid = x.pid, msg = row.querySelector('.live-msg');
@@ -1910,7 +2082,7 @@ VIEWS.live = async (page) => {
1910
2082
  const r = await sessionAction(pid, 'handover', {tasks: ta.value.split('\n'), launch,
1911
2083
  close: launch && ho.querySelector('.ho-close').checked});
1912
2084
  hoOut.innerHTML = r.ok ? `<div class="live-msg ok">${esc(r.message)}</div>` + r.sessions.map(s =>
1913
- `<div>${s.launched ? '▶' : '•'} ${esc(s.task || 'Continue the same work')} — <code>${esc(s.brief)}</code>${s.error ? ` <span class="live-msg err">${esc(s.error)}</span>` : ''}</div>`).join('')
2085
+ `<div>${s.launched ? I('play') : '•'} ${esc(s.task || 'Continue the same work')} — <code>${esc(s.brief)}</code>${s.error ? ` <span class="live-msg err">${esc(s.error)}</span>` : ''}</div>`).join('')
1914
2086
  : `<div class="live-msg err">Failed: ${esc(r.error)}</div>`;
1915
2087
  if (r.ok && r.closed && r.closed.exited) { row.classList.add('gone'); setTimeout(() => render(), 2500); }
1916
2088
  } catch (e) { hoOut.innerHTML = `<div class="live-msg err">Failed: ${esc(e.message)}</div>`; }
@@ -1964,12 +2136,12 @@ const pb = x => {
1964
2136
  <ol>${x.steps.map(t => `<li>${esc(t)}</li>`).join('')}</ol>${promptHtml}</details>`;
1965
2137
  };
1966
2138
  const wirePB = page => page.querySelectorAll('.pb-copy').forEach(b => b.onclick = async () => {
1967
- try { await navigator.clipboard.writeText(PB[+b.dataset.pb]); b.textContent = 'Copied ✓'; }
2139
+ try { await navigator.clipboard.writeText(PB[+b.dataset.pb]); b.innerHTML = `Copied ${I('check')}`; }
1968
2140
  catch { b.textContent = 'Copy failed'; }
1969
2141
  setTimeout(() => b.textContent = 'Copy', 1500);
1970
2142
  });
1971
- const HARNESS = {needed: ['🔴 Needed', 'high'], partial: ['🟡 Partial', 'medium'],
1972
- in_place: ['✅ In place', 'low'], not_needed: ['⚪ Not needed', 'low'], unknown: ['— Unknown', 'low']};
2143
+ const HARNESS = {needed: [dot('red') + ' Needed', 'high'], partial: [dot('yellow') + ' Partial', 'medium'],
2144
+ in_place: [I('checkCircle') + ' In place', 'low'], not_needed: [dot('grey') + ' Not needed', 'low'], unknown: ['— Unknown', 'low']};
1973
2145
  const harnessChip = h => h ? `<b>${HARNESS[h.verdict][0]}</b>` : '—';
1974
2146
 
1975
2147
  VIEWS.diagnose = async (page) => {
@@ -1978,7 +2150,7 @@ VIEWS.diagnose = async (page) => {
1978
2150
  const gdir = {claude: '~/.claude', codex: '~/.codex', gemini: '~/.gemini'}[d.agent || 'claude'];
1979
2151
  PB.length = 0;
1980
2152
  const t = d.total || {}, tr = d.trend || {};
1981
- const sevChip = s => s === 'high' ? '🔴' : s === 'medium' ? '🟡' : '⚪';
2153
+ const sevChip = s => dot(s === 'high' ? 'red' : s === 'medium' ? 'yellow' : 'grey');
1982
2154
  page.innerHTML = `${focusStrip(d)}
1983
2155
  <div class="note" style="font-size:14px"><b>${esc(d.headline)}</b>
1984
2156
  ${tr.explanation ? `<br>${esc(tr.explanation)}` : ''}</div>
@@ -1991,7 +2163,7 @@ VIEWS.diagnose = async (page) => {
1991
2163
  </div>
1992
2164
  <div id="dx-live"></div>${card('Running now', d.live_sessions.length ? `<div class="stack">${d.live_sessions.map(x => `
1993
2165
  <div class="item sev-${x.severity === 'ok' ? 'low' : x.severity}">
1994
- <div class="hd">${x.severity === 'high' ? '🔴' : x.severity === 'medium' ? '🟡' : '🟢'}
2166
+ <div class="hd">${dot(x.severity === 'high' ? 'red' : x.severity === 'medium' ? 'yellow' : 'green')}
1995
2167
  ${esc(stitle(x))} <span class="note">· ${esc(x.project)}</span><span class="spacer"></span>
1996
2168
  <span style="font-variant-numeric:tabular-nums">${fmtNum(x.context)} context · ${fmtInt(x.steps)} steps</span></div>
1997
2169
  <div class="dt">${esc(x.advice)} <span class="note">Last write ${x.idle_min} min ago; context grew
@@ -2009,7 +2181,7 @@ VIEWS.diagnose = async (page) => {
2009
2181
  {badge: BADGE.recommendation, hint: '"Above baseline" = context re-read beyond 100K'})}
2010
2182
  <div id="dx-mem"></div>${card(`Add to ${md}${isCl ? ' / memory' : ''} (from your past prompts)`, `<div class="stack">${d.memory_suggestions.map(x => `
2011
2183
  <div class="item sev-${x.kind === 'security' ? 'high' : x.already_saved ? 'low' : 'medium'}">
2012
- <div class="hd">${x.kind === 'security' ? '🔐' : x.kind === 'template' ? '⚙' : x.kind === 'reference' ? '🔗' : '📝'}
2184
+ <div class="hd">${I(x.kind === 'security' ? 'lock' : x.kind === 'template' ? 'cog' : x.kind === 'reference' ? 'link' : 'note')}
2013
2185
  ${esc(pt(x.text.slice(0, 140)))}${x.already_saved ? ' <span class="na">already saved</span>' : ''}
2014
2186
  <span class="spacer"></span><span class="note">${fmtInt(x.sessions)} sessions</span></div>
2015
2187
  <div class="dt">${esc(x.why)}</div>
@@ -2052,7 +2224,7 @@ VIEWS.diagnose = async (page) => {
2052
2224
  ...(isCl ? [{h: 'Memory', num: 1, f: r => r.memory_tokens == null ? '—' : `~${fmtInt(r.memory_tokens)}`}] : []),
2053
2225
  {h: 'Explore %', num: 1, f: r => fmtPct(r.explore_pct)},
2054
2226
  ...(isCl ? [{h: 'Harness', f: r => harnessChip(r.harness)}] : []),
2055
- {h: 'Needs update', f: r => r.issues.length ? r.issues.map(i => sevChip(i.severity) + ' ' + esc(i.title)).join('<br>') : '✓'},
2227
+ {h: 'Needs update', f: r => r.issues.length ? r.issues.map(i => sevChip(i.severity) + ' ' + esc(i.title)).join('<br>') : I('check')},
2056
2228
  ];
2057
2229
  page.querySelectorAll('.sess-link').forEach(l => l.onclick = e => { e.preventDefault(); openSession(l.dataset.sess); });
2058
2230
  $('#dx-proj', page).innerHTML = table(cols, d.projects);
@@ -2091,7 +2263,7 @@ VIEWS.anomalies = async (page) => {
2091
2263
  '<div class="legend" id="anl"></div>', {badge: BADGE.estimated})}
2092
2264
  ${card('Detected anomalies', `<div class="stack" id="anolist">${a.anomalies.length
2093
2265
  ? a.anomalies.map((x, i) => `<div class="item sev-${x.severity} clickable" data-i="${i}">
2094
- <div class="hd">${x.severity === 'high' ? '🚨' : '⚠️'} ${esc(x.title)}
2266
+ <div class="hd">${I(x.severity === 'high' ? 'siren' : 'alert')} ${esc(x.title)}
2095
2267
  <span class="spacer"></span><span class="pill">${esc(x.type)}</span></div>
2096
2268
  <div class="dt">${esc(x.detail)}</div>
2097
2269
  <div class="mt"><span>observed ${fmtNum(x.metric_value)}</span>
@@ -2108,7 +2280,7 @@ VIEWS.anomalies = async (page) => {
2108
2280
  const bars = $('#an', page).querySelectorAll('rect');
2109
2281
  tl.forEach((r, i) => { if (dates.has(r.bucket) && bars[i]) bars[i].setAttribute('fill', 'var(--critical)'); });
2110
2282
  $('#anl', page).innerHTML = `<span class="it"><span class="swatch" style="background:${seriesVar(0)}"></span>Normal day</span>
2111
- <span class="it"><span class="swatch" style="background:var(--critical)"></span>🚨 Anomalous day</span>`;
2283
+ <span class="it"><span class="swatch" style="background:var(--critical)"></span>${I('siren')} Anomalous day</span>`;
2112
2284
  wireAnomalies(page, a.anomalies);
2113
2285
  };
2114
2286
 
@@ -2130,7 +2302,7 @@ VIEWS.forecast = async (page) => {
2130
2302
  ${f.limit_exhaustion_date === S.opts.unavailable_label
2131
2303
  ? kpi('Limit exhaustion date', null, 'Requires a configured allowance')
2132
2304
  : kpi('Limit exhaustion date', esc(f.limit_exhaustion_date),
2133
- f.will_exceed ? '🔴 forecast exceeds allowance' : '🟢 within allowance', {badge: BADGE.forecast})}
2305
+ f.will_exceed ? dot('red') + ' forecast exceeds allowance' : dot('green') + ' within allowance', {badge: BADGE.forecast})}
2134
2306
  </div>
2135
2307
  ${card('Cumulative spend and forecast fan', f.insufficient_history
2136
2308
  ? '<div class="empty">Fewer than 7 priced days in the window: bands not shown.</div>'
@@ -2176,7 +2348,7 @@ VIEWS.budgets = async (page) => {
2176
2348
  </div>
2177
2349
  <div class="meter ${l.status}"><i style="width:${Math.min(l.used_pct, 100)}%"></i></div>
2178
2350
  <div class="mt">
2179
- ${l.thresholds_breached.length ? `<span>⚠️ breached ${l.thresholds_breached.join('%, ')}%</span>` : '<span>No threshold breached</span>'}
2351
+ ${l.thresholds_breached.length ? `<span>${I('alert')} breached ${l.thresholds_breached.join('%, ')}%</span>` : '<span>No threshold breached</span>'}
2180
2352
  ${l.thresholds_forecast_breach.length
2181
2353
  ? `<span style="color:var(--serious-ink)">forecast to breach ${l.thresholds_forecast_breach.join('%, ')}%</span>` : ''}
2182
2354
  </div></div>`
@@ -2258,13 +2430,13 @@ VIEWS.scorecard = async (page) => {
2258
2430
  </div></div>`, {badge: BADGE.estimated,
2259
2431
  footer: 'Each dimension is measured from your transcripts; budget adherence needs a budget in Settings.'})}
2260
2432
  <div class="grid g3">
2261
- ${card('✅ What is good', `<ul style="margin:0 0 0 18px;font-size:12.5px">${
2433
+ ${card('What is good', `<ul style="margin:0 0 0 18px;font-size:12.5px">${
2262
2434
  sc.what_is_good.map(x => `<li style="margin-bottom:6px">${esc(x)}</li>`).join('')
2263
- || '<li class="na">Nothing scored above 60 in this range</li>'}</ul>`)}
2264
- ${card('⚠️ Needs attention', `<ul style="margin:0 0 0 18px;font-size:12.5px">${
2435
+ || '<li class="na">Nothing scored above 60 in this range</li>'}</ul>`, {icon: I('checkCircle')})}
2436
+ ${card('Needs attention', `<ul style="margin:0 0 0 18px;font-size:12.5px">${
2265
2437
  sc.needs_attention.map(x => `<li style="margin-bottom:6px">${esc(x)}</li>`).join('')
2266
- || '<li class="na">Nothing scored below 70</li>'}</ul>`)}
2267
- ${card('🎯 Biggest opportunity', sc.biggest_opportunity ? `
2438
+ || '<li class="na">Nothing scored below 70</li>'}</ul>`, {icon: I('alert')})}
2439
+ ${card('Biggest opportunity', sc.biggest_opportunity ? `
2268
2440
  <div class="item sev-high"><div class="hd">${esc(sc.biggest_opportunity.title)}</div>
2269
2441
  <div class="dt">${esc(sc.biggest_opportunity.detail)}</div>
2270
2442
  <div class="mt"><span>Estimated excess ${fmtUSD(sc.biggest_opportunity.estimated_excess_usd)}</span>
@@ -2484,14 +2656,14 @@ function focusStrip(d, n = 5) {
2484
2656
  const nHidden = all.length - items.length;
2485
2657
  if (!all.length) return '';
2486
2658
  const fixes = items.filter(i => i.kind === 'fix').length, habits = items.filter(i => i.kind === 'habit').length;
2487
- return `<section class="focus"><div class="focus-hd">🎯 What to change
2659
+ return `<section class="focus"><div class="focus-hd">${I('target')} What to change
2488
2660
  <span class="note">${fixes} to fix once · ${habits} habit${habits === 1 ? '' : 's'} from your past sessions</span>
2489
2661
  <span class="spacer"></span>${nHidden ? `<button class="act ghost" data-unhide="1">Show ${nHidden} hidden</button>` : ''}</div>
2490
2662
  ${items.slice(0, n).map((it, k) => `<div class="focus-it l${it.lvl === 1 ? 1 : 2}" data-go="${it.view}" data-anchor="${it.anchor}" data-key="${esc(it.key)}">
2491
2663
  <span class="fn">${k + 1}</span>
2492
2664
  <span class="ft"><span class="fx">${esc(it.text)}</span><span class="fw">${esc(it.why || '')}</span></span>
2493
2665
  <span class="fl">${it.kind === 'habit' ? 'Habit' : 'Fix once'}</span>
2494
- <button class="fd" title="Hide this until its evidence changes" aria-label="Hide">✕</button></div>`).join('')
2666
+ <button class="fd" title="Hide this until its evidence changes" aria-label="Hide">${I('x')}</button></div>`).join('')
2495
2667
  || '<div class="note">Everything here is hidden. Nice.</div>'}</section>`;
2496
2668
  }
2497
2669
  function wireFocus(root) {
@@ -2608,7 +2780,9 @@ async function render() {
2608
2780
  if (!f.include_sandbox) bits.push('excl. sandbox');
2609
2781
  $('#crumbs').innerHTML = bits.join(' <span style="opacity:.4">·</span> ');
2610
2782
  filterBar();
2611
- page.innerHTML = '<div class="loading">Computing…</div>';
2783
+ scopeStrip();
2784
+ page.innerHTML = pageLoader();
2785
+ loaderTicker(page);
2612
2786
  updateNavBadges();
2613
2787
  try {
2614
2788
  await (VIEWS[S.view] || VIEWS.overview)(page);
@@ -2637,8 +2811,8 @@ function toggleRecent(force) {
2637
2811
  const scrim = h('<div class="recent-scrim"></div>');
2638
2812
  const p = h(`<aside class="recent" aria-label="Recent sessions"><header>
2639
2813
  <h2>Recent</h2><span class="note">all time · agent &amp; project filters apply</span>
2640
- <span class="spacer"></span><button class="iconbtn" data-x>Close ✕</button></header>
2641
- <div class="recent-search"><span class="mag">⌕</span>
2814
+ <span class="spacer"></span><button class="iconbtn" data-x>Close ${I('x')}</button></header>
2815
+ <div class="recent-search"><span class="mag">${I('search')}</span>
2642
2816
  <input id="recent-q" placeholder="Search your prompts…" value="${esc(RECENT.q)}"></div>
2643
2817
  <div class="content"><div class="loading">Loading…</div></div></aside>`);
2644
2818
  scrim.onclick = () => toggleRecent(false);
@@ -2677,7 +2851,7 @@ async function renderRecent() {
2677
2851
  }
2678
2852
 
2679
2853
  const recentLive = list => !list.length ? '' : `<div class="recent-hd">Running now
2680
- <span class="spacer"></span><a href="#" data-go-live>Close or kill ›</a></div>
2854
+ <span class="spacer"></span><a href="#" data-go-live>Close or kill ${I('chevRight')}</a></div>
2681
2855
  ${list.map((x, i) => `<div class="item recent-it sev-${x.severity === 'high' ? 'high' : x.severity === 'medium' ? 'medium' : 'low'}" data-live="${i}">
2682
2856
  <div class="hd"><span class="live-dot ${x.status === 'busy' ? 'busy' : ''}" title="${esc(x.status)}"></span>
2683
2857
  <span class="recent-title">${esc((!MASKED && x.name) || shortId(x.session_id))}</span>
@@ -2686,9 +2860,9 @@ const recentLive = list => !list.length ? '' : `<div class="recent-hd">Running n
2686
2860
  ${x.context ? `<span>${fmtNum(x.context)} context</span>` : ''}
2687
2861
  ${x.est_cost_usd != null ? `<span>${fmtUSD(x.est_cost_usd)}</span>` : ''}</div>
2688
2862
  <div class="live-actions">
2689
- ${x.resume ? `<button class="act ghost" data-resume="${esc(x.resume)}" title="Copy: ${esc(x.resume)}">⧉ Resume</button>` : ''}
2690
- ${x.signalable && x.agent === 'claude' ? '<button class="act" data-la="compact" title="Types /compact into that session\'s terminal">🗜 Compact</button>' : ''}
2691
- ${x.signalable ? `<button class="act warn" data-la="interrupt" ${x.status !== 'busy' ? 'disabled title="Nothing running"' : 'title="Stops the current turn, like pressing Esc"'}>⏸ Stop</button>` : ''}
2863
+ ${x.resume ? `<button class="act ghost" data-resume="${esc(x.resume)}" title="Copy: ${esc(x.resume)}">${I('copy')} Resume</button>` : ''}
2864
+ ${x.signalable && x.agent === 'claude' ? '<button class="act" data-la="compact" title="Types /compact into that session\'s terminal">' + I('compact') + ' Compact</button>' : ''}
2865
+ ${x.signalable ? `<button class="act warn" data-la="interrupt" ${x.status !== 'busy' ? 'disabled title="Nothing running"' : 'title="Stops the current turn, like pressing Esc"'}>${I('pause')} Stop</button>` : ''}
2692
2866
  ${x.session_id ? `<button class="act ghost" data-open-sess="${esc(x.session_id)}">Details</button>` : ''}
2693
2867
  <span class="live-msg"></span></div></div>`).join('')}
2694
2868
  <div class="recent-hd">Past sessions</div>`;
@@ -2701,9 +2875,9 @@ const recentSessions = (rows, liveIds) => rows.map(r => `
2701
2875
  <div class="mt"><span>${esc(r.project || '')}</span><span>${fmtInt(r.prompts)} prompts</span>
2702
2876
  <span>${fmtWhen(r.started_at)}</span></div>
2703
2877
  <div class="live-actions">
2704
- ${r.resume ? `<button class="act ghost" data-resume="${esc(r.resume)}" title="Copy: ${esc(r.resume)}">⧉ Resume</button>` : ''}
2878
+ ${r.resume ? `<button class="act ghost" data-resume="${esc(r.resume)}" title="Copy: ${esc(r.resume)}">${I('copy')} Resume</button>` : ''}
2705
2879
  <button class="act ghost" data-open-sess="${esc(r.session_id)}">Details</button>
2706
- <span class="spacer"></span><span class="note recent-caret">${RECENT.open_ids.has(r.session_id) ? '▾' : '▸'} prompts</span></div>
2880
+ <span class="spacer"></span><span class="note recent-caret">${I(RECENT.open_ids.has(r.session_id) ? 'chevDown' : 'chevRight')} prompts</span></div>
2707
2881
  <div class="recent-prompts"${RECENT.open_ids.has(r.session_id) ? '' : ' hidden'}></div></div>`).join('')
2708
2882
  || '<div class="empty">No sessions for these filters</div>';
2709
2883
 
@@ -2716,7 +2890,7 @@ const recentPrompts = rows => rows.map(x => `<div class="item recent-it">
2716
2890
  ${promptLine(x)}
2717
2891
  <div class="mt"><span>${esc((!MASKED && x.session_title) || shortId(x.session_id))}</span>
2718
2892
  <span>${esc(x.project || '')}</span>
2719
- <a href="#" data-open-sess="${esc(x.session_id)}">Session ›</a></div></div>`).join('');
2893
+ <a href="#" data-open-sess="${esc(x.session_id)}">Session ${I('chevRight')}</a></div></div>`).join('');
2720
2894
 
2721
2895
  async function loadSessionPrompts(host, sid) {
2722
2896
  host.innerHTML = '<div class="note">Loading…</div>';
@@ -2745,7 +2919,7 @@ function wireRecent(root) {
2745
2919
  if (e.target.closest('button, a, .recent-prompts')) return;
2746
2920
  host.hidden = !host.hidden;
2747
2921
  it.classList.toggle('open', !host.hidden);
2748
- it.querySelector('.recent-caret').textContent = (host.hidden ? '▸' : '▾') + ' prompts';
2922
+ it.querySelector('.recent-caret').innerHTML = I(host.hidden ? 'chevRight' : 'chevDown') + ' prompts';
2749
2923
  host.hidden ? RECENT.open_ids.delete(sid) : RECENT.open_ids.add(sid);
2750
2924
  if (!host.hidden && !host.childElementCount) loadSessionPrompts(host, sid);
2751
2925
  };
@@ -2892,12 +3066,13 @@ async function syncLabel() {
2892
3066
  const b = $('#sync'); if (!b) return;
2893
3067
  const d = await fetch('/api/sync').then(r => r.json()).catch(() => ({}));
2894
3068
  if (d.job && d.job.state === 'running') return runSync();
2895
- b.title = `Last synced ${ago(d.built_at)}. Re-read every agent's local data so the dashboard is current`;
2896
- b.textContent = `⟳ Sync · ${ago(d.built_at)}`;
3069
+ b.title = `Last synced ${ago(d.built_at)}. Re-read every agent's transcripts from disk into the warehouse`;
3070
+ b.classList.remove('busy');
3071
+ b.innerHTML = `${ICON.sync}Sync <span class="ago">· ${ago(d.built_at)}</span>`;
2897
3072
  }
2898
3073
  async function runSync() {
2899
3074
  const b = $('#sync');
2900
- b.disabled = true; b.textContent = '⟳ Syncing…';
3075
+ b.disabled = true; b.classList.add('busy'); b.innerHTML = `${ICON.sync}Syncing…`;
2901
3076
  try {
2902
3077
  const {job} = await doAction('sync');
2903
3078
  const j = await followJob(job);
@@ -2916,7 +3091,7 @@ VIEWS.freemodels = async (page) => {
2916
3091
  ${!d.bin_on_path ? `<div class="item sev-medium"><div class="dt">${esc(d.bin_dir)} is not on your PATH, so the new commands
2917
3092
  won't run by name. Add <code>export PATH="$HOME/.local/bin:$PATH"</code> to ~/.zshrc.</div></div>` : ''}
2918
3093
  ${card('How to use and test a free model', `<div class="stack">
2919
- <div class="dt"><b>1. Test:</b> click <b>▶ Test it</b> on an added model. It asks the model directly, then runs a real one-line Claude Code session through the new command.</div>
3094
+ <div class="dt"><b>1. Test:</b> click <b>${I('play')} Test it</b> on an added model. It asks the model directly, then runs a real one-line Claude Code session through the new command.</div>
2920
3095
  <div class="dt"><b>2. Use:</b> open a terminal in any project and run the command, e.g. <code>claude-qwen-coder</code>. It's normal Claude Code, running on the free model.</div>
2921
3096
  <div class="dt"><b>3. Check which model is on:</b> type <code>/model</code> or <code>/status</code> inside that session. It shows the Qwen model.</div>
2922
3097
  <div class="dt"><b>Will it appear in /model in my normal <code>claude</code>?</b> No. Normal <code>claude</code> talks only to Anthropic, which doesn't serve Qwen. Each free model lives behind its own command, so your Claude sessions stay unchanged. Inside a free-model session, <code>/model</code> can't switch to Opus/Sonnet either; exit and run <code>claude</code> for that.</div>
@@ -2929,11 +3104,11 @@ VIEWS.freemodels = async (page) => {
2929
3104
  ? `Runs on this machine · ~${m.download_gb} GB download · wants ${m.min_ram_gb} GB RAM (you have ${m.ram_gb} GB)`
2930
3105
  : 'Runs in the cloud via OpenRouter · no download'}</div>
2931
3106
  ${m.installed
2932
- ? `<div class="dt">✅ Added. Run <code>${esc(m.command)}</code> in any project.</div>
2933
- <div><button class="act" data-test="${esc(m.id)}">▶ Test it</button>
3107
+ ? `<div class="dt">${I('checkCircle')} Added. Run <code>${esc(m.command)}</code> in any project.</div>
3108
+ <div><button class="act" data-test="${esc(m.id)}">${I('play')} Test it</button>
2934
3109
  <button class="act ghost" data-rm="${esc(m.id)}">Remove</button></div>
2935
3110
  <div data-testlog="${esc(m.id)}"></div>`
2936
- : `<div><button class="act" data-add="${esc(m.id)}" ${m.fits_ram ? '' : 'title="Less RAM than recommended"'}>+ Add ${esc(m.command)}</button></div>`}
3111
+ : `<div><button class="act" data-add="${esc(m.id)}" ${m.fits_ram ? '' : 'title="Less RAM than recommended"'}>${I('plus')} Add ${esc(m.command)}</button></div>`}
2937
3112
  </div>`, {badge: m.installed ? '<span class="badge">Added</span>' : ''})).join('')}</div>`;
2938
3113
  page.querySelectorAll('[data-go]').forEach(a => a.onclick = () => go(a.dataset.go));
2939
3114
  page.querySelectorAll('[data-add]').forEach(b => b.onclick = () => addFreeModel(b.dataset.add));
@@ -2950,7 +3125,7 @@ async function testFreeModel(id, host, btn) {
2950
3125
  const {job} = await doAction(`free_model/${id}/test`);
2951
3126
  const j = await followJob(job, host);
2952
3127
  host.insertAdjacentHTML('beforeend', j.state === 'done'
2953
- ? '<div class="item sev-low"><div class="dt">✅ Working end to end.</div></div>'
3128
+ ? '<div class="item sev-low"><div class="dt">' + I('checkCircle') + ' Working end to end.</div></div>'
2954
3129
  : '<div class="item sev-high"><div class="dt">Test failed. See the log above.</div></div>');
2955
3130
  } catch (e) { host.innerHTML = `<div class="item sev-high"><div class="dt">${esc(e.message)}</div></div>`; }
2956
3131
  btn.disabled = false;
@@ -2966,10 +3141,10 @@ async function addFreeModel(id) {
2966
3141
  <h3>${esc(plan.model.name)}</h3>
2967
3142
  <div class="dt">This will:</div>
2968
3143
  <div class="stack">${plan.steps.map((s, i) => `<div class="item ${s.warn ? 'sev-medium' : ''}"><div class="dt">
2969
- ${s.warn ? '⚠' : `${i + 1}.`} ${esc(s.do)}${s.consent ? ' <span class="pill">needs your OK</span>' : ''}</div></div>`).join('')}</div>
3144
+ ${s.warn ? I('alert') : `${i + 1}.`} ${esc(s.do)}${s.consent ? ' <span class="pill">needs your OK</span>' : ''}</div></div>`).join('')}</div>
2970
3145
  ${plan.needs.map(n => `${n.steps ? `<div class="item"><div class="hd">How to get your ${esc(n.label)}</div>
2971
3146
  <ol class="dt" style="margin:6px 0 8px 18px;padding:0;line-height:1.7">${n.steps.map(x => `<li>${esc(x)}</li>`).join('')}</ol>
2972
- ${n.url ? `<a class="act" href="${esc(n.url)}" target="_blank" rel="noopener" style="text-decoration:none;display:inline-block">Open ${esc(new URL(n.url).host)} ↗</a>` : ''}</div>` : ''}
3147
+ ${n.url ? `<a class="act" href="${esc(n.url)}" target="_blank" rel="noopener" style="text-decoration:none;display:inline-block">Open ${esc(new URL(n.url).host)} ${I('external')}</a>` : ''}</div>` : ''}
2973
3148
  <label class="dt"><b>${esc(n.label)}</b><br>
2974
3149
  <input type="password" data-need="${esc(n.field)}" autocomplete="off" style="width:100%;margin-top:6px;padding:6px 8px;border:1px solid var(--border);border-radius:6px;background:var(--surface-2)">
2975
3150
  <span class="note">${esc(n.help)}</span></label>`).join('')}
@@ -2991,8 +3166,8 @@ async function addFreeModel(id) {
2991
3166
  const {job} = await doAction(`free_model/${id}/install`, body);
2992
3167
  const j = await followJob(job, $('#fm-log', c));
2993
3168
  if (j.state === 'done') { $('#fm-log', c).insertAdjacentHTML('beforeend',
2994
- `<div class="item sev-low"><div class="dt">✅ Added. Open a terminal in any project and run <code>${esc(j.result.command)}</code>. Inside it, <code>/model</code> shows the Qwen model; your normal <code>claude</code> is unchanged.</div>
2995
- <div><button class="act" id="fm-test">▶ Test it now</button></div><div id="fm-testlog"></div></div>`);
3169
+ `<div class="item sev-low"><div class="dt">${I('checkCircle')} Added. Open a terminal in any project and run <code>${esc(j.result.command)}</code>. Inside it, <code>/model</code> shows the Qwen model; your normal <code>claude</code> is unchanged.</div>
3170
+ <div><button class="act" id="fm-test">${I('play')} Test it now</button></div><div id="fm-testlog"></div></div>`);
2996
3171
  $('#fm-test', c).onclick = e => testFreeModel(id, $('#fm-testlog', c), e.target);
2997
3172
  if (S.view === 'freemodels') render(); }
2998
3173
  else $('#fm-go', c).disabled = false;
@@ -3005,12 +3180,12 @@ VIEWS.compare = async (page) => {
3005
3180
  const d = await fetch('/api/compare?agents=' + encodeURIComponent(S.filter.agents.join(','))).then(r => r.json());
3006
3181
  if (d.error) throw new Error(d.error);
3007
3182
  const hasFree = d.rows.some(r => r.kind === 'free');
3008
- const stars = n => n == null ? '<span class="na">—</span>' : '★'.repeat(Math.floor(n)) + (n % 1 ? '½' : '') +
3009
- `<span style="opacity:.25">${'★'.repeat(5 - Math.ceil(n))}</span>`;
3183
+ const stars = n => n == null ? '<span class="na">—</span>' : I('star').repeat(Math.floor(n)) + (n % 1 ? I('starHalf') : '') +
3184
+ `<span style="opacity:.25">${I('star').repeat(5 - Math.ceil(n))}</span>`;
3010
3185
  const price = r => r.kind === 'free' ? '<b>$0</b>' : `${fmtUSD(r.price_in)} / ${fmtUSD(r.price_out)}`;
3011
3186
  const status = r => r.kind !== 'free'
3012
3187
  ? (r.requests ? `${fmtInt(r.requests)} req · ${fmtUSD(r.cost)}` : '<span class="na">not used</span>')
3013
- : !r.fits ? `❌ needs ${r.min_ram_gb} GB RAM` : r.installed ? `✅ <code>${esc(r.command)}</code>` : `<a data-go="freemodels">+ Add</a>`;
3188
+ : !r.fits ? `${I('xCircle')} needs ${r.min_ram_gb} GB RAM` : r.installed ? `${I('checkCircle')} <code>${esc(r.command)}</code>` : `<a data-go="freemodels">${I('plus')} Add</a>`;
3014
3189
  page.innerHTML = `
3015
3190
  ${card(hasFree ? 'Claude vs free models' : `${esc(agentWord())} models side by side`, `<div id="cmp"></div>`, {flush: 1, badge: BADGE.recommendation,
3016
3191
  hint: hasFree ? `This machine: ${esc(d.os)}, ${d.ram_gb} GB RAM` : 'List prices, your usage in range', footer: esc(d.note)})}
@@ -3051,9 +3226,9 @@ VIEWS.toolkit = async (page) => {
3051
3226
  <span class="spacer"></span><span class="note">${fmtInt(m.sessions)} sessions · ${esc(m.projects.join(', '))}</span></div>
3052
3227
  <div class="dt">${esc(m.what)}</div>
3053
3228
  ${m.evidence.length ? `<div class="dt note">Seen: ${ev(m.evidence)}</div>` : ''}
3054
- ${m.installed ? '<div class="dt">✅ Already connected</div>' : `
3229
+ ${m.installed ? '<div class="dt">' + I('checkCircle') + ' Already connected</div>' : `
3055
3230
  <div class="dt"><code>${esc(m.command)}</code></div>
3056
- <div><button class="act" data-mcp="${esc(m.id)}">+ Add to Claude</button><span class="mcp-out"></span></div>`}
3231
+ <div><button class="act" data-mcp="${esc(m.id)}">${I('plus')} Add to Claude</button><span class="mcp-out"></span></div>`}
3057
3232
  </div>`).join('') || '<div class="empty">No recurring pattern points to an MCP server</div>'}</div>`,
3058
3233
  {badge: BADGE.recommendation})}
3059
3234
  ${card('Skills from what you repeat', `<div class="stack">${d.skills.map((s, i) => `
@@ -3061,13 +3236,13 @@ VIEWS.toolkit = async (page) => {
3061
3236
  <span class="spacer"></span><span class="note">${fmtInt(s.sessions)} sessions · ${fmtInt(s.runs)} runs · ${esc(s.projects.join(', '))}</span></div>
3062
3237
  <div class="dt">${esc(s.what)}</div>
3063
3238
  <div class="dt note">Examples: ${ev(s.examples.slice(0, 2))}</div>
3064
- ${s.installed ? '<div class="dt">✅ Skill exists</div>' : `<div><button class="act" data-skill="${i}">+ Create skill</button><span class="sk-out"></span></div>`}
3239
+ ${s.installed ? '<div class="dt">' + I('checkCircle') + ' Skill exists</div>' : `<div><button class="act" data-skill="${i}">${I('plus')} Create skill</button><span class="sk-out"></span></div>`}
3065
3240
  </div>`).join('') || '<div class="empty">Nothing repeats often enough yet</div>'}</div>`,
3066
3241
  {badge: BADGE.recommendation, hint: `Created in ${d.skills_dir}; edit the SKILL.md afterwards`})}`;
3067
3242
  page.querySelectorAll('[data-skill]').forEach(b => b.onclick = async () => {
3068
3243
  const out = b.nextElementSibling; b.disabled = true;
3069
3244
  try { const r = await doAction('skill', d.skills[+b.dataset.skill]);
3070
- out.innerHTML = ` ✅ Created <code>${esc(r.path)}</code>. Use it with <code>${esc(r.use)}</code>.`; }
3245
+ out.innerHTML = ` ${I('checkCircle')} Created <code>${esc(r.path)}</code>. Use it with <code>${esc(r.use)}</code>.`; }
3071
3246
  catch (e) { out.textContent = ' ' + e.message; b.disabled = false; }
3072
3247
  });
3073
3248
  page.querySelectorAll('[data-mcp]').forEach(b => b.onclick = async () => {
@@ -3081,7 +3256,7 @@ VIEWS.toolkit = async (page) => {
3081
3256
  body[n.field] = out.querySelector(`[data-need="${n.field}"]`).value;
3082
3257
  }
3083
3258
  b.disabled = true;
3084
- try { const r = await doAction(`mcp/${m.id}`, body); out.innerHTML = ` ✅ Added. ${esc(r.next)}`; }
3259
+ try { const r = await doAction(`mcp/${m.id}`, body); out.innerHTML = ` ${I('checkCircle')} Added. ${esc(r.next)}`; }
3085
3260
  catch (e) { out.textContent = ' ' + e.message; b.disabled = false; }
3086
3261
  });
3087
3262
  };
@@ -3094,7 +3269,7 @@ VIEWS.cloud = async (page) => {
3094
3269
  const gap = (t.billed_claude_cost || 0) - (t.local_claude_cost || 0);
3095
3270
  const setup = Object.entries(pv).map(([k, p]) => `
3096
3271
  <div class="item sev-${cfg[k] ? 'low' : 'medium'}">
3097
- <div class="hd">${cfg[k] ? '✓' : '○'} ${esc(p.name)}<span class="spacer"></span>
3272
+ <div class="hd">${I(cfg[k] ? 'check' : 'circle')} ${esc(p.name)}<span class="spacer"></span>
3098
3273
  <span class="note">${cfg[k] ? 'key found' : 'no key'}</span></div>
3099
3274
  <div class="dt"><b>Gives you:</b> ${esc(p.covers)}</div>
3100
3275
  <div class="dt"><b>Get a key:</b> ${esc(p.how)}</div>
@@ -3107,7 +3282,7 @@ VIEWS.cloud = async (page) => {
3107
3282
  Nothing is fetched until you click Refresh.
3108
3283
  <span class="spacer"></span>
3109
3284
  <span class="note">Last fetched: ${esc(d.fetched_at || 'never')}</span>
3110
- <button class="act" id="cl-sync" ${none ? 'disabled title="Add a key first"' : ''}>↯ Refresh from APIs</button></div>
3285
+ <button class="act" id="cl-sync" ${none ? 'disabled title="Add a key first"' : ''}>${I('cloudDown')} Refresh from APIs</button></div>
3111
3286
  <div id="cl-log"></div>
3112
3287
  ${Object.keys(d.errors).length ? `<div class="note sev-high">${Object.entries(d.errors)
3113
3288
  .map(([k, v]) => `<div><b>${esc(k)}</b>: ${esc(v)}</div>`).join('')}</div>` : ''}
@@ -3170,7 +3345,7 @@ VIEWS.cloud = async (page) => {
3170
3345
  render();
3171
3346
  } catch (e) {
3172
3347
  $('#cl-log', page).innerHTML = `<div class="note sev-high">Failed: ${esc(e.message)}</div>`;
3173
- btn.disabled = false; btn.textContent = '↯ Refresh from APIs';
3348
+ btn.disabled = false; btn.innerHTML = `${I('cloudDown')} Refresh from APIs`;
3174
3349
  }
3175
3350
  };
3176
3351
  };
@@ -3192,11 +3367,11 @@ const TOUR_WELCOME = [
3192
3367
  act: 'Click <b>Codex</b> or <b>All</b> to switch.'},
3193
3368
  {el: '[data-range]', t: 'Date range & filters', see: 'Range chips, plus Model, Project, Category and Threshold filters.',
3194
3369
  get: 'Narrow any dashboard to a period, a model or a project.',
3195
- act: 'Try <b>7 days</b>, then open <b>Project ▾</b> to focus on one repo.'},
3370
+ act: `Try <b>7 days</b>, then open <b>Project ${I('chevDown')}</b> to focus on one repo.`},
3196
3371
  {el: '#gsearch', t: 'Search', see: 'Search across prompts, sessions, models and dates.',
3197
3372
  get: 'Jump straight to the prompt or session you remember.', act: 'Type a word from a recent prompt and press Enter.'},
3198
3373
  {el: '#sync', t: 'Keep it current', see: 'Sync re-reads every agent\'s local data.',
3199
- get: 'Fresh numbers after you\'ve been working.', act: 'Click <b>⟳ Sync</b> whenever the numbers look stale.'},
3374
+ get: 'Fresh numbers after you\'ve been working.', act: `Click <b>${I('sync')} Sync</b> whenever the numbers look stale.`},
3200
3375
  {el: '#tour-btn', t: 'Tour any dashboard', see: 'This button starts a tour of the dashboard you\'re on.',
3201
3376
  get: 'A short explanation of each section: what it shows, what you get, what you can do.',
3202
3377
  act: 'Open any dashboard and click <b>? Tour</b> to replay it. Next: this dashboard.'},
@@ -3229,13 +3404,13 @@ const TOURS = {
3229
3404
  {el: 'card:What each agent records', t: 'What each agent records', see: 'Which fields each agent writes to disk on this machine.', get: 'Why some dashboards are hidden for some agents: the data simply isn\'t recorded.', act: 'Check here first when a number reads "not recorded".'}],
3230
3405
  live: [
3231
3406
  {el: 'kpis', t: 'Running now', see: 'Sessions running right now, which are working (spending) at this moment, the largest live context and what live sessions have spent.', get: 'What is costing you money this second.', act: 'Only <b>working</b> sessions consume tokens; idle ones cost again on your next message.'},
3232
- {el: '#live-refresh', t: 'Refresh & the rules', see: 'The note explains what Interrupt, Close and Force kill do, and how non-Claude agents are detected.', get: 'Confidence before you stop something.', act: 'Click <b>↻ Refresh</b> for the latest state.'},
3407
+ {el: '#live-refresh', t: 'Refresh & the rules', see: 'The note explains what Interrupt, Close and Force kill do, and how non-Claude agents are detected.', get: 'Confidence before you stop something.', act: `Click <b>${I('reload')} Refresh</b> for the latest state.`},
3233
3408
  {el: '.item[data-i]', t: 'A session', see: 'Agent, project, context size, steps, cost and last activity, plus advice when a session gets heavy.', get: 'Control over sessions that are quietly growing expensive.', act: 'Interrupt, Close or Force kill it (each needs two clicks), copy its resume command, or <b>Hand over</b> a Claude session to a fresh one.'}],
3234
3409
  scorecard: [
3235
3410
  {el: 'card:AI FinOps Score', t: 'Your score', see: 'The overall score with a component breakdown: cache use, model mix, waste and budget.', get: 'Where your habits are strong and where they cost money.', act: 'Hover a component to see how it is calculated.'},
3236
- {el: 'card:✅ What is good', t: 'What is good', see: 'The components you already score well on.', get: 'The habits worth keeping.', act: 'Keep these when you change your setup.'},
3237
- {el: 'card:⚠️ Needs attention', t: 'Needs attention', see: 'The components dragging the score down.', get: 'A short list of what to fix.', act: 'Start at the top: it carries the most weight.'},
3238
- {el: 'card:🎯 Biggest opportunity', t: 'Biggest opportunity', see: 'The single change that would move the score most.', get: 'One concrete action grounded in observed spend.', act: 'Apply it, then Sync and re-check the score.'}],
3411
+ {el: 'card:What is good', t: 'What is good', see: 'The components you already score well on.', get: 'The habits worth keeping.', act: 'Keep these when you change your setup.'},
3412
+ {el: 'card:Needs attention', t: 'Needs attention', see: 'The components dragging the score down.', get: 'A short list of what to fix.', act: 'Start at the top: it carries the most weight.'},
3413
+ {el: 'card:Biggest opportunity', t: 'Biggest opportunity', see: 'The single change that would move the score most.', get: 'One concrete action grounded in observed spend.', act: 'Apply it, then Sync and re-check the score.'}],
3239
3414
  usage: [
3240
3415
  {el: 'kpis', t: 'Token breakdown', see: 'Total, input, output, thinking, cache read and cache write tokens, plus requests, sessions, prompts and active days.', get: 'Exactly which kind of token you spend on. Cache read is cheap; uncached input is not.', act: 'Compare cache read against input: a low ratio means context is being re-sent, not reused.'},
3241
3416
  {el: 'card0', t: 'Usage over time', see: 'Tokens or cost per day (or per hour on short ranges).', get: 'Spikes and quiet periods at a glance.', act: 'Hover a bar for the day\'s numbers; narrow the range to zoom in.'},
@@ -3246,7 +3421,7 @@ const TOURS = {
3246
3421
  {el: 'card0', t: 'Allowance gauges', see: 'One gauge per allowance you configured: cost, tokens or requests.', get: 'How much of each limit is gone, and how many days it lasts at this rate.', act: 'Set your limits in config/settings.json to make these exact.'},
3247
3422
  {el: 'card:Daily consumption within the billing period', t: 'Daily consumption', see: 'Each day of the billing period against the pace you\'d need to stay inside the limit.', get: 'Which days pushed you off pace.', act: 'Hover a bar to see that day against the target line.'}],
3248
3423
  models: [
3249
- {el: 'kpis', t: 'Model headlines', see: 'How many models you used and what the mix costs.', get: 'A first read on whether the mix is right.', act: 'Filter to one model with <b>Model ▾</b> above.'},
3424
+ {el: 'kpis', t: 'Model headlines', see: 'How many models you used and what the mix costs.', get: 'A first read on whether the mix is right.', act: `Filter to one model with <b>Model ${I('chevDown')}</b> above.`},
3250
3425
  {el: 'card:Cost share', t: 'Cost share', see: 'Spend split across models.', get: 'The model that owns your bill.', act: 'Check whether that model is doing work a cheaper one could.'},
3251
3426
  {el: 'card:Token share', t: 'Token share', see: 'The same split by tokens instead of money.', get: 'The gap between the two charts is the price difference at work.', act: 'A model with a small token share but a big cost share is your expensive one.'},
3252
3427
  {el: 'card:Model FinOps table', t: 'Model FinOps table', see: 'Per model: cost, tokens, context, output and cost per 1K output.', get: 'What each model really costs for the work you give it.', act: 'Sort by cost per 1K output, then open <b>Model switch</b>.'},
@@ -3305,16 +3480,24 @@ const TOURS = {
3305
3480
  {el: 'card:MCP servers', t: 'MCP servers', see: 'Each configured server, whether it was ever called, and its context cost.', get: 'Tool definitions loaded on every request for nothing.', act: 'Remove servers that are configured but never called.'},
3306
3481
  {el: 'card:Connectors', t: 'Connectors', see: 'claude.ai connectors and their share of context.', get: 'The same check for connectors as for MCP servers.', act: 'Disconnect what you don\'t use from this machine.'}],
3307
3482
  hygiene: [
3308
- {el: 'kpis', t: 'Where spend sat', see: 'The share of spend in requests above each context size, and the share that came after a session first crossed it.', get: 'How much of the bill was re-sending a large prefix.', act: 'A high "after crossing" share means the expensive part of a session was its tail.'},
3483
+ {el: 'kpis', t: 'Where spend sat', see: 'Headline shares of your spend, colour-graded: green is low, amber and orange are rising, red means most of the bill sat there. Hover a card for its bands.', get: 'How much of the bill was re-sending a large prefix.', act: 'Walk through each card with Next; the colour is a reading aid, not a verdict.'},
3484
+ {el: 'kpi:Spend in requests ≥ 100K', t: 'Spend in requests ≥ 100K context', see: 'The share of spend on requests whose prompt side (context re-sent to the model) was 100K tokens or more.', get: 'How much you paid while carrying a big conversation. Every turn re-sends it, so large context costs on every request.', act: 'Red here with a red "after crossing" card means sessions keep going long after they got big.'},
3485
+ {el: 'kpi:Spend in requests ≥ 150K', t: 'Spend in requests ≥ 150K context', see: 'The same share, but for requests at 150K tokens or more.', get: 'The heaviest tier: close to where auto-compaction kicks in.', act: 'Compare with the 100K card: a small gap means sessions that pass 100K usually keep growing to 150K.'},
3486
+ {el: 'kpi:Spend after a session first crossed 100K', t: 'Spend after first crossing 100K', see: 'The share of spend that came after a session first reached 100K, even if a later /compact brought it back down. The detail line counts how many sessions ever crossed it.', get: 'How much of your bill is the long tail of big sessions.', act: 'If this is much higher than the "requests ≥ 100K" card, compaction is working but sessions still run long afterwards.'},
3487
+ {el: 'kpi:Spend after a session first crossed 150K', t: 'Spend after first crossing 150K', see: 'The same, measured from the first time a session reached 150K.', get: 'Spend in sessions that went all the way to the heavy end.', act: 'Few sessions but a high share? Open them in the ranked list below.'},
3488
+ {el: 'kpi:Spend near or over the context window', t: 'Near or over the context window', see: 'The share of spend on requests using 90% or more of the model\'s context window, or going over it.', get: 'Turns that risk truncation or a forced compaction mid-task.', act: 'Anything above amber is worth a look: a /compact or fresh session before you hit the wall keeps the answer quality up.'},
3489
+ {el: 'kpi:Sessions in range', t: 'Sessions in range', see: 'How many sessions and main-thread requests the page is based on (blue: context, not graded).', get: 'The sample size behind the percentages.', act: 'Widen the date range if this is small; a handful of sessions makes the shares jumpy.'},
3490
+ {el: 'kpi:Spend in range', t: 'Spend in range', see: 'Total estimated main-thread spend the shares are taken from. Subagent turns are left out because they run on their own prefix.', get: 'The dollar base: multiply any share above by this to get dollars.', act: 'Marked Estimated because it uses list prices, not your invoice.'},
3491
+ {el: 'card:Context per request', t: 'Context per request', see: 'Prompt-side tokens on every request of one session, in order.', get: 'Where a session grew, and where compaction dropped it back.', act: 'Click a row in the ranked list below to switch session.'},
3309
3492
  {el: 'card:Sessions ranked', t: 'Sessions', see: 'Each session\'s context trajectory and what it spent after crossing the threshold.', get: 'The sessions where a fresh start would have mattered most.', act: 'Click a row for its trajectory; alt-click to open the session.'}],
3310
3493
  waste: [
3311
3494
  {el: 'kpis', t: 'Waste headlines', see: 'Estimated excess, exposed spend, and how many prompts, sessions and rules are involved.', get: 'An honest estimate of avoidable spend.', act: '<b>Exposed</b> is what flagged work cost in total; <b>excess</b> is how much more than a fair baseline.'},
3312
- {el: 'card:🔴 High waste', t: 'High waste', see: 'The rules that fired hardest: repeated reads, retries, stale sessions.', get: 'The costly patterns, each with the baseline it is measured against.', act: 'Open <b>Show flagged items</b> to see the evidence, then fix these first.'},
3313
- {el: 'card:🟡 Optimization opportunities', t: 'Medium findings', see: 'Patterns worth changing but not urgent.', get: 'The next tier of opportunities.', act: 'Batch these into one config change.'},
3314
- {el: 'card:⚪ Low-priority observations', t: 'Low-priority observations', see: 'Small findings, kept for completeness.', get: 'Context for the numbers above.', act: 'Skim them; act only if one matches a habit you want to change.'}],
3495
+ {el: 'card:High waste', t: 'High waste', see: 'The rules that fired hardest: repeated reads, retries, stale sessions.', get: 'The costly patterns, each with the baseline it is measured against.', act: 'Open <b>Show flagged items</b> to see the evidence, then fix these first.'},
3496
+ {el: 'card:Optimization opportunities', t: 'Medium findings', see: 'Patterns worth changing but not urgent.', get: 'The next tier of opportunities.', act: 'Batch these into one config change.'},
3497
+ {el: 'card:Low-priority observations', t: 'Low-priority observations', see: 'Small findings, kept for completeness.', get: 'Context for the numbers above.', act: 'Skim them; act only if one matches a habit you want to change.'}],
3315
3498
  freemodels: [
3316
3499
  {el: 'card:How to use and test a free model', t: 'How it works', see: 'How to plug a local or free cloud model into Claude Code, and how to test it.', get: 'Zero-cost options for simple or private work.', act: 'Read the RAM guidance before you pick a model.'},
3317
- {el: 'card1', t: 'A model', see: 'Each model with its size, RAM needs, strengths and limits.', get: 'A realistic idea of what runs on your machine.', act: 'Click <b>+ Add</b> on a model that fits your RAM.'}],
3500
+ {el: 'card1', t: 'A model', see: 'Each model with its size, RAM needs, strengths and limits.', get: 'A realistic idea of what runs on your machine.', act: `Click <b>${I('plus')} Add</b> on a model that fits your RAM.`}],
3318
3501
  compare: [
3319
3502
  {el: 'card0', t: 'Models side by side', see: 'Price, context window, ratings and your own usage per model.', get: 'A clear pick for each kind of work.', act: 'Sort by output price, the one that usually dominates the bill.'},
3320
3503
  {el: 'card:Use Claude for', t: 'Use the big model for', see: 'The work that genuinely needs a top model.', get: 'Where paying more actually pays off.', act: 'Keep multi-file and agentic work here.'},
@@ -3339,7 +3522,7 @@ const TOURS = {
3339
3522
  {el: 'card:Budget vs actual vs forecast', t: 'Budget vs actual', see: 'Each budget line with its budget, actual, forecast and variance.', get: 'A warning before you overspend, not after.', act: 'Watch the variance column: a positive forecast variance means trouble.'},
3340
3523
  {el: 'card:Configure budgets', t: 'Configure budgets', see: 'Your budget lines, limits and alert thresholds.', get: 'Numbers that make the forecast and burn dashboards meaningful.', act: 'Edit a budget and save; every dashboard picks it up.'}],
3341
3524
  cloud: [
3342
- {el: 'kpis', t: 'Billed vs local', see: 'What the vendor billed the whole organisation next to what this machine recorded.', get: 'The gap: usage from other machines, other members, or work off this machine.', act: 'Click <b>↯ Refresh from APIs</b> to fetch — this is the only page that goes online.'},
3525
+ {el: 'kpis', t: 'Billed vs local', see: 'What the vendor billed the whole organisation next to what this machine recorded.', get: 'The gap: usage from other machines, other members, or work off this machine.', act: `Click <b>${I('cloudDown')} Refresh from APIs</b> to fetch — this is the only page that goes online.`},
3343
3526
  {el: 'card:Set up the APIs', t: 'Set up the APIs', see: 'Which provider keys were found, and how to get each one.', get: 'Org-wide Claude Code usage per user, and Cursor team spend.', act: 'Run claude-finops --set-key, or set the environment variable, then restart.'},
3344
3527
  {el: 'card:Billed vs local', t: 'The comparison', see: 'Billed totals against local totals for the same period.', get: 'Proof of how much of the bill this machine explains.', act: 'A large gap means most spend happens elsewhere: check the user tables.'},
3345
3528
  {el: 'card:Claude Code users', t: 'Users org-wide', see: 'Each Claude Code user in the organisation and their usage.', get: 'Who drives the bill across the team.', act: 'Compare your own row with the team average.'},
@@ -3363,6 +3546,13 @@ function tourTarget(el) {
3363
3546
  const page = $('#page');
3364
3547
  if (!el) return null;
3365
3548
  if (el === 'kpis') return page?.querySelector('.kpi')?.closest('.grid') || null;
3549
+ // 'kpi:Label start' — one KPI card, matched by the start of its label (badge text ignored).
3550
+ if (el.startsWith('kpi:')) {
3551
+ const want = el.slice(4).trim().toLowerCase();
3552
+ for (const k of page?.querySelectorAll('.kpi') || [])
3553
+ if ((k.querySelector('.l')?.firstChild?.textContent || '').trim().toLowerCase().startsWith(want)) return k;
3554
+ return null;
3555
+ }
3366
3556
  const m = /^card(\d+)$/.exec(el);
3367
3557
  if (m) return page?.querySelectorAll('.card')[+m[1]] || null;
3368
3558
  // 'card:Some title' — match a card by the start of its heading, so a step keeps
@@ -3415,7 +3605,7 @@ function showStep() {
3415
3605
  const last = i === steps.length - 1;
3416
3606
  pop.innerHTML = `
3417
3607
  <div class="tour-hd"><span class="tour-n">${esc(name)} · ${i + 1} / ${steps.length}</span>
3418
- <button class="tour-x" data-t="skip" title="Close (Esc)">✕</button></div>
3608
+ <button class="tour-x" data-t="skip" title="Close (Esc)">${I('x')}</button></div>
3419
3609
  <h4>${esc(s.t)}</h4>
3420
3610
  ${s.see ? `<div class="tour-row"><b>What you see</b><span>${s.see}</span></div>` : ''}
3421
3611
  ${s.get ? `<div class="tour-row"><b>What you get</b><span>${s.get}</span></div>` : ''}