eleven / ocho Public

Clone
git clone https://amadan.net/eleven/ocho

Plain git — no account needed to clone.

Download

Download this file

1<!-- πŸ€– Public Investing page: the funding model (docs/funding.md β€” the repo is
2 private; this page is what makes the model public) and its open ledger.
3 The JSON is COMMITTED data (web/status/funding.json β€” empty until the
4 model is adopted and a series opens), republished as-is by
5 deploy/status-publish.sh. The simulator imports ./waterfall.js β€” the
6 normative reference implementation, served from this same directory β€” so
7 the page RUNS the published algorithm rather than describing a copy of
8 it. Self-contained otherwise: inline CSS/JS; the only requests are
9 ./funding.json and ./waterfall.js (relative, same origin).
10 Chart palette (violet/yellow/aqua + a dashed neutral reference line) is
11 dataviz-validated for both modes; the light-mode contrast WARN on
12 yellow/aqua is covered by the relief rule: legend, right-edge direct
13 labels, and the table view below the chart. -->
14<!doctype html>
15<html lang="en">
16<head>
17<meta charset="utf-8" />
18<meta name="viewport" content="width=device-width, initial-scale=1" />
19<meta name="color-scheme" content="light dark" />
20<title>Eleven β€” investing</title>
21<link rel="icon" type="image/png" href="./icon-192.png" />
22<link rel="stylesheet" href="./status.css" />
23<style>
24 /* Investing-only: the status callout, simulator controls, chart figure, and
25 ledger tables. Everything shared lives in status.css. */
26 .callout { background: var(--surface); border: 1px solid var(--border); border-left: 4px solid var(--warn); border-radius: 12px; padding: 14px 16px; margin-bottom: 28px; }
27 .callout .head { font-weight: 650; }
28 .callout p { margin-top: 6px; font-size: 15px; }
29 .callout.adopted { border-left-color: var(--good-mark); }
30
31 ul.prose { padding-left: 20px; display: grid; gap: 8px; font-size: 15px; }
32 ul.prose .lead-in { font-weight: 650; }
33
34 /* The three pool hues β€” validated for light and dark against the status
35 surfaces. The remaining-cap reference line is neutral ink, dashed: it is
36 a bound, not a stream, so it never takes a categorical slot. */
37 :root { --pool-investors: #4a3aa7; --pool-team: #eda100; --pool-commons: #1baf7a; }
38 @media (prefers-color-scheme: dark) {
39 :root { --pool-investors: #9085e9; --pool-team: #c98500; --pool-commons: #199e70; }
40 }
41
42 .controls { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 14px 18px; background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 14px 16px; margin-bottom: 14px; }
43 .ctl { display: grid; gap: 2px; }
44 .ctl label { font-size: 13px; color: var(--ink-2); }
45 .ctl output { font-weight: 600; font-variant-numeric: tabular-nums; }
46 .ctl input[type="range"] { width: 100%; margin: 6px 0 0; accent-color: var(--series); }
47 .ctl input[type="range"]:focus-visible { outline: 2px solid var(--series); outline-offset: 2px; border-radius: 4px; }
48
49 .legend { display: flex; flex-wrap: wrap; gap: 6px 18px; font-size: 14px; margin: 0 0 8px; list-style: none; padding: 0; }
50 .legend .chip { display: inline-block; width: 12px; height: 12px; border-radius: 3px; margin-right: 6px; vertical-align: -1px; }
51 .legend .chip.dash { height: 0; width: 16px; border-top: 3px dashed var(--ink-2); border-radius: 0; vertical-align: 3px; background: none; }
52
53 figure { position: relative; background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 12px; margin: 0 0 8px; }
54 figure svg { display: block; width: 100%; height: auto; }
55 figure svg:focus-visible { outline: 2px solid var(--series); outline-offset: 2px; border-radius: 8px; }
56 figcaption { color: var(--ink-2); font-size: 13px; padding: 4px 8px 8px; }
57 #tip {
58 display: none; position: absolute; z-index: 1; pointer-events: none;
59 background: var(--surface); border: 1px solid var(--border); border-radius: 8px;
60 padding: 8px 10px; font-size: 13px; max-width: 260px; box-shadow: 0 2px 8px rgba(0,0,0,0.12);
61 }
62 #tip .big { font-size: 15px; font-weight: 650; }
63 #tip .sub { color: var(--ink-2); font-variant-numeric: tabular-nums; }
64
65 .tablewrap { overflow-x: auto; background: var(--surface); border: 1px solid var(--border); border-radius: 12px; }
66 table { border-collapse: collapse; width: 100%; font-size: 14px; }
67 caption { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
68 th, td { text-align: left; padding: 10px 14px; border-bottom: 1px solid var(--grid); }
69 thead th { font-size: 13px; color: var(--ink-2); font-weight: 650; white-space: nowrap; }
70 tbody tr:last-child th, tbody tr:last-child td { border-bottom: none; }
71 tbody th { font-weight: 600; white-space: nowrap; }
72 .num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
73
74 details.tableview { margin-top: 8px; }
75 details.tableview summary { cursor: pointer; font-size: 14px; font-weight: 600; padding: 4px 0; }
76 details.tableview summary:focus-visible { outline: 2px solid var(--series); outline-offset: 2px; border-radius: 4px; }
77 details.tableview .tablewrap { margin-top: 8px; }
78
79 .empty { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 14px 16px; color: var(--ink-2); font-size: 14px; }
80</style>
81</head>
82<body>
83<div class="wrap">
84 <header data-title="Investing"></header>
85 <nav class="status-nav" aria-label="Status"></nav>
86 <script src="./status-nav.js"></script>
87 <p class="lede">How money can fund Eleven without owning it. Eleven takes no
88 venture capital: investment buys a position in a published algorithm β€” capped
89 returns, no shares, no votes, no board seats β€” and every parameter, input,
90 and payment lands on this page. The simulator below runs the actual published
91 algorithm in your browser.</p>
92 <p class="asof">Ledger dated <time id="asof" datetime="">β€”</time></p>
93
94 <p id="error" role="alert">Couldn’t load the funding ledger. It may be
95 mid-publish β€” this page retries every minute.</p>
96
97 <div class="callout" id="status-callout">
98 <p class="head" id="status-head">Status: designed, not adopted</p>
99 <p id="status-body">No notes exist and no money has been taken. The model
100 below is a published design β€” open so it can be argued with β€” and becomes
101 live only after independent legal and tax review, and only then by a
102 deliberately opened series. Nothing on this page is an offer.</p>
103 </div>
104
105 <section aria-labelledby="h-model">
106 <h2 id="h-model">The model in one minute</h2>
107 <ul class="prose">
108 <li><span class="lead-in">Notes, not shares.</span> Investors buy
109 profit-participating notes in named series β€” principal plus a fixed cap
110 multiple, and the cap is the whole return. No interest, no maturity date,
111 no equity, no exit.</li>
112 <li><span class="lead-in">Money has no control.</span> Notes carry no
113 votes, no board seats, no vetoes. The only enforceable investor rights
114 are transparency promises: this ledger publishes, the independent
115 verification panel stays funded, and distributions follow the published
116 algorithm. Breach makes unreturned principal repayable β€” investors can
117 force the money back out; they can never take the wheel.</li>
118 <li><span class="lead-in">Money sits before it streams.</span> No
119 distributions until a series has seasoned and the treasury holds a
120 reserve floor of forward operating costs β€” new money builds first.</li>
121 <li><span class="lead-in">An open algorithm decides the rest.</span>
122 Each quarter, distributable surplus streams out by a fixed split β€”
123 noteholders pro-rata until each cap fills and the note extinguishes,
124 alongside the team pool and the commons (price cuts, free-tier subsidy,
125 aligned grants).</li>
126 <li><span class="lead-in">Verification is funded first.</span> An
127 independent panel β€” funded as an operating cost, senior to every
128 distribution β€” audits the claims: the encryption architecture, the
129 published economics, this ledger against bank records, and whether each
130 quarter’s payments were the algorithm’s outputs.</li>
131 <li><span class="lead-in">The algorithm is versioned.</span> A note pins
132 the version it was sold under, forever. Changes apply to future series
133 only.</li>
134 </ul>
135 </section>
136
137 <section aria-labelledby="h-params">
138 <h2 id="h-params">The algorithm’s parameters</h2>
139 <p class="note explain">Read live from <a href="./waterfall.js">waterfall.js</a> β€”
140 the reference implementation this page serves and runs. Changing any of
141 these is a new algorithm version, applied to future series only.</p>
142 <div class="tiles" id="param-tiles"></div>
143 </section>
144
145 <section aria-labelledby="h-sim">
146 <h2 id="h-sim">Run the waterfall</h2>
147 <p class="note explain">Pick a scenario; the page runs the published
148 algorithm over it, quarter by quarter. An illustration, not a forecast β€”
149 and not an offer.</p>
150 <div class="controls">
151 <div class="ctl">
152 <label for="c-raise">Series raise</label>
153 <output id="o-raise" for="c-raise">β€”</output>
154 <input type="range" id="c-raise" min="50000" max="1000000" step="25000" value="300000" />
155 </div>
156 <div class="ctl">
157 <label for="c-cap">Cap multiple</label>
158 <output id="o-cap" for="c-cap">β€”</output>
159 <input type="range" id="c-cap" min="1.5" max="3" step="0.1" value="2" />
160 </div>
161 <div class="ctl">
162 <label for="c-rev">Quarterly revenue (starting)</label>
163 <output id="o-rev" for="c-rev">β€”</output>
164 <input type="range" id="c-rev" min="0" max="150000" step="2500" value="40000" />
165 </div>
166 <div class="ctl">
167 <label for="c-growth">Revenue growth per quarter</label>
168 <output id="o-growth" for="c-growth">β€”</output>
169 <input type="range" id="c-growth" min="0" max="15" step="0.5" value="4" />
170 </div>
171 <div class="ctl">
172 <label for="c-costs">Quarterly operating costs</label>
173 <output id="o-costs" for="c-costs">β€”</output>
174 <input type="range" id="c-costs" min="5000" max="150000" step="1000" value="28000" />
175 </div>
176 </div>
177
178 <div class="tiles">
179 <div class="tile"><div class="label">First distribution</div><div class="value" id="t-first" style="font-size:22px">β€”</div><div class="sub" id="t-first-sub"></div></div>
180 <div class="tile"><div class="label">Noteholders fully repaid</div><div class="value" id="t-repaid" style="font-size:22px">β€”</div><div class="sub" id="t-repaid-sub"></div></div>
181 <div class="tile"><div class="label">To the commons by then</div><div class="value" id="t-commons" style="font-size:22px">β€”</div><div class="sub" id="t-commons-sub"></div></div>
182 </div>
183
184 <ul class="legend" id="legend">
185 <li><span class="chip" style="background:var(--pool-investors)"></span>Noteholders (cumulative)</li>
186 <li><span class="chip" style="background:var(--pool-team)"></span>Team (cumulative)</li>
187 <li><span class="chip" style="background:var(--pool-commons)"></span>Commons (cumulative)</li>
188 <li><span class="chip dash"></span>Remaining cap</li>
189 </ul>
190 <figure aria-labelledby="h-sim">
191 <svg id="chart" viewBox="0 0 720 260" role="img" aria-label="" tabindex="0"></svg>
192 <div id="tip"></div>
193 <figcaption id="chart-cap">Cumulative euros streamed to each pool over
194 the scenario, against the falling remaining cap. Hover, or focus the
195 chart and use the arrow keys, to read any quarter; the table view below
196 carries the same numbers.<span id="chart-window-note"></span></figcaption>
197 </figure>
198 <details class="tableview">
199 <summary>Table view (yearly)</summary>
200 <div class="tablewrap">
201 <table id="sim-table">
202 <caption>Simulated yearly totals for the chosen scenario</caption>
203 <thead><tr>
204 <th scope="col">Year end</th>
205 <th scope="col" class="num">Revenue / quarter</th>
206 <th scope="col" class="num">Noteholders (cum.)</th>
207 <th scope="col" class="num">Team (cum.)</th>
208 <th scope="col" class="num">Commons (cum.)</th>
209 <th scope="col" class="num">Remaining cap</th>
210 </tr></thead>
211 <tbody></tbody>
212 </table>
213 </div>
214 </details>
215 <p class="note" id="sim-note"></p>
216 </section>
217
218 <section aria-labelledby="h-series">
219 <h2 id="h-series">Series</h2>
220 <div id="series-out"></div>
221 </section>
222
223 <section aria-labelledby="h-notes">
224 <h2 id="h-notes">Notes</h2>
225 <p class="note explain">Pseudonymous by design: amounts and movements are
226 public; identities never are. A note id buys no influence β€” there is
227 nothing to buy.</p>
228 <div id="notes-out"></div>
229 </section>
230
231 <section aria-labelledby="h-quarters">
232 <h2 id="h-quarters">Quarterly distributions</h2>
233 <div id="quarters-out"></div>
234 </section>
235
236 <footer>
237 <p><strong>Check this page yourself.</strong> The algorithm is
238 <a href="./waterfall.js">waterfall.js</a> (served here, imported by this
239 page β€” the simulator runs it, not a copy); the ledger is
240 <a href="./funding.json">funding.json</a>. All ledger amounts are integer
241 cents, and every computation is deterministic β€” re-running the algorithm
242 over the ledger must reproduce every published payment. An audit is a
243 re-execution.</p>
244 </footer>
245</div>
246
247<script type="module">
248import { applyQuarter, SPLIT, DEFAULT_SEASONING_MONTHS, RESERVE_MONTHS,
249 CAP_RANGE, DEFAULT_CAP_MULTIPLE } from "./waterfall.js";
250
251const $ = (id) => document.getElementById(id);
252const NS = "http://www.w3.org/2000/svg";
253const eur = new Intl.NumberFormat("en-IE", { style: "currency", currency: "EUR", maximumFractionDigits: 0 });
254const eurCents = (c) => eur.format(Math.round(c / 100));
255const plur = (n, w) => n.toLocaleString() + " " + w + (n === 1 ? "" : "s");
256
257// ---- Parameters (from the algorithm module β€” one source, no copy) ----------
258{
259 const tiles = $("param-tiles");
260 const tile = (label, value, sub) => {
261 const d = document.createElement("div");
262 d.className = "tile";
263 const l = document.createElement("div"); l.className = "label"; l.textContent = label;
264 const v = document.createElement("div"); v.className = "value"; v.style.fontSize = "22px"; v.textContent = value;
265 d.append(l, v);
266 if (sub) { const s = document.createElement("div"); s.className = "sub"; s.textContent = sub; d.append(s); }
267 tiles.append(d);
268 };
269 tile("Noteholders", SPLIT.investors + "%", "of distributable surplus, pro-rata by remaining cap");
270 tile("Team", SPLIT.team + "%", "the profit-share pool");
271 tile("Commons", SPLIT.commons + "%", "price cuts, free-tier subsidy, grants β€” plus every rounding cent");
272 tile("Seasoning", plur(DEFAULT_SEASONING_MONTHS, "month"), "before a series can receive anything");
273 tile("Reserve floor", plur(RESERVE_MONTHS, "month"), "of forward costs held before anything is distributable");
274 tile("Cap multiple", CAP_RANGE[0].toLocaleString() + "–" + CAP_RANGE[1].toLocaleString() + "Γ—", "fixed per series; the whole return, then the note ends");
275}
276
277// ---- Scenario simulator -----------------------------------------------------
278// The waterfall is the imported published algorithm; only the scenario AROUND
279// it is modelled here (one series, flat costs, compounding revenue). Kept
280// deliberately simple: the point is to show how the algorithm behaves, not to
281// predict anything.
282const HORIZON_QUARTERS = 60; // 15 years
283const QENDS = ["03-31", "06-30", "09-30", "12-31"];
284
285function simulate(p) {
286 const cents = (e) => Math.round(e * 100);
287 let ledger = {
288 series: [{ id: "S1", closedAt: "2027-01-01", capMultiple: p.capMultiple, seasoningMonths: DEFAULT_SEASONING_MONTHS }],
289 notes: [{ id: "S1-001", series: "S1", principal: cents(p.raise), remainingCap: Math.round(cents(p.raise) * p.capMultiple) }],
290 quarters: [],
291 };
292 // The raise sits in the treasury; the floor is RESERVE_MONTHS of the cost
293 // base. Retained pools and residue stay in the treasury (their destination
294 // is a published decision, so the scenario just holds them).
295 let treasury = cents(p.raise);
296 const floor = Math.round(cents(p.costs) * (RESERVE_MONTHS / 3));
297 const rows = [];
298 let cumI = 0, cumT = 0, cumC = 0, brokeAt = -1;
299 for (let q = 0; q < HORIZON_QUARTERS; q++) {
300 const year = 2027 + Math.floor(q / 4), qi = q % 4;
301 const revenue = Math.round(cents(p.revenue0) * Math.pow(1 + p.growthPct / 100, q));
302 const costs = cents(p.costs);
303 const reserveTopUp = Math.max(0, Math.min(Math.max(0, revenue - costs), floor - treasury));
304 const step = applyQuarter(ledger, {
305 id: year + "-Q" + (qi + 1), asOf: year + "-" + QENDS[qi],
306 revenue, costs, reserveTopUp, floorMet: false, // patched below once cash is known
307 });
308 const r = step.result;
309 const paid = r.payments.reduce((s, x) => s + x.amount, 0);
310 const out = r.distributed ? paid + r.teamPool + r.commonsPool - r.retained : 0;
311 treasury += revenue - costs - out;
312 // floorMet is a fact about the treasury at quarter end; the algorithm only
313 // ever reads it from PRIOR quarters, so patching the fresh record is sound.
314 step.ledger.quarters[step.ledger.quarters.length - 1].floorMet = treasury >= floor;
315 ledger = step.ledger;
316 if (treasury < 0 && brokeAt < 0) brokeAt = q;
317 cumI += paid; cumT += r.distributed ? r.teamPool : 0; cumC += r.distributed ? r.commonsPool : 0;
318 rows.push({
319 q, label: year + " Q" + (qi + 1), yearEnd: qi === 3 ? year : 0,
320 revenue, paid, cumI, cumT, cumC,
321 remainingCap: ledger.notes[0].remainingCap, treasury,
322 });
323 }
324 const capTotal = Math.round(cents(p.raise) * p.capMultiple);
325 return {
326 rows, capTotal, brokeAt,
327 firstAt: rows.findIndex((r) => r.paid > 0),
328 repaidAt: rows.findIndex((r) => r.remainingCap === 0),
329 };
330}
331
332// ---- Controls ---------------------------------------------------------------
333const CTLS = [
334 { id: "c-raise", out: "o-raise", key: "raise", fmt: (v) => eur.format(v) },
335 { id: "c-cap", out: "o-cap", key: "capMultiple", fmt: (v) => v.toLocaleString(undefined, { minimumFractionDigits: 1 }) + "Γ—" },
336 { id: "c-rev", out: "o-rev", key: "revenue0", fmt: (v) => eur.format(v) },
337 { id: "c-growth", out: "o-growth", key: "growthPct", fmt: (v) => v.toLocaleString() + "% / quarter" },
338 { id: "c-costs", out: "o-costs", key: "costs", fmt: (v) => eur.format(v) },
339];
340$("c-cap").min = CAP_RANGE[0]; $("c-cap").max = CAP_RANGE[1]; $("c-cap").value = DEFAULT_CAP_MULTIPLE;
341
342function params() {
343 const p = {};
344 for (const c of CTLS) {
345 const v = Number($(c.id).value);
346 p[c.key] = v;
347 $(c.out).textContent = c.fmt(v);
348 }
349 return p;
350}
351
352// ---- Chart ------------------------------------------------------------------
353// Four marks, one euro axis: three cumulative pool lines (categorical hues)
354// and the falling remaining-cap bound as a dashed neutral line. Crosshair
355// snaps to the nearest quarter; ←/β†’ walk quarters when the chart is focused.
356// No animation, so there is nothing to reduce for prefers-reduced-motion.
357let sim = null, tipIdx = -1;
358
359function yearsWord(q) {
360 const yrs = (q + 1) / 4;
361 const r = Math.round(yrs * 2) / 2;
362 return r === 1 ? "1 year" : r.toLocaleString() + " years";
363}
364
365function drawChart() {
366 const svg = $("chart");
367 svg.replaceChildren();
368 const { capTotal } = sim;
369 // Window to the payback story: through repayment plus two years when the
370 // cap fills, the whole horizon otherwise. The table view always carries the
371 // full horizon, so nothing is hidden β€” just focused.
372 const lastQ = sim.repaidAt >= 0 ? Math.min(sim.rows.length - 1, sim.repaidAt + 8) : sim.rows.length - 1;
373 const rows = sim.rows.slice(0, lastQ + 1);
374 $("chart-window-note").textContent = lastQ < sim.rows.length - 1
375 ? " Chart shown through repayment plus two years; the table runs the full horizon."
376 : "";
377 const W = 720, H = 260, m = { l: 56, r: 118, t: 14, b: 26 };
378 const x = (q) => m.l + (W - m.l - m.r) * (q / (rows.length - 1));
379 // Every plotted value must fit: the cap bound AND all three streams β€” team
380 // and commons keep accruing after the cap fills, so they can top everything.
381 const top = Math.max(1, ...rows.map((r) => Math.max(r.remainingCap, r.cumI, r.cumT, r.cumC)));
382 let step = 1;
383 for (const s of [1, 2, 5, 10, 20, 50, 100, 200, 500, 1000, 2000, 5000]) {
384 if (top / 100000 / s <= 4) { step = s; break; } // ticks in €k, ≀5 lines
385 }
386 const yMax = Math.max(step, Math.ceil(top / 100000 / step) * step) * 100000;
387 const y = (c) => H - m.b - (H - m.b - m.t) * (c / yMax);
388 const el = (name, attrs, text) => {
389 const n = document.createElementNS(NS, name);
390 for (const [k, v] of Object.entries(attrs)) n.setAttribute(k, v);
391 if (text !== undefined) n.textContent = text;
392 svg.append(n);
393 return n;
394 };
395
396 for (let v = 0; v <= yMax; v += step * 100000) {
397 el("line", { x1: m.l, x2: W - m.r, y1: y(v), y2: y(v), stroke: "var(--grid)", "stroke-width": 1 });
398 el("text", { x: m.l - 8, y: y(v) + 4, "text-anchor": "end", "font-size": 12,
399 fill: "var(--ink-2)", style: "font-variant-numeric: tabular-nums" }, v === 0 ? "€0" : "€" + (v / 100000) + "k");
400 }
401 const yearEnds = rows.filter((r) => r.yearEnd);
402 const yStep = Math.max(1, Math.ceil(yearEnds.length / 6));
403 yearEnds.forEach((r, i) => {
404 if (i % yStep === yStep - 1) {
405 el("text", { x: x(r.q), y: H - 8, "text-anchor": "middle", "font-size": 12, fill: "var(--ink-2)" }, String(r.yearEnd));
406 }
407 });
408
409 const path = (pick) => rows.map((r, i) => (i ? "L" : "M") + x(r.q).toFixed(1) + " " + y(pick(r)).toFixed(1)).join(" ");
410 el("path", { d: "M" + x(0) + " " + y(capTotal) + " " + path((r) => r.remainingCap).slice(1),
411 fill: "none", stroke: "var(--ink-2)", "stroke-width": 2, "stroke-dasharray": "6 5", "stroke-linejoin": "round" });
412 const seriesDefs = [
413 { pick: (r) => r.cumI, color: "var(--pool-investors)", name: "noteholders" },
414 { pick: (r) => r.cumT, color: "var(--pool-team)", name: "team" },
415 { pick: (r) => r.cumC, color: "var(--pool-commons)", name: "commons" },
416 ];
417 for (const s of seriesDefs) {
418 el("path", { d: path(s.pick), fill: "none", stroke: s.color,
419 "stroke-width": 2, "stroke-linejoin": "round", "stroke-linecap": "round" });
420 }
421
422 // Right-edge direct labels (the relief rule for the low-contrast hues β€”
423 // identity is never color alone). Nudge apart when lines end close together.
424 // Identity-only direct labels (the relief rule): the values live in the
425 // tooltip, the tiles, and the table β€” a name fits the margin, a sum doesn't.
426 const end = rows[rows.length - 1];
427 const band = [m.t + 8, H - m.b - 4];
428 const labels = [
429 { y: y(end.remainingCap), text: "remaining cap", fill: "var(--ink-2)", w: 400 },
430 { y: y(end.cumI), text: "noteholders", fill: "var(--ink)", w: 650 },
431 { y: y(end.cumT), text: "team", fill: "var(--ink-2)", w: 400 },
432 { y: y(end.cumC), text: "commons", fill: "var(--ink-2)", w: 400 },
433 ].map((L) => ({ ...L, y: Math.min(band[1], Math.max(band[0], L.y)) }))
434 .sort((a, b) => a.y - b.y);
435 for (let i = 1; i < labels.length; i++) {
436 if (labels[i].y - labels[i - 1].y < 14) labels[i].y = labels[i - 1].y + 14;
437 }
438 const over = labels[labels.length - 1].y - band[1];
439 if (over > 0) for (const L of labels) L.y -= over; // slide the stack back into the band
440 for (const L of labels) {
441 el("text", { x: W - m.r + 8, y: L.y + 4, "font-size": 12, "font-weight": L.w, fill: L.fill }, L.text);
442 }
443
444 const horizon = yearsWord(rows.length - 1);
445 const repaid = sim.repaidAt >= 0
446 ? "the cap fills in " + yearsWord(sim.repaidAt)
447 : "the cap does not fill within " + horizon;
448 svg.setAttribute("aria-label", "Simulated streams over " + horizon + ": " +
449 eurCents(end.cumI) + " to noteholders, " + eurCents(end.cumT) + " to the team, " +
450 eurCents(end.cumC) + " to the commons; " + repaid + ".");
451
452 // Crosshair + tooltip: the whole plot is the hit area, snapping to the
453 // nearest quarter; arrow keys walk quarters when the svg has focus.
454 const hair = el("line", { y1: m.t, y2: H - m.b, stroke: "var(--grid)", "stroke-width": 1, visibility: "hidden" });
455 const tip = $("tip");
456 const show = (i) => {
457 tipIdx = i;
458 const r = rows[i];
459 hair.setAttribute("x1", x(r.q)); hair.setAttribute("x2", x(r.q));
460 hair.setAttribute("visibility", "visible");
461 tip.replaceChildren();
462 const big = document.createElement("div");
463 big.className = "big";
464 big.textContent = r.label;
465 tip.append(big);
466 for (const [label, v] of [["Noteholders", r.cumI], ["Team", r.cumT], ["Commons", r.cumC], ["Remaining cap", r.remainingCap]]) {
467 const d = document.createElement("div");
468 d.className = "sub";
469 d.textContent = label + ": " + eurCents(v);
470 tip.append(d);
471 }
472 tip.style.display = "block";
473 const box = svg.getBoundingClientRect();
474 const px = (x(r.q) / W) * box.width;
475 tip.style.left = Math.min(Math.max(0, px - 70), box.width - tip.offsetWidth - 4) + "px";
476 tip.style.top = Math.max(0, (y(r.cumI) / H) * box.height - tip.offsetHeight - 14) + "px";
477 };
478 const hide = () => { tipIdx = -1; hair.setAttribute("visibility", "hidden"); tip.style.display = "none"; };
479 svg.addEventListener("pointermove", (ev) => {
480 const box = svg.getBoundingClientRect();
481 const mx = ((ev.clientX - box.left) / box.width) * W;
482 let best = 0;
483 for (const r of rows) if (Math.abs(x(r.q) - mx) < Math.abs(x(best) - mx)) best = r.q;
484 show(best);
485 });
486 svg.addEventListener("pointerleave", hide);
487 svg.addEventListener("keydown", (ev) => {
488 if (ev.key === "ArrowRight" || ev.key === "ArrowLeft") {
489 ev.preventDefault();
490 const next = tipIdx < 0 ? (ev.key === "ArrowRight" ? 0 : rows.length - 1)
491 : Math.min(rows.length - 1, Math.max(0, tipIdx + (ev.key === "ArrowRight" ? 1 : -1)));
492 show(next);
493 } else if (ev.key === "Escape") hide();
494 });
495 svg.addEventListener("blur", hide);
496}
497
498function renderSim() {
499 sim = simulate(params());
500 const horizon = yearsWord(HORIZON_QUARTERS - 1);
501
502 const firstTile = $("t-first"), firstSub = $("t-first-sub");
503 if (sim.firstAt >= 0) {
504 firstTile.textContent = sim.rows[sim.firstAt].label;
505 firstSub.textContent = "after " + yearsWord(sim.firstAt) + " β€” seasoning plus the reserve floor";
506 } else {
507 firstTile.textContent = "Not within " + horizon;
508 firstSub.textContent = "this scenario never clears seasoning and the reserve floor";
509 }
510 const repaidTile = $("t-repaid"), repaidSub = $("t-repaid-sub");
511 if (sim.repaidAt >= 0) {
512 repaidTile.textContent = sim.rows[sim.repaidAt].label;
513 repaidSub.textContent = eurCents(sim.capTotal) + " streamed β€” every note extinguished after " + yearsWord(sim.repaidAt);
514 } else {
515 const endCum = sim.rows[sim.rows.length - 1].cumI;
516 repaidTile.textContent = "Not within " + horizon;
517 repaidSub.textContent = eurCents(endCum) + " of " + eurCents(sim.capTotal) + " streamed by the horizon β€” patient money, by construction";
518 }
519 const upTo = sim.repaidAt >= 0 ? sim.repaidAt : sim.rows.length - 1;
520 $("t-commons").textContent = eurCents(sim.rows[upTo].cumC);
521 $("t-commons-sub").textContent = "cumulative by " + sim.rows[upTo].label + " (team: " + eurCents(sim.rows[upTo].cumT) + ")";
522
523 $("sim-note").textContent = sim.brokeAt >= 0
524 ? "Honesty check: this scenario runs out of money in " + sim.rows[sim.brokeAt].label +
525 " β€” costs outrun revenue and the raise. Nothing streams from a treasury that cannot cover operations."
526 : "";
527
528 const tbody = $("sim-table").querySelector("tbody");
529 tbody.replaceChildren();
530 for (const r of sim.rows) {
531 if (!r.yearEnd) continue;
532 const tr = document.createElement("tr");
533 const th = document.createElement("th");
534 th.scope = "row";
535 th.textContent = String(r.yearEnd);
536 tr.append(th);
537 for (const v of [r.revenue, r.cumI, r.cumT, r.cumC, r.remainingCap]) {
538 const td = document.createElement("td");
539 td.className = "num";
540 td.textContent = eurCents(v);
541 tr.append(td);
542 }
543 tbody.append(tr);
544 }
545
546 drawChart();
547}
548
549for (const c of CTLS) $(c.id).addEventListener("input", renderSim);
550renderSim();
551
552// ---- The ledger (funding.json β€” committed data) -----------------------------
553function emptyNote(target, text) {
554 const d = document.createElement("p");
555 d.className = "empty";
556 d.textContent = text;
557 target.replaceChildren(d);
558}
559
560function table(target, caption, heads, rows) {
561 const wrap = document.createElement("div");
562 wrap.className = "tablewrap";
563 const t = document.createElement("table");
564 const cap = document.createElement("caption");
565 cap.textContent = caption;
566 const thead = document.createElement("thead");
567 const hr = document.createElement("tr");
568 for (const h of heads) {
569 const th = document.createElement("th");
570 th.scope = "col";
571 if (h.num) th.className = "num";
572 th.textContent = h.label;
573 hr.append(th);
574 }
575 thead.append(hr);
576 const tbody = document.createElement("tbody");
577 for (const row of rows) {
578 const tr = document.createElement("tr");
579 row.forEach((cell, i) => {
580 const td = document.createElement(i === 0 ? "th" : "td");
581 if (i === 0) td.scope = "row";
582 if (heads[i].num) td.className = "num";
583 td.textContent = cell;
584 tr.append(td);
585 });
586 tbody.append(tr);
587 }
588 t.append(cap, thead, tbody);
589 wrap.append(t);
590 target.replaceChildren(wrap);
591}
592
593function renderLedger(d) {
594 $("asof").dateTime = d.as_of;
595 $("asof").textContent = new Date(d.as_of + "T00:00:00Z")
596 .toLocaleDateString(undefined, { dateStyle: "long" });
597
598 if (d.adopted) {
599 const co = $("status-callout");
600 co.classList.add("adopted");
601 $("status-head").textContent = "Status: adopted";
602 $("status-body").textContent = "The model is live. Every series below names " +
603 "the algorithm version it was sold under; every quarter’s distributions " +
604 "must be that version’s outputs for the published inputs.";
605 }
606
607 if (!d.series.length) {
608 emptyNote($("series-out"), "None β€” no series has opened. When one does, it appears here before any money moves: size, cap multiple, seasoning, and the pinned algorithm version.");
609 } else {
610 table($("series-out"), "Note series", [
611 { label: "Series" }, { label: "Opened" }, { label: "Closed" },
612 { label: "Raised", num: true }, { label: "Cap", num: true },
613 { label: "Seasoning" }, { label: "Algorithm" },
614 ], d.series.map((s) => [
615 s.id, s.openedAt || "β€”", s.closedAt || "β€”",
616 s.raised != null ? eurCents(s.raised) : "β€”",
617 (s.capMultiple ?? DEFAULT_CAP_MULTIPLE).toLocaleString(undefined, { minimumFractionDigits: 1 }) + "Γ—",
618 plur(s.seasoningMonths ?? DEFAULT_SEASONING_MONTHS, "month"),
619 s.algorithm || "β€”",
620 ]));
621 }
622
623 if (!d.notes.length) {
624 emptyNote($("notes-out"), "None β€” no notes exist. Each will show its pseudonymous id, principal, and remaining cap; a note at zero has been fully repaid and no longer exists.");
625 } else {
626 table($("notes-out"), "Outstanding notes", [
627 { label: "Note" }, { label: "Series" },
628 { label: "Principal", num: true }, { label: "Remaining cap", num: true },
629 ], d.notes.map((n) => [n.id, n.series, eurCents(n.principal), eurCents(n.remainingCap)]));
630 }
631
632 if (!d.quarters.length) {
633 emptyNote($("quarters-out"), "None β€” nothing has been computed because nothing has been raised. Every quarter will publish its inputs (revenue, costs, reserve top-up) and every output, so re-running the algorithm reproduces each payment.");
634 } else {
635 table($("quarters-out"), "Quarterly waterfall computations", [
636 { label: "Quarter" }, { label: "Revenue", num: true }, { label: "Costs", num: true },
637 { label: "Reserve", num: true }, { label: "Noteholders", num: true },
638 { label: "Team", num: true }, { label: "Commons", num: true },
639 ], d.quarters.map((q) => [
640 q.id, eurCents(q.revenue), eurCents(q.costs), eurCents(q.reserveTopUp),
641 eurCents((q.payments || []).reduce((s, p) => s + p.amount, 0)),
642 eurCents(q.teamPool || 0), eurCents(q.commonsPool || 0),
643 ]));
644 }
645}
646
647async function refresh() {
648 try {
649 const res = await fetch("./funding.json", { cache: "no-cache" });
650 if (!res.ok) throw new Error("HTTP " + res.status);
651 renderLedger(await res.json());
652 $("error").classList.remove("show");
653 } catch {
654 $("error").classList.add("show");
655 }
656}
657refresh();
658setInterval(refresh, 60000);
659</script>
660</body>
661</html>
662