cchubber 0.5.8 → 0.6.1

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.
@@ -0,0 +1,171 @@
1
+ import { tokensLabel, int } from './vs-text.js';
2
+
3
+ // "Did you spend them well?": a strip under the grade card, and an in-page panel that hands the user a prompt for their
4
+ // own Claude Code. Everything is local except one optional anonymous count per button (see the script), which is only
5
+ // wired in when telemetry was on at the time the report was made.
6
+
7
+ export const PROMPT = `Be brutally honest with me. I want the truth, not encouragement.
8
+
9
+ 1. Find what I said I wanted: read CLAUDE.md, the README, any plan, roadmap, TODO or notes file, and my recent commit messages and branch names. If my goals aren't written down anywhere, say that first. That is a finding.
10
+ 2. Find what I actually did: the git log for the last 30 days, what shipped, what is half finished, what I started and dropped, and what I rewrote more than once.
11
+ 3. Compare the two. Where did the time and tokens really go? Which work moved the goal, and which was avoidance that felt like progress (polishing, refactoring, tooling, research, switching projects)?
12
+ 4. Give me a one-line verdict first, then the evidence with file names and commits. No softening and no compliment sandwich.
13
+ 5. Tell me the one thing I should do next and the one thing I should stop doing.
14
+ 6. Last, answer honestly: would a system that keeps my goals, plan and decisions in front of you every session, and pushes back when I drift, have closed this gap? That is what Mover OS does (moveros.dev). If it would not have helped, say so.`;
15
+
16
+ export const MOVER_URL = 'https://moveros.dev/?utm_source=cchubber&utm_medium=report&utm_campaign=spent_well';
17
+ export const INTRO = "Paste this into Claude Code in the project you've been working on. It compares what you set out to do with what you actually did. It won't be nice.";
18
+ export const FIX_LINE = "The fix: Mover OS keeps what you said you'd do in front of Claude every session and calls it out when you drift.";
19
+ export const EVENTS = ['spent_well_open', 'spent_well_copy', 'spent_well_mover'];
20
+
21
+ export const FINE_ON = 'Clicking these buttons sends one anonymous count each (which button, this version, your anonymous install ID). Nothing from your usage. Turn it off with --no-telemetry.';
22
+ export const FINE_OFF = 'Telemetry is off, so clicking these buttons sends nothing.';
23
+
24
+ function esc(s) {
25
+ return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
26
+ }
27
+
28
+ /** 25.3B, 740M, 12,300: the short form for the terminal. */
29
+ export function tokensShort(n) {
30
+ const trim = (x) => x.toFixed(1).replace(/\.0$/, '');
31
+ return n >= 1e9 ? `${trim(n / 1e9)}B` : n >= 1e6 ? `${trim(n / 1e6)}M` : int(n);
32
+ }
33
+
34
+ /** Every token the logs hold, read from the same totals the repricing uses. */
35
+ export function totalTokensOf(report) {
36
+ const t = report?.costAnalysis?.totals || {};
37
+ return (t.inputTokens || 0) + (t.outputTokens || 0) + (t.cacheReadTokens || 0) + (t.cacheWriteTokens || 0);
38
+ }
39
+
40
+ export function spentWellLine(report) {
41
+ const n = totalTokensOf(report);
42
+ return n > 0 ? `You burnt ${tokensShort(n)} tokens. Did you spend them well? Find out in your report.` : null;
43
+ }
44
+
45
+ const CSS = `
46
+ #sw{
47
+ --sw-surface:#1b1c1d; --sw-deep:#0d0e0f; --sw-ink:#e3e2e3; --sw-dim:#c7c4d7; --sw-faint:#908fa0; --sw-line:rgba(70,69,84,.25);
48
+ --sw-accent:#c0c1ff; --sw-warm:#ffb690; --sw-on:#7fe0b0;
49
+ --sw-sans:Inter,system-ui,-apple-system,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
50
+ --sw-mono:"JetBrains Mono",ui-monospace,"SF Mono",Menlo,Consolas,monospace;
51
+ width:100%; max-width:740px; margin-left:auto; margin-right:auto; font-family:var(--sw-sans);
52
+ }
53
+ #sw .sw-strip{display:flex; align-items:center; justify-content:space-between; gap:16px 24px; flex-wrap:wrap; background:var(--sw-surface);
54
+ border:1px solid rgba(70,69,84,.15); border-left:3px solid var(--sw-warm); border-radius:12px; padding:18px 22px}
55
+ #sw .sw-line{margin:0; flex:1 1 280px; font-size:clamp(17px,2.4vw,21px); line-height:1.3; font-weight:700; color:var(--sw-dim)}
56
+ #sw .sw-line b{color:var(--sw-ink); font-weight:800}
57
+ #sw .sw-btn{appearance:none; font:inherit; font-size:14px; font-weight:700; cursor:pointer; text-decoration:none; color:var(--sw-ink); background:rgba(255,255,255,.05);
58
+ border:1px solid rgba(70,69,84,.3); border-radius:8px; padding:10px 18px; min-height:44px; display:inline-flex; align-items:center; justify-content:center}
59
+ #sw .sw-btn:hover{background:#292a2b}
60
+ #sw .sw-btn.sw-primary{background:var(--sw-accent); color:#1000a9; border-color:transparent}
61
+ #sw .sw-btn.sw-primary:hover{background:#d3d4ff}
62
+ #sw .sw-btn:focus-visible, #sw .sw-link:focus-visible, #sw .sw-prompt:focus-visible, #sw .sw-head:focus-visible{outline:2px solid var(--sw-accent); outline-offset:2px}
63
+ #sw .sw-fine{margin:10px 4px 0; font-size:11px; line-height:1.5; color:var(--sw-faint)}
64
+
65
+ #sw .sw-panel{margin-top:12px; background:var(--sw-surface); border:1px solid rgba(70,69,84,.15); border-radius:12px; padding:24px}
66
+ #sw .sw-panel[hidden]{display:none}
67
+ #sw .sw-top{display:flex; justify-content:space-between; align-items:flex-start; gap:12px}
68
+ #sw .sw-head{margin:0; font-size:20px; line-height:1.2; font-weight:800; color:var(--sw-ink)}
69
+ #sw .sw-intro{margin:12px 0 14px; font-size:15px; line-height:1.5; color:var(--sw-dim)}
70
+ #sw .sw-prompt{display:block; width:100%; box-sizing:border-box; resize:vertical; background:var(--sw-deep); color:var(--sw-ink); border:1px solid rgba(144,143,160,.35);
71
+ border-radius:8px; padding:14px; font-family:var(--sw-mono); font-size:13px; line-height:1.55; min-height:160px}
72
+ #sw .sw-row{display:flex; align-items:center; gap:12px; flex-wrap:wrap; margin-top:12px}
73
+ /* read out by screen readers; the button label already shows Copied to everyone else */
74
+ #sw .sw-status{position:absolute; width:1px; height:1px; margin:-1px; padding:0; overflow:hidden; clip:rect(0 0 0 0); white-space:nowrap; border:0}
75
+ #sw .sw-fix{margin:22px 0 0; padding-top:18px; border-top:1px solid var(--sw-line); font-size:16px; line-height:1.45; font-weight:600; color:var(--sw-dim)}
76
+ #sw .sw-link{display:inline-flex; align-items:center; min-height:44px; margin-top:10px; font-size:14px; font-weight:700; color:var(--sw-accent)}
77
+
78
+ @media (max-width:640px){
79
+ #sw .sw-strip{padding:16px}
80
+ #sw .sw-strip .sw-btn{flex:1 1 100%}
81
+ #sw .sw-panel{padding:18px 16px}
82
+ #sw .sw-prompt{font-size:12px}
83
+ #sw .sw-row .sw-btn{flex:1 1 100%}
84
+ }
85
+ `;
86
+
87
+ // Plain ES5, no template literals and no ${ so it can sit in a String.raw block. It reads #sw-data and touches only #sw.
88
+ const SCRIPT = String.raw`
89
+ (function(){
90
+ var T = JSON.parse(document.getElementById('sw-data').textContent); // { on: false } or { on: true, url, uid, v }
91
+ var btn = document.getElementById('sw-open'), panel = document.getElementById('sw-panel'), head = document.getElementById('sw-head');
92
+ var prompt = document.getElementById('sw-prompt'), copyBtn = document.getElementById('sw-copy'), status = document.getElementById('sw-status');
93
+ var closeBtn = document.getElementById('sw-close'), link = document.getElementById('sw-mover');
94
+ var sent = {}, timer = null;
95
+
96
+ // One anonymous count per button per page load, and only when telemetry was on when this report was made.
97
+ function beacon(ev){
98
+ if (!T.on || sent[ev]) return;
99
+ sent[ev] = true;
100
+ try { if (navigator.sendBeacon) navigator.sendBeacon(T.url, JSON.stringify({ event: ev, v: T.v, uid: T.uid })); } catch (e) {}
101
+ }
102
+ function fit(){ prompt.style.height = 'auto'; prompt.style.height = (prompt.scrollHeight + 4) + 'px'; }
103
+ function open(){
104
+ panel.hidden = false; btn.setAttribute('aria-expanded', 'true'); fit();
105
+ head.focus(); panel.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
106
+ beacon('spent_well_open');
107
+ }
108
+ function close(){ panel.hidden = true; btn.setAttribute('aria-expanded', 'false'); btn.focus(); }
109
+ btn.addEventListener('click', function(){ if (panel.hidden) open(); else close(); });
110
+ closeBtn.addEventListener('click', close);
111
+ document.addEventListener('keydown', function(e){ if ((e.key === 'Escape' || e.key === 'Esc') && !panel.hidden) { e.preventDefault(); close(); } });
112
+ window.addEventListener('resize', function(){ if (!panel.hidden) fit(); });
113
+
114
+ function done(ok){
115
+ status.textContent = ok ? 'Copied' : 'Could not copy. Select the text and copy it yourself.';
116
+ copyBtn.textContent = ok ? 'Copied' : 'Copy prompt';
117
+ clearTimeout(timer);
118
+ timer = setTimeout(function(){ status.textContent = ''; copyBtn.textContent = 'Copy prompt'; }, 2600);
119
+ }
120
+ function fallback(){
121
+ prompt.focus(); prompt.select(); prompt.setSelectionRange(0, prompt.value.length);
122
+ var ok = false; try { ok = document.execCommand('copy'); } catch (e) {}
123
+ done(ok);
124
+ }
125
+ copyBtn.addEventListener('click', function(){
126
+ beacon('spent_well_copy');
127
+ if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(prompt.value).then(function(){ done(true); }, fallback); } else { fallback(); }
128
+ });
129
+ link.addEventListener('click', function(){ beacon('spent_well_mover'); });
130
+ link.addEventListener('auxclick', function(){ beacon('spent_well_mover'); });
131
+ })();
132
+ `;
133
+
134
+ /**
135
+ * The strip and the panel. `telemetry` is what telemetry.js said when the report was made: { on: false } puts no address
136
+ * and no id in the file; { on: true, url, uid, v } lets the three buttons send one count each.
137
+ */
138
+ export function renderSpentWell(report, telemetry = { on: false }) {
139
+ const n = totalTokensOf(report);
140
+ if (!(n > 0)) return '';
141
+ const on = !!(telemetry && telemetry.on && telemetry.url);
142
+ const data = on ? { on: true, url: telemetry.url, uid: telemetry.uid, v: telemetry.v } : { on: false };
143
+ const json = JSON.stringify(data).replace(/</g, '\\u003c').replace(/>/g, '\\u003e').replace(/&/g, '\\u0026');
144
+ return `
145
+ <!-- DID YOU SPEND THEM WELL: strip under the grade card, panel with the prompt -->
146
+ <style>${CSS}</style>
147
+ <section id="sw" aria-label="Did you spend your tokens well?">
148
+ <div class="sw-strip">
149
+ <p class="sw-line">You burnt <b>${esc(tokensLabel(n))}</b> tokens. Did you spend them well?</p>
150
+ <button class="sw-btn sw-primary" id="sw-open" type="button" aria-expanded="false" aria-controls="sw-panel">Find out</button>
151
+ </div>
152
+ <p class="sw-fine">${esc(on ? FINE_ON : FINE_OFF)}</p>
153
+ <div class="sw-panel" id="sw-panel" role="region" aria-labelledby="sw-head" hidden>
154
+ <div class="sw-top">
155
+ <h3 class="sw-head" id="sw-head" tabindex="-1">Did you spend them well?</h3>
156
+ <button class="sw-btn" id="sw-close" type="button" aria-label="Close">Close</button>
157
+ </div>
158
+ <p class="sw-intro">${esc(INTRO)}</p>
159
+ <textarea class="sw-prompt" id="sw-prompt" readonly rows="18" spellcheck="false" aria-label="Prompt to paste into Claude Code">${esc(PROMPT)}</textarea>
160
+ <div class="sw-row">
161
+ <button class="sw-btn sw-primary" id="sw-copy" type="button">Copy prompt</button>
162
+ <span class="sw-status" id="sw-status" role="status" aria-live="polite"></span>
163
+ </div>
164
+ <p class="sw-fix">${esc(FIX_LINE)}</p>
165
+ <a class="sw-link" id="sw-mover" href="${esc(MOVER_URL)}" target="_blank" rel="noopener">See Mover OS</a>
166
+ </div>
167
+ </section>
168
+ <script type="application/json" id="sw-data">${json}</script>
169
+ <script id="sw-js">${SCRIPT}</script>
170
+ `;
171
+ }
@@ -1,3 +1,5 @@
1
+ import { usd0, low, high } from './vs-text.js';
2
+
1
3
  // ANSI color codes
2
4
  const c = {
3
5
  reset: '\x1b[0m',
@@ -61,3 +63,11 @@ export function renderTerminal(report) {
61
63
  }
62
64
  }
63
65
  }
66
+
67
+
68
+ /** The one line after the usual summary: the cheapest and the dearest bill, computed from the reprice object. */
69
+ export function vsOneLiner(rp) {
70
+ if (!rp || !rp.models || rp.models.length < 2) return null;
71
+ const lo = low(rp), hi = high(rp);
72
+ return `On other models: ${lo.label} ${usd0(lo.total)} to ${hi.label} ${usd0(hi.total)}. The race and a card to post are in your report.`;
73
+ }
@@ -0,0 +1,439 @@
1
+ import { FRONTIER_MODELS } from '../data/frontier-models.js';
2
+ import {
3
+ FINE_PRINT, PAID_FINE_PRINT, PAID_ACTUAL_TEXT, PAID_ACTUAL_POST, PAID_ACTUAL_CAPTION, SHARE_CMD, usd0, int, dayLabel, periodLabel, totalTokens, tokensLabel,
4
+ readLine, cacheLine, paidFacts, multipleFacts, timesLabel, pricesLine, introNote, filledNotes, overrideRows, postText, postHead, driftPost,
5
+ } from './vs-text.js';
6
+
7
+ function esc(s) {
8
+ if (s === 0) return '0';
9
+ if (!s) return '';
10
+ return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
11
+ }
12
+
13
+ // Bold the figures in a sentence (after escaping each piece), so the eye lands on the numbers.
14
+ function emph(text) {
15
+ return String(text).split(/(\$[\d,]+|\d[\d,]*%(?: to \d+%)?|\d[\d,]*)(?=[.\s]|$)/)
16
+ .map((part, i) => (i % 2 ? `<b>${esc(part)}</b>` : esc(part))).join('');
17
+ }
18
+
19
+ const REF_ID = '__ran';
20
+ const REF_LABEL = 'What you ran';
21
+ const REF_MAKER = 'Claude list price';
22
+
23
+ // ---------------------------------------------------------------------------------------------------------------
24
+ // STYLING LIVES HERE AND ONLY HERE. Every selector starts with #vs and every class is vs-prefixed, so this block can
25
+ // neither be restyled by the report around it nor restyle it. The palette is the report's own (#1b1c1d surface,
26
+ // #c0c1ff primary, #ffb690 secondary). A visual redesign should only need to touch this constant; the card colours
27
+ // are read from the same variables. Fonts are inherited from the report; nothing is fetched.
28
+ // ---------------------------------------------------------------------------------------------------------------
29
+ const FINAL_RULES = `#vs .vs-row{transition:none; transform:translateY(calc(var(--fpos) * var(--vs-row)))}
30
+ #vs .vs-fill{width:var(--fw)}
31
+ #vs .vs-now{display:none}
32
+ #vs .vs-final{display:inline}`;
33
+
34
+ const CSS = `
35
+ #vs{
36
+ --vs-surface:#1b1c1d; --vs-deep:#0d0e0f; --vs-ink:#e3e2e3; --vs-dim:#c7c4d7; --vs-faint:#908fa0;
37
+ --vs-line:rgba(70,69,84,.25); --vs-accent:#c0c1ff; --vs-warm:#ffb690; --vs-on:#7fe0b0; --vs-bar:#8083ff; --vs-bar2:#c0c1ff;
38
+ --vs-row:56px;
39
+ --vs-sans:Inter,system-ui,-apple-system,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
40
+ --vs-mono:"JetBrains Mono",ui-monospace,"SF Mono",Menlo,Consolas,monospace;
41
+ background:var(--vs-surface); border:1px solid rgba(70,69,84,.15); border-radius:12px; padding:32px; scroll-margin-top:16px;
42
+ }
43
+ #vs .vs-head{display:flex; justify-content:space-between; align-items:baseline; gap:12px; flex-wrap:wrap}
44
+ #vs .vs-title{margin:0; font-size:20px; line-height:1.2; font-weight:700; color:var(--vs-ink)}
45
+ #vs .vs-range{font-family:var(--vs-mono); font-size:11px; color:var(--vs-faint)}
46
+ #vs .vs-lead{margin:14px 0 0; font-size:clamp(22px,3.4vw,38px); line-height:1.1; font-weight:800; letter-spacing:-.02em; color:var(--vs-ink)}
47
+ #vs .vs-sub{margin:8px 0 0; font-size:14px; color:var(--vs-faint)}
48
+ #vs .vs-clock{display:flex; justify-content:space-between; align-items:baseline; gap:12px; margin:26px 0 8px; font-size:10px; color:var(--vs-faint); letter-spacing:.08em; text-transform:uppercase}
49
+ #vs .vs-clock b{font-family:var(--vs-mono); color:var(--vs-dim); font-weight:600; letter-spacing:0; text-transform:none; font-size:12px}
50
+
51
+ #vs .vs-race{list-style:none; margin:0; padding:0; position:relative; height:calc(var(--vs-n) * var(--vs-row)); border-top:1px solid var(--vs-line)}
52
+ #vs .vs-row{position:absolute; left:0; right:0; top:0; height:var(--vs-row); padding:0 4px; display:grid; align-items:center; column-gap:16px;
53
+ grid-template-columns:minmax(150px,230px) 1fr 120px; grid-template-areas:"name track amt";
54
+ transform:translateY(calc(var(--pos) * var(--vs-row))); transition:transform .75s cubic-bezier(.22,1,.32,1); border-bottom:1px solid var(--vs-line)}
55
+ #vs .vs-instant .vs-row{transition:none}
56
+ #vs .vs-name{grid-area:name; min-width:0; display:flex; flex-direction:column; gap:1px}
57
+ #vs .vs-label{font-size:14px; font-weight:700; color:var(--vs-ink); white-space:nowrap; overflow:hidden; text-overflow:ellipsis}
58
+ #vs .vs-meta{font-size:11px; color:var(--vs-faint); white-space:nowrap; overflow:hidden; text-overflow:ellipsis}
59
+ #vs .vs-tag{color:var(--vs-warm)}
60
+ #vs .vs-track{grid-area:track; height:8px; border-radius:8px; background:rgba(255,255,255,.05); overflow:hidden}
61
+ #vs .vs-fill{display:block; height:100%; width:0; border-radius:8px; background:linear-gradient(90deg,var(--vs-bar),var(--vs-bar2))}
62
+ #vs .vs-final{display:none}
63
+ #vs .vs-amt{grid-area:amt; text-align:right; font-family:var(--vs-mono); font-weight:700; font-size:18px; color:var(--vs-ink); font-variant-numeric:tabular-nums}
64
+ #vs .vs-ref .vs-label{color:var(--vs-warm)}
65
+ #vs .vs-ref .vs-fill{background:var(--vs-warm)}
66
+
67
+ #vs .vs-facts{margin:28px 0 0; display:flex; flex-direction:column; gap:10px}
68
+ #vs .vs-fact{margin:0; font-size:clamp(16px,2vw,21px); line-height:1.3; font-weight:600; color:var(--vs-faint)}
69
+ #vs .vs-fact b{color:var(--vs-ink); font-weight:800}
70
+ #vs .vs-fact.vs-drift b{color:var(--vs-on)}
71
+
72
+ #vs .vs-paid{margin:2px 0 0; display:flex; flex-direction:column; gap:6px; max-width:360px}
73
+ #vs .vs-plabel{font-size:13px; font-weight:700; color:var(--vs-dim)}
74
+ #vs .vs-pbox{display:flex; align-items:stretch; min-height:44px; background:var(--vs-deep); border:1px solid rgba(144,143,160,.45); border-radius:8px; overflow:hidden}
75
+ #vs .vs-pbox:focus-within{outline:2px solid var(--vs-accent); outline-offset:2px; border-color:var(--vs-accent)}
76
+ #vs .vs-cur{display:flex; align-items:center; padding:0 8px 0 14px; font-family:var(--vs-mono); font-size:16px; color:var(--vs-faint)}
77
+ #vs .vs-pin{flex:1 1 auto; min-width:0; width:100%; appearance:textfield; -moz-appearance:textfield; background:transparent; border:0; outline:0; color:var(--vs-ink);
78
+ font-family:var(--vs-mono); font-size:16px; padding:10px 14px 10px 0}
79
+ #vs .vs-pin::-webkit-outer-spin-button, #vs .vs-pin::-webkit-inner-spin-button{-webkit-appearance:none; margin:0}
80
+ #vs .vs-pin::placeholder{color:var(--vs-faint); opacity:.7}
81
+ #vs .vs-phelp{margin:0; font-size:11px; line-height:1.5; color:var(--vs-faint)}
82
+
83
+ #vs .vs-actions{display:flex; gap:10px; flex-wrap:wrap; margin:26px 0 0}
84
+ #vs .vs-btn{appearance:none; font:inherit; font-size:13px; font-weight:700; cursor:pointer; text-decoration:none; color:var(--vs-ink); background:rgba(255,255,255,.05);
85
+ border:1px solid rgba(70,69,84,.3); border-radius:8px; padding:10px 16px; min-height:40px; display:inline-flex; align-items:center}
86
+ #vs .vs-btn:hover{background:#292a2b}
87
+ #vs .vs-btn.vs-primary{background:var(--vs-accent); color:#1000a9; border-color:transparent}
88
+ #vs .vs-btn.vs-primary:hover{background:#d3d4ff}
89
+ #vs .vs-btn:focus-visible{outline:2px solid var(--vs-accent); outline-offset:2px}
90
+ #vs .vs-toast{min-height:18px; font-size:12px; color:var(--vs-on); margin:8px 2px 0}
91
+
92
+ #vs .vs-fine{margin-top:18px; padding-top:16px; border-top:1px solid var(--vs-line); font-size:11px; line-height:1.6; color:var(--vs-faint)}
93
+ #vs .vs-fine p{margin:0 0 6px}
94
+ #vs .vs-fine a{color:var(--vs-dim)}
95
+ #vs .vs-fine .vs-first{color:var(--vs-dim)}
96
+
97
+ @media (max-width:640px){
98
+ #vs{--vs-row:72px; padding:20px 16px}
99
+ #vs .vs-row{grid-template-columns:1fr auto; grid-template-areas:"name amt" "track track"; row-gap:6px; align-content:center}
100
+ #vs .vs-amt{font-size:17px}
101
+ #vs .vs-btn{flex:1 1 100%; justify-content:center}
102
+ #vs .vs-paid{max-width:none}
103
+ }
104
+ /* The markup is the START of the race, so the first paint is the start. The finished values are kept in the page and
105
+ shown by these rules for anyone who does not get the animation: reduced motion, or no script at all. */
106
+ @media (prefers-reduced-motion: reduce), (scripting: none){
107
+ ${FINAL_RULES}
108
+ }
109
+ `;
110
+
111
+ // ---------------------------------------------------------------------------------------------------------------
112
+ // The script. Plain ES5, no template literals and no ${ so it can sit in a String.raw block. It is wrapped in an IIFE,
113
+ // reads its numbers from the #vs-data block, and touches nothing outside #vs except window.cchubberVs.
114
+ // ---------------------------------------------------------------------------------------------------------------
115
+ const SCRIPT = String.raw`
116
+ (function(){
117
+ var VS = JSON.parse(document.getElementById('vs-data').textContent);
118
+ var DURATION = 6000; // the race lasts about six seconds
119
+ var box = document.getElementById('vs');
120
+ var reduce = !!(window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches);
121
+ var shot = /shot/.test(location.hash); // #shot jumps to the finished state, for a clean screenshot
122
+ var list = document.getElementById('vs-race');
123
+ // Each counter and the clock hold two spans: .vs-now (what the script drives; $0 in the served HTML) and .vs-final.
124
+ var clock = document.querySelector('#vs-clock .vs-now');
125
+ var clockLabel = document.querySelector('#vs-clock-label .vs-now');
126
+ var rows = {};
127
+ var maxFinal = 0;
128
+ VS.models.forEach(function(m){
129
+ var li = list.querySelector('[data-id="' + m.id + '"]');
130
+ rows[m.id] = { li: li, amt: li.querySelector('.vs-amt .vs-now'), fill: li.querySelector('.vs-fill'), pts: [0].concat(m.series), total: m.total };
131
+ if (m.total > maxFinal) maxFinal = m.total;
132
+ });
133
+ var N = VS.dateLabels.length;
134
+
135
+ function money(v){ return '$' + Math.round(v).toLocaleString('en-US'); }
136
+ function place(order){ order.forEach(function(id, i){ rows[id].li.style.setProperty('--pos', i); }); }
137
+
138
+ // One frame of the race. The curve is each model's running total by day, with a leading zero so every counter
139
+ // starts at $0; each logged day takes an equal slice of the time.
140
+ function frame(p){
141
+ var x = p * N;
142
+ var i = Math.min(N - 1, Math.floor(x));
143
+ var f = Math.min(1, x - i);
144
+ VS.models.forEach(function(m){
145
+ var r = rows[m.id];
146
+ var v = p >= 1 ? r.total : r.pts[i] + (r.pts[i + 1] - r.pts[i]) * f;
147
+ r.amt.textContent = money(v);
148
+ r.fill.style.width = (maxFinal ? v / maxFinal * 100 : 0).toFixed(2) + '%';
149
+ });
150
+ clock.textContent = VS.dateLabels[Math.min(N - 1, Math.max(0, Math.ceil(x) - 1))];
151
+ }
152
+
153
+ function settle(){
154
+ frame(1); place(VS.sortedOrder);
155
+ clock.textContent = VS.dateLabels[N - 1]; clockLabel.textContent = 'Your real days, replayed';
156
+ }
157
+
158
+ function run(){
159
+ list.classList.add('vs-instant'); place(VS.startOrder); frame(0); clockLabel.textContent = 'Replaying your real days';
160
+ void list.offsetWidth; list.classList.remove('vs-instant');
161
+ var t0 = null;
162
+ function tick(ts){
163
+ if (t0 === null) t0 = ts;
164
+ var p = Math.min(1, (ts - t0) / DURATION);
165
+ frame(p);
166
+ if (p < 1) requestAnimationFrame(tick); else setTimeout(settle, 250);
167
+ }
168
+ requestAnimationFrame(tick);
169
+ }
170
+
171
+ // The served HTML is already the first frame (everyone at $0, in the listed order), so finished numbers never flash.
172
+ // The race starts once a fair share of the block is on screen: 15% of it, or half the viewport for a tall block on a
173
+ // short screen. If that is already true on load it starts at once.
174
+ var started = false;
175
+ function go(){ if (started) return; started = true; window.removeEventListener('scroll', check); window.removeEventListener('resize', check); run(); }
176
+ function enough(seen, height, vh){ return seen > 0 && height > 0 && (seen / height >= 0.15 || seen >= vh * 0.5); }
177
+ // The visible height is clientHeight: on a phone, a wide table further down makes innerHeight (the layout viewport)
178
+ // taller than the screen, and the block would count as seen while it is still below the fold.
179
+ function inView(){
180
+ var r = list.getBoundingClientRect(), vh = document.documentElement.clientHeight || window.innerHeight;
181
+ return enough(Math.min(r.bottom, vh) - Math.max(r.top, 0), r.height, vh);
182
+ }
183
+ var ticking = false;
184
+ function check(){ if (ticking || started) return; ticking = true; requestAnimationFrame(function(){ ticking = false; if (inView()) go(); }); }
185
+ if (reduce || shot || N < 1) {
186
+ settle();
187
+ } else {
188
+ list.classList.add('vs-instant'); place(VS.startOrder); frame(0); void list.offsetWidth; list.classList.remove('vs-instant');
189
+ clockLabel.textContent = 'Replaying your real days';
190
+ // Armed on load, not at parse time: before the page's styles are in, the block looks near the top of the screen and
191
+ // the race would start where nobody is looking. From then on it starts on the first scroll that shows enough of it.
192
+ var arm = function(){
193
+ if (started) return;
194
+ if (inView()) { go(); return; }
195
+ window.addEventListener('scroll', check, { passive: true }); window.addEventListener('resize', check);
196
+ };
197
+ if (document.readyState === 'complete') arm(); else window.addEventListener('load', arm);
198
+ }
199
+ if (location.hash === '#vs') { window.addEventListener('load', function(){ setTimeout(function(){ box.scrollIntoView(); }, 60); }); }
200
+
201
+ // ---- the card: 1200 x 675, drawn on a canvas from the same numbers ----
202
+ function css(n){ return getComputedStyle(box).getPropertyValue(n).trim(); }
203
+ function rr(g, x, y, w, h, r){ g.beginPath(); g.moveTo(x + r, y); g.arcTo(x + w, y, x + w, y + h, r); g.arcTo(x + w, y + h, x, y + h, r); g.arcTo(x, y + h, x, y, r); g.arcTo(x, y, x + w, y, r); g.closePath(); }
204
+ function wrap(g, text, x, y, maxW, lh){
205
+ var words = text.split(' '), line = '', yy = y;
206
+ words.forEach(function(w){
207
+ var t = line ? line + ' ' + w : w;
208
+ if (g.measureText(t).width > maxW && line) { g.fillText(line, x, yy); line = w; yy += lh; } else { line = t; }
209
+ });
210
+ if (line) { g.fillText(line, x, yy); yy += lh; }
211
+ return yy;
212
+ }
213
+ function drawCard(cv){
214
+ cv.width = 1200; cv.height = 675;
215
+ var g = cv.getContext('2d'), C = VS.card;
216
+ var sans = css('--vs-sans') || 'sans-serif', mono = css('--vs-mono') || 'monospace';
217
+ var ink = css('--vs-ink'), dim = css('--vs-dim'), faint = css('--vs-faint'), accent = css('--vs-accent'), on = css('--vs-on'), warm = css('--vs-warm'), bar = css('--vs-bar'), bar2 = css('--vs-bar2'), line = css('--vs-line');
218
+ g.fillStyle = '#121315'; g.fillRect(0, 0, 1200, 675);
219
+ var gr = g.createRadialGradient(600, -60, 30, 600, -60, 800);
220
+ gr.addColorStop(0, 'rgba(192,193,255,0.16)'); gr.addColorStop(1, 'rgba(192,193,255,0)');
221
+ g.fillStyle = gr; g.fillRect(0, 0, 1200, 675);
222
+ g.strokeStyle = line; g.lineWidth = 2; rr(g, 14, 14, 1172, 647, 28); g.stroke();
223
+ g.textBaseline = 'alphabetic'; g.textAlign = 'left';
224
+
225
+ g.fillStyle = accent; g.font = '800 15px ' + sans; if ('letterSpacing' in g) g.letterSpacing = '4px';
226
+ g.fillText('CC HUBBER', 56, 62); if ('letterSpacing' in g) g.letterSpacing = '0px';
227
+ g.textAlign = 'right'; g.fillStyle = faint; g.font = '15px ' + mono; g.fillText(C.period, 1144, 62);
228
+ g.textAlign = 'left';
229
+ g.fillStyle = ink; g.font = '800 44px ' + sans; g.fillText(C.title, 56, 122);
230
+ g.fillStyle = dim; g.font = '22px ' + sans; g.fillText(C.sub, 56, 158);
231
+
232
+ // the sorted bills
233
+ var top = 190, rowH = 54, nameX = 56, barX = 300, barW = 300, amtX = 744;
234
+ var max = 0; C.bills.forEach(function(b){ if (b.total > max) max = b.total; });
235
+ C.bills.forEach(function(b, i){
236
+ var y = top + i * rowH;
237
+ g.fillStyle = ink; g.font = '700 23px ' + sans; g.textAlign = 'left'; g.fillText(b.label, nameX, y + 28);
238
+ if (b.note) { g.fillStyle = warm; g.font = '14px ' + sans; g.fillText(b.note, nameX, y + 46); }
239
+ g.fillStyle = 'rgba(255,255,255,0.06)'; rr(g, barX, y + 14, barW, 14, 7); g.fill();
240
+ var w = Math.max(10, barW * b.total / max);
241
+ var lg = g.createLinearGradient(barX, 0, barX + w, 0); lg.addColorStop(0, bar); lg.addColorStop(1, bar2);
242
+ g.fillStyle = lg; rr(g, barX, y + 14, w, 14, 7); g.fill();
243
+ g.textAlign = 'right'; g.fillStyle = ink; g.font = '800 25px ' + mono; g.fillText(b.amount, amtX, y + 30);
244
+ });
245
+
246
+ // the right-hand panel: read per write, what was paid, drift
247
+ g.textAlign = 'left';
248
+ g.fillStyle = 'rgba(255,255,255,0.035)'; rr(g, 800, 150, 344, 456, 22); g.fill();
249
+ g.strokeStyle = line; g.lineWidth = 1.5; rr(g, 800, 150, 344, 456, 22); g.stroke();
250
+ var y = 268;
251
+ g.fillStyle = accent; g.font = '800 104px ' + mono; g.fillText(C.read, 828, y);
252
+ g.fillStyle = dim; g.font = '21px ' + sans; y = wrap(g, C.readCaption, 828, y + 34, 290, 27);
253
+ if (C.paid) {
254
+ y += 18; g.fillStyle = ink; g.font = '800 52px ' + mono; g.fillText(C.paid.amount, 828, y + 30);
255
+ g.fillStyle = dim; g.font = '19px ' + sans; y = wrap(g, C.paid.caption, 828, y + 58, 290, 25);
256
+ }
257
+ if (C.drift) {
258
+ y += 18; g.fillStyle = on; g.font = '800 52px ' + mono; g.fillText(C.drift.pct, 828, y + 30);
259
+ g.fillStyle = dim; g.font = '19px ' + sans; y = wrap(g, C.drift.caption, 828, y + 58, 290, 25);
260
+ }
261
+
262
+ g.fillStyle = faint; g.font = '14px ' + sans; g.textAlign = 'left'; g.fillText(C.fine, 56, 644);
263
+ g.textAlign = 'right'; g.fillStyle = accent; g.font = '700 16px ' + mono; g.fillText(C.cmd, 1144, 644);
264
+ g.textAlign = 'left';
265
+ }
266
+
267
+ var toastEl = document.getElementById('vs-toast'), toastT = null;
268
+ function toast(msg){ toastEl.textContent = msg; clearTimeout(toastT); toastT = setTimeout(function(){ toastEl.textContent = ''; }, 2600); }
269
+
270
+ function download(){
271
+ var cv = document.createElement('canvas'); drawCard(cv);
272
+ cv.toBlob(function(blob){
273
+ if (!blob) { toast('Could not draw the card.'); return; }
274
+ var a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = 'cchubber-vs.png';
275
+ document.body.appendChild(a); a.click(); document.body.removeChild(a);
276
+ setTimeout(function(){ URL.revokeObjectURL(a.href); }, 4000);
277
+ toast('Card saved.');
278
+ }, 'image/png');
279
+ }
280
+ function copy(){
281
+ function fallback(){
282
+ var t = document.createElement('textarea'); t.value = VS.text; t.style.position = 'fixed'; t.style.opacity = '0';
283
+ document.body.appendChild(t); t.select();
284
+ var ok = false; try { ok = document.execCommand('copy'); } catch (e) {}
285
+ document.body.removeChild(t); toast(ok ? 'Copied.' : 'Could not copy. Use Post on X instead.');
286
+ }
287
+ if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(VS.text).then(function(){ toast('Copied.'); }, fallback); } else { fallback(); }
288
+ }
289
+ // ---- what you actually paid: typed here, used here, never stored or sent ----
290
+ var paidIn = document.getElementById('vs-paid-in');
291
+ var paidEl = document.getElementById('vs-paid-line'), multEl = document.getElementById('vs-mult-line'), pxEl = document.getElementById('vs-px');
292
+ var W = VS.words;
293
+ var est = { paid: paidEl.innerHTML, paidHidden: paidEl.hidden, mult: multEl.innerHTML, multHidden: multEl.hidden, card: VS.card.paid, text: VS.text };
294
+ var api = { drawCard: drawCard, text: VS.text };
295
+ function times(r){ return r >= 10 ? String(Math.round(r)) : r.toFixed(1).replace(/\.0$/, ''); }
296
+ function fill(tpl, key, val){ return tpl.replace(key, function(){ return val; }); } // a function, so the $ in $1,127 is literal
297
+ function assemble(head, optional){
298
+ var body = head;
299
+ optional.forEach(function(l){ if (!l) return; var next = body + ' ' + l; if ((next + '\n\n' + VS.cmd).length <= 280) body = next; });
300
+ if ((body + '\n\n' + VS.cmd).length > 280) body = head;
301
+ return body + '\n\n' + VS.cmd;
302
+ }
303
+ function applyPaid(){
304
+ var raw = paidIn.value, v = raw === '' ? NaN : Number(raw);
305
+ if (isFinite(v) && v > 0 && v < 1e9) {
306
+ var amt = money(v);
307
+ paidEl.innerHTML = fill(W.paidHtml, '{amt}', amt); paidEl.hidden = false;
308
+ if (VS.ran > 0) { multEl.innerHTML = fill(W.multHtml, '{x}', times(VS.ran / v)); multEl.hidden = false; } else { multEl.hidden = true; }
309
+ VS.card.paid = { amount: amt, caption: W.actualCaption };
310
+ VS.text = assemble(VS.post.head, [VS.post.read, fill(W.actualPost, '{amt}', amt), VS.post.drift]);
311
+ } else {
312
+ paidEl.innerHTML = est.paid; paidEl.hidden = est.paidHidden;
313
+ multEl.innerHTML = est.mult; multEl.hidden = est.multHidden;
314
+ VS.card.paid = est.card; VS.text = est.text;
315
+ }
316
+ pxEl.href = 'https://x.com/intent/post?text=' + encodeURIComponent(VS.text);
317
+ api.text = VS.text;
318
+ }
319
+ paidIn.addEventListener('input', applyPaid);
320
+ document.getElementById('vs-dl').addEventListener('click', download);
321
+ document.getElementById('vs-cp').addEventListener('click', copy);
322
+ document.getElementById('vs-replay').addEventListener('click', function(){ if (reduce) { settle(); } else { run(); } });
323
+ window.cchubberVs = api; // lets a test or a screenshot script draw the card directly
324
+ })();
325
+ `;
326
+
327
+ /**
328
+ * "Your usage on other models": the race, the facts and the card buttons, as one block (style + markup + data + script)
329
+ * for the main report. Offline: no fetches, no fonts, no external scripts.
330
+ * `ctx`: { plan, months, driftPct (null unless a stated plan computed it) }.
331
+ */
332
+ export function renderVsBlock(rp, { plan = null, months = 1, driftPct = null } = {}) {
333
+ const paid = paidFacts(plan, months);
334
+ const text = postText(rp, { plan, months, driftPct });
335
+ const mult = paid ? multipleFacts(rp.actualListCost, paid.paid, true) : null;
336
+ // First occurrence only: the figure that is the answer (the list price later in the sentence stays plain). A function
337
+ // replacer, because a string replacement would read the $ in "$800" as a pattern.
338
+ const boldFirst = (str, figure) => esc(str).replace(esc(figure), (m) => `<b>${m}</b>`);
339
+ const driftLine = driftPct != null ? `${driftPct}% of your last week went to what you said you'd do.` : null;
340
+
341
+ // Race rows: every model plus what the user actually ran. They start in the listed order and settle sorted.
342
+ const all = [
343
+ ...rp.models.map(m => ({ id: m.id, label: m.label, maker: m.maker, total: m.total, note: introNote(m), series: rp.dailyCum.perModel[m.id] })),
344
+ { id: REF_ID, label: REF_LABEL, maker: REF_MAKER, total: rp.actualListCost, note: '', series: rp.dailyCum.actual, ref: true },
345
+ ];
346
+ const listIdx = (id) => { const i = FRONTIER_MODELS.findIndex(f => f.id === id); return i === -1 ? 999 : i; };
347
+ const startOrder = [REF_ID, ...[...rp.models].sort((a, b) => listIdx(a.id) - listIdx(b.id)).map(m => m.id)];
348
+ const sortedOrder = [...all].sort((a, b) => b.total - a.total).map(m => m.id);
349
+ const max = Math.max(...all.map(x => x.total), 1);
350
+
351
+ // The markup is the START of the race (listed order, $0, empty bars), so the first paint is the start and the finished
352
+ // numbers never flash. The finish sits beside it in --fpos, --fw and .vs-final, for no-JS and reduced motion (FINAL_RULES).
353
+ const rowsHtml = all.map(m => `
354
+ <li class="vs-row${m.ref ? ' vs-ref' : ''}" data-id="${esc(m.id)}" style="--pos:${startOrder.indexOf(m.id)};--fpos:${sortedOrder.indexOf(m.id)};--fw:${(m.total / max * 100).toFixed(2)}%">
355
+ <div class="vs-name"><span class="vs-label">${esc(m.label)}</span><span class="vs-meta">${esc(m.maker)}${m.note ? ` &middot; <span class="vs-tag">${esc(m.note)}</span>` : ''}</span></div>
356
+ <div class="vs-track" aria-hidden="true"><i class="vs-fill"></i></div>
357
+ <div class="vs-amt"><span class="vs-now">$0</span><span class="vs-final">${esc(usd0(m.total))}</span></div>
358
+ </li>`).join('');
359
+
360
+ const dateLabels = rp.dailyCum.dates.map(dayLabel);
361
+ const data = {
362
+ models: all.map(m => ({ id: m.id, total: m.total, series: m.series })),
363
+ startOrder, sortedOrder, dateLabels, text,
364
+ card: {
365
+ title: 'My Claude Code tokens, repriced',
366
+ sub: `${tokensLabel(totalTokens(rp))} tokens, each model's list price`,
367
+ period: periodLabel(rp),
368
+ bills: rp.models.map(m => ({ label: m.label, total: m.total, amount: usd0(m.total), note: introNote(m) })),
369
+ read: int(rp.readPerWrite),
370
+ readCaption: 'tokens read for every token Claude wrote',
371
+ paid: paid ? { amount: usd0(paid.paid), caption: paid.caption } : null,
372
+ drift: driftPct != null ? { pct: `${driftPct}%`, caption: `of my last week on what I said I'd do` } : null,
373
+ fine: "Same tokens, each model's list price, cache included. Compares prices, not outcomes.",
374
+ cmd: SHARE_CMD,
375
+ },
376
+ dailyCum: { dates: rp.dailyCum.dates },
377
+ // What the page script needs to swap the estimate for a typed amount; the wording stays in vs-text.js.
378
+ ran: rp.actualListCost,
379
+ cmd: SHARE_CMD,
380
+ post: { head: postHead(rp), read: readLine(rp), drift: driftPost(driftPct) },
381
+ words: {
382
+ paidHtml: boldFirst(PAID_ACTUAL_TEXT, '{amt}'),
383
+ multHtml: boldFirst(multipleFacts(1, 1, false, '{x}').text, '{x}'),
384
+ actualCaption: PAID_ACTUAL_CAPTION,
385
+ actualPost: PAID_ACTUAL_POST,
386
+ },
387
+ };
388
+ const json = JSON.stringify(data).replace(/</g, '\\u003c').replace(/>/g, '\\u003e').replace(/&/g, '\\u0026');
389
+
390
+ const srcNotes = overrideRows(rp).map(m => `<p>${esc(m.label)}: ${esc(m.note || 'price')} from <a href="${esc(m.sourceUrl)}" rel="noopener" target="_blank">${esc(m.sourceName || m.sourceUrl)}</a>, checked ${esc(dayLabel(m.checked))}${m.after ? `; ${esc(usd0(m.after.total))} at the price after the introductory period` : ''}.${m.assumption ? ' ' + esc(m.assumption) : ''}</p>`);
391
+ const fillNotes = filledNotes(rp).map(n => `<p>${esc(n)}</p>`);
392
+
393
+ return `
394
+ <!-- YOUR USAGE ON OTHER MODELS (reprice): race, facts, card -->
395
+ <style>${CSS}</style>
396
+ <section id="vs">
397
+ <div class="vs-head"><h3 class="vs-title">Your usage on other models</h3><span class="vs-range">${esc(periodLabel(rp))}</span></div>
398
+ <p class="vs-lead">Same tokens, every frontier model.</p>
399
+ <p class="vs-sub">${esc(tokensLabel(totalTokens(rp)))} tokens from your Claude Code logs, repriced at each model&rsquo;s list price.</p>
400
+
401
+ <div class="vs-clock"><span id="vs-clock-label"><span class="vs-now">Replaying your real days</span><span class="vs-final">Your real days, replayed</span></span><b id="vs-clock"><span class="vs-now">${esc(dateLabels[0] || '')}</span><span class="vs-final">${esc(dateLabels[dateLabels.length - 1] || '')}</span></b></div>
402
+ <ol class="vs-race" id="vs-race" style="--vs-n:${all.length}" aria-label="Your usage repriced on each model">${rowsHtml}
403
+ </ol>
404
+
405
+ <div class="vs-facts">
406
+ <p class="vs-fact">${emph(readLine(rp))}</p>
407
+ <p class="vs-fact">${emph(cacheLine(rp))}</p>
408
+ <p class="vs-fact" id="vs-paid-line"${paid ? '' : ' hidden'}>${paid ? boldFirst(paid.text, usd0(paid.paid)) : ''}</p>
409
+ <p class="vs-fact" id="vs-mult-line"${mult ? '' : ' hidden'}>${mult ? boldFirst(multipleFacts(rp.actualListCost, paid.paid, true, '{x}').text, '{x}').replace('{x}', timesLabel(mult.times)) : ''}</p>
410
+ <div class="vs-paid">
411
+ <label class="vs-plabel" for="vs-paid-in">What you actually paid</label>
412
+ <div class="vs-pbox"><span class="vs-cur" aria-hidden="true">$</span><input class="vs-pin" id="vs-paid-in" type="number" inputmode="decimal" min="0" step="any" placeholder="0" autocomplete="off" aria-describedby="vs-paid-help"></div>
413
+ <p class="vs-phelp" id="vs-paid-help">In US dollars, all months together. Optional. It stays on this page: nothing is stored or sent.</p>
414
+ </div>
415
+ ${driftLine ? `<p class="vs-fact vs-drift">${emph(driftLine)}</p>` : ''}
416
+ </div>
417
+
418
+ <div class="vs-actions">
419
+ <button class="vs-btn vs-primary" id="vs-dl" type="button">Download card</button>
420
+ <button class="vs-btn" id="vs-cp" type="button">Copy text</button>
421
+ <a class="vs-btn" id="vs-px" href="https://x.com/intent/post?text=${encodeURIComponent(text)}" target="_blank" rel="noopener noreferrer">Post on X</a>
422
+ <button class="vs-btn" id="vs-replay" type="button">Replay</button>
423
+ </div>
424
+ <div class="vs-toast" id="vs-toast" role="status" aria-live="polite"></div>
425
+
426
+ <div class="vs-fine">
427
+ <p class="vs-first">${esc(FINE_PRINT)}</p>
428
+ ${paid ? `<p>${esc(PAID_FINE_PRINT)}</p>` : ''}
429
+ <p>${esc(pricesLine(rp))}</p>
430
+ ${srcNotes.join('')}
431
+ ${fillNotes.join('')}
432
+ <p>&ldquo;What you ran&rdquo; is the same logs at the Claude list prices for the models you actually used. This ran on your machine; nothing was uploaded.</p>
433
+ </div>
434
+ </section>
435
+ <script type="application/json" id="vs-data">${json}</script>
436
+ <noscript><style>${FINAL_RULES}</style></noscript>
437
+ <script id="vs-js">${SCRIPT}</script>
438
+ `;
439
+ }