this repo has no description
1<!DOCTYPE html>
2<html lang="en">
3<head>
4<meta charset="UTF-8">
5<meta name="viewport" content="width=device-width, initial-scale=1.0">
6<title>Servo — Opex & Staffing Addendum</title>
7<link rel="preconnect" href="https://fonts.googleapis.com">
8<link href="https://fonts.googleapis.com/css2?family=Pragati+Narrow:wght@400;700&display=swap" rel="stylesheet">
9<style>
10 @page { size: A4; margin: 0; }
11 * { margin: 0; padding: 0; box-sizing: border-box; }
12 body {
13 font-family: "Pragati Narrow", sans-serif;
14 background: rgb(247, 226, 231); color: rgb(71, 11, 0);
15 width: 210mm; min-height: 297mm; padding: 22px 28px;
16 -webkit-print-color-adjust: exact; print-color-adjust: exact;
17 font-size: 12px; line-height: 1.4;
18 }
19 .header { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 10px; padding-bottom: 6px; }
20 h1 { font-size: 24px; font-weight: 700; color: rgb(71, 11, 0); }
21 .byline { font-size: 11px; color: #1d1d1d; margin-top: 2px; }
22 .byline a { color: rgb(71, 11, 0); }
23 .date { font-size: 11px; color: #666; }
24 h2 { font-size: 13px; font-weight: 700; color: rgb(71, 11, 0); text-transform: uppercase; letter-spacing: 0.06em; margin: 12px 0 5px; border-bottom: 1px solid rgba(71, 11, 0, 0.2); padding-bottom: 3px; }
25 p { color: #1d1d1d; margin-bottom: 5px; font-size: 11.5px; line-height: 1.45; }
26 strong { color: rgb(71, 11, 0); }
27 table { width: 100%; border-collapse: collapse; font-size: 10.5px; margin: 4px 0 8px; }
28 th { text-align: left; color: #666; padding: 3px 6px; border-bottom: 1px solid rgba(71, 11, 0, 0.2); font-weight: 700; }
29 td { padding: 3px 6px; border-bottom: 1px solid rgba(71, 11, 0, 0.08); color: #1d1d1d; }
30 .r { text-align: right; }
31 .subtotal td { background: rgba(71, 11, 0, 0.04); font-weight: 600; }
32 .total td { background: rgba(71, 11, 0, 0.1); color: rgb(71, 11, 0); font-weight: 700; font-size: 11.5px; }
33 .two-col { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
34 .callout { background: #fff; border: 1px solid rgba(71, 11, 0, 0.2); border-radius: 8px; padding: 10px 14px; margin-bottom: 10px; border-left: 3px solid rgb(71, 11, 0); }
35 .callout p { font-size: 11px; line-height: 1.5; }
36 .pass { color: #1f6e1f; }
37 .fail { color: #a33; }
38 .muted { color: #686868; }
39 a { color: rgb(71, 11, 0); }
40 .footer { font-size: 9px; color: #686868; text-align: center; margin-top: auto; padding-top: 4px; border-top: 1px solid rgba(71, 11, 0, 0.15); }
41 .dial-row { display: flex; gap: 14px; margin-bottom: 10px; flex-wrap: wrap; }
42 .dial-item { flex: 1; min-width: 120px; }
43 .dial-item label { display: block; font-size: 10px; color: #666; margin-bottom: 3px; }
44 .dial-item input[type="range"] { width: 100%; accent-color: rgb(71, 11, 0); }
45 .dial-val { font-size: 14px; font-weight: 700; color: rgb(71, 11, 0); text-align: center; }
46 .note { font-size: 9px; color: #686868; line-height: 1.4; }
47</style>
48</head>
49<body>
50
51<div style="background:#a33;color:#fff;text-align:center;padding:4px;font-size:11px;font-weight:700;letter-spacing:0.1em;text-transform:uppercase;margin-bottom:6px;">DRAFT — Do not circulate without permission</div>
52
53<div class="header">
54 <div>
55 <h1>Opex & Staffing Addendum</h1>
56 <div class="byline">Servo Baseline Readiness — Dietrich Ayala — <a href="https://webtransitions.org">webtransitions.org</a></div>
57 </div>
58 <span class="date">February 2026</span>
59</div>
60
61<div class="callout">
62 <p>The main report models <strong>engineering salary only</strong> (€150k avg). This addendum provides the <strong>full loaded cost</strong> to operate an organization that employs those engineers — leadership, management, HR, legal, accounting, benefits, taxes, on-sites, infrastructure, and CX. Use the dials below to adjust the scenario.</p>
63</div>
64
65<div class="dial-row">
66 <div class="dial-item">
67 <label>Engineering FTE (new hires)</label>
68 <input type="range" id="opexFTE" min="5" max="50" value="22">
69 <div class="dial-val" id="opexFTEVal">22</div>
70 </div>
71 <div class="dial-item">
72 <label>BWA Target</label>
73 <input type="range" id="opexBWA" min="30" max="100" value="60">
74 <div class="dial-val" id="opexBWAVal">60%</div>
75 </div>
76 <div class="dial-item">
77 <label>Avg Eng Salary (€k)</label>
78 <input type="range" id="opexSalary" min="80" max="300" step="5" value="150">
79 <div class="dial-val" id="opexSalaryVal">€150k</div>
80 </div>
81 <div class="dial-item">
82 <label>Benefits Loading (%)</label>
83 <input type="range" id="opexBenefits" min="15" max="50" value="30">
84 <div class="dial-val" id="opexBenefitsVal">30%</div>
85 </div>
86 <div class="dial-item">
87 <label>Community FTE Offset (%)</label>
88 <input type="range" id="opexCommunity" min="0" max="50" value="0">
89 <div class="dial-val" id="opexCommunityVal">0%</div>
90 </div>
91</div>
92
93<div id="opexOutput"></div>
94
95<div class="footer">
96 Servo Baseline Readiness — Opex Addendum — <a href="https://webtransitions.org">webtransitions.org</a>
97</div>
98
99<script>
100function runOpex() {
101 const rawFTE = +document.getElementById('opexFTE').value;
102 const bwaPct = +document.getElementById('opexBWA').value;
103 const engSalary = +document.getElementById('opexSalary').value * 1000;
104 const benefitsPct = +document.getElementById('opexBenefits').value / 100;
105 const communityPct = +document.getElementById('opexCommunity').value / 100;
106
107 document.getElementById('opexFTEVal').textContent = rawFTE;
108 document.getElementById('opexBWAVal').textContent = bwaPct + '%';
109 document.getElementById('opexSalaryVal').innerHTML = '€' + (engSalary / 1000) + 'k';
110 document.getElementById('opexBenefitsVal').textContent = (benefitsPct * 100) + '%';
111 document.getElementById('opexCommunityVal').textContent = (communityPct * 100) + '%';
112
113 // Community offset reduces paid new hires
114 const communityFTE = Math.round(rawFTE * communityPct);
115 const paidNewEng = rawFTE - communityFTE;
116 const existingEng = 13; // current contributors (largely unfunded/volunteer-equivalent)
117 const totalEng = existingEng + paidNewEng;
118
119 // Staffing ratios
120 const mgmtRatio = 7; // 1 eng manager per 7 engineers
121 const engManagers = Math.ceil(paidNewEng / mgmtRatio);
122 const needsVPEng = paidNewEng >= 15;
123 const needsCTO = paidNewEng >= 25;
124
125 // Salaries for non-engineering roles (annual, EUR)
126 const salaries = {
127 engManager: 180000,
128 vpEng: 220000,
129 cto: 250000,
130 devrel: 130000,
131 communityMgr: 120000,
132 hr: 100000,
133 accounting: 90000,
134 legal: 70000, // fractional / outsourced
135 ops: 95000, // office/IT ops
136 };
137
138 // CX investment: scale with team size
139 const devrelCount = paidNewEng >= 15 ? 2 : 1;
140 const communityMgrCount = paidNewEng >= 10 ? 1 : 0;
141
142 // Support staff: scale with org size
143 const totalHeadcount = paidNewEng + engManagers + (needsVPEng ? 1 : 0) + (needsCTO ? 1 : 0) + devrelCount + communityMgrCount;
144 const hrCount = totalHeadcount >= 20 ? 1 : 0.5; // fractional for small orgs
145 const accountingCount = 0.5; // typically outsourced
146 const legalCount = 0.25; // fractional / outsourced
147 const opsCount = totalHeadcount >= 25 ? 1 : 0.5;
148
149 // Per-person costs
150 const onsitePerPerson = 4000; // per quarter
151 const onsitesPerYear = 4;
152 const infra = 2000; // per eng per month (CI, hosting, tools)
153 const remoteStipend = 3000; // per person per year
154
155 // Build line items
156 const lines = [];
157 const cat = (name, count, salary, note) => {
158 const base = Math.round(count * salary);
159 const loaded = Math.round(base * (1 + benefitsPct));
160 lines.push({ category: name, count: count, base, loaded, note: note || '' });
161 return loaded;
162 };
163
164 // Engineering
165 let total = 0;
166 total += cat('Engineers (new paid)', paidNewEng, engSalary, 'Core engine, layout, DOM, CSS, JS integration');
167 total += cat('Engineering Managers', engManagers, salaries.engManager, '1 per ~' + mgmtRatio + ' engineers');
168 if (needsVPEng) total += cat('VP Engineering', 1, salaries.vpEng, 'Technical leadership');
169 if (needsCTO) total += cat('CTO / Tech Director', 1, salaries.cto, 'Strategic technical direction');
170
171 // CX
172 total += cat('Developer Relations', devrelCount, salaries.devrel, 'Community engagement, docs, evangelism');
173 if (communityMgrCount > 0) total += cat('Community Manager', communityMgrCount, salaries.communityMgr, 'Community operations, contributor onboarding');
174
175 // Operations
176 if (hrCount > 0) total += cat('HR / People Ops', hrCount, salaries.hr, hrCount < 1 ? 'Fractional / shared' : 'Full-time');
177 total += cat('Accounting / Finance', accountingCount, salaries.accounting, 'Outsourced / fractional');
178 total += cat('Legal', legalCount, salaries.legal, 'Outsourced / fractional');
179 if (opsCount > 0) total += cat('IT / Ops', opsCount, salaries.ops, opsCount < 1 ? 'Fractional' : 'Full-time');
180
181 const totalStaffLoaded = total;
182
183 // Non-staff costs
184 const onsiteCost = Math.round(totalHeadcount * onsitePerPerson * onsitesPerYear);
185 const infraCost = Math.round(paidNewEng * infra * 12);
186 const remoteCost = Math.round(totalHeadcount * remoteStipend);
187 const contingency = Math.round((totalStaffLoaded + onsiteCost + infraCost + remoteCost) * 0.05);
188 const totalAnnual = totalStaffLoaded + onsiteCost + infraCost + remoteCost + contingency;
189
190 // Render
191 const el = document.getElementById('opexOutput');
192
193 let staffRows = lines.map(l => {
194 const countStr = l.count % 1 === 0 ? l.count : l.count.toFixed(1);
195 return '<tr><td>' + l.category + '</td><td class="r">' + countStr + '</td><td class="r">€' + (l.base / 1000).toFixed(0) + 'k</td><td class="r">€' + (l.loaded / 1000).toFixed(0) + 'k</td><td class="muted">' + l.note + '</td></tr>';
196 }).join('');
197
198 const totalHeadFinal = lines.reduce((s, l) => s + l.count, 0);
199
200 el.innerHTML =
201 '<h2>Scenario: ' + bwaPct + '% BWA in 3 Years — ' + paidNewEng + ' Paid New Engineers' + (communityFTE > 0 ? ' + ' + communityFTE + ' Community FTE' : '') + '</h2>' +
202 '<div class="two-col"><div>' +
203
204 '<h2 style="margin-top:0;">Staff Costs (Loaded)</h2>' +
205 '<table>' +
206 '<tr><th>Role</th><th class="r">Count</th><th class="r">Base/yr</th><th class="r">Loaded/yr</th><th>Notes</th></tr>' +
207 staffRows +
208 '<tr class="subtotal"><td>Staff Subtotal</td><td class="r">' + totalHeadFinal.toFixed(1) + '</td><td></td><td class="r">€' + (totalStaffLoaded / 1e6).toFixed(2) + 'M</td><td class="muted">incl. ' + (benefitsPct * 100) + '% benefits loading</td></tr>' +
209 '</table>' +
210
211 '<h2>Non-Staff Costs</h2>' +
212 '<table>' +
213 '<tr><th>Item</th><th class="r">Annual</th><th>Notes</th></tr>' +
214 '<tr><td>Quarterly On-Sites</td><td class="r">€' + (onsiteCost / 1000).toFixed(0) + 'k</td><td class="muted">' + totalHeadcount + ' people × €' + (onsitePerPerson / 1000).toFixed(0) + 'k × ' + onsitesPerYear + '/yr</td></tr>' +
215 '<tr><td>Infrastructure & CI</td><td class="r">€' + (infraCost / 1000).toFixed(0) + 'k</td><td class="muted">' + paidNewEng + ' eng × €' + (infra / 1000).toFixed(0) + 'k/mo (CI, hosting, tools)</td></tr>' +
216 '<tr><td>Remote Stipend</td><td class="r">€' + (remoteCost / 1000).toFixed(0) + 'k</td><td class="muted">€' + (remoteStipend / 1000).toFixed(0) + 'k/person/yr (equipment, coworking)</td></tr>' +
217 '<tr><td>Contingency (5%)</td><td class="r">€' + (contingency / 1000).toFixed(0) + 'k</td><td class="muted">Buffer for unforeseen costs</td></tr>' +
218 '<tr class="subtotal"><td>Non-Staff Subtotal</td><td class="r">€' + ((onsiteCost + infraCost + remoteCost + contingency) / 1000).toFixed(0) + 'k</td><td></td></tr>' +
219 '</table>' +
220
221 '</div><div>' +
222
223 '<h2 style="margin-top:0;">3-Year Total</h2>' +
224 '<table>' +
225 '<tr><th>Period</th><th class="r">Staff</th><th class="r">Non-Staff</th><th class="r">Total</th></tr>' +
226 '<tr><td>Year 1 (ramp)</td><td class="r">€' + (totalStaffLoaded * 0.6 / 1e6).toFixed(2) + 'M</td><td class="r">€' + ((onsiteCost + infraCost + remoteCost + contingency) * 0.6 / 1e6).toFixed(2) + 'M</td><td class="r">€' + (totalAnnual * 0.6 / 1e6).toFixed(2) + 'M</td></tr>' +
227 '<tr><td>Year 2</td><td class="r">€' + (totalStaffLoaded / 1e6).toFixed(2) + 'M</td><td class="r">€' + ((onsiteCost + infraCost + remoteCost + contingency) / 1e6).toFixed(2) + 'M</td><td class="r">€' + (totalAnnual / 1e6).toFixed(2) + 'M</td></tr>' +
228 '<tr><td>Year 3</td><td class="r">€' + (totalStaffLoaded / 1e6).toFixed(2) + 'M</td><td class="r">€' + ((onsiteCost + infraCost + remoteCost + contingency) / 1e6).toFixed(2) + 'M</td><td class="r">€' + (totalAnnual / 1e6).toFixed(2) + 'M</td></tr>' +
229 '<tr class="total"><td>3-Year Total</td><td class="r">€' + (totalStaffLoaded * 2.6 / 1e6).toFixed(2) + 'M</td><td class="r">€' + ((onsiteCost + infraCost + remoteCost + contingency) * 2.6 / 1e6).toFixed(2) + 'M</td><td class="r">€' + (totalAnnual * 2.6 / 1e6).toFixed(2) + 'M</td></tr>' +
230 '</table>' +
231
232 '<h2>Comparison: Eng-Only vs Full Loaded</h2>' +
233 '<table>' +
234 '<tr><th></th><th class="r">Eng Only</th><th class="r">Full Loaded</th><th class="r">Multiplier</th></tr>' +
235 '<tr><td>Annual</td><td class="r">€' + (paidNewEng * engSalary / 1e6).toFixed(2) + 'M</td><td class="r">€' + (totalAnnual / 1e6).toFixed(2) + 'M</td><td class="r">' + (totalAnnual / (paidNewEng * engSalary || 1)).toFixed(2) + 'x</td></tr>' +
236 '<tr><td>3-Year</td><td class="r">€' + (paidNewEng * engSalary * 3 / 1e6).toFixed(2) + 'M</td><td class="r">€' + (totalAnnual * 2.6 / 1e6).toFixed(2) + 'M</td><td class="r">' + (totalAnnual * 2.6 / (paidNewEng * engSalary * 3 || 1)).toFixed(2) + 'x</td></tr>' +
237 '</table>' +
238 '<p class="note" style="margin-top:6px;">The multiplier shows how much more the full org costs compared to raw engineering salaries. Typical range: 1.8–2.5x depending on benefits loading, team size, and operational overhead.</p>' +
239
240 '<h2>Org Chart Summary</h2>' +
241 '<p><strong>Total headcount:</strong> ' + totalHeadFinal.toFixed(1) + ' (paid)' + (communityFTE > 0 ? ' + ' + communityFTE + ' community FTE' : '') + '</p>' +
242 '<p><strong>Engineering:</strong> ' + paidNewEng + ' new + 13 existing = ' + totalEng + ' total</p>' +
243 '<p><strong>Management:</strong> ' + engManagers + ' eng mgr' + (needsVPEng ? ' + VP Eng' : '') + (needsCTO ? ' + CTO' : '') + '</p>' +
244 '<p><strong>CX:</strong> ' + devrelCount + ' DevRel' + (communityMgrCount > 0 ? ' + ' + communityMgrCount + ' Community Mgr' : '') + '</p>' +
245 '<p><strong>Ops:</strong> ' + hrCount.toFixed(1) + ' HR, ' + accountingCount.toFixed(1) + ' accounting, ' + legalCount.toFixed(2) + ' legal' + (opsCount > 0 ? ', ' + opsCount.toFixed(1) + ' IT' : '') + '</p>' +
246
247 '</div></div>' +
248
249 '<h2>Assumptions & Notes</h2>' +
250 '<p class="note"><strong>Benefits loading (' + (benefitsPct * 100) + '%):</strong> Employer taxes (social security, pension), health insurance, equipment, professional development. EU-typical range 25–40%. • <strong>On-sites:</strong> €' + (onsitePerPerson / 1000).toFixed(0) + 'k/person/quarter covers travel, accommodation, venue for distributed team meetups. • <strong>Infrastructure:</strong> €' + (infra / 1000).toFixed(0) + 'k/eng/month covers CI runners, cloud hosting, development tools, WPT infrastructure. • <strong>Ramp:</strong> Year 1 at 60% assumes mid-year average as hiring ramps. • <strong>Existing 13 FTE:</strong> Current contributors are largely volunteer or externally funded; their costs are not included. • <strong>Inflation:</strong> Not modeled; add 2–4% annually for multi-year planning. • <strong>No office costs:</strong> Assumes remote-first with on-site gatherings.</p>';
251
252}
253
254document.getElementById('opexFTE').addEventListener('input', runOpex);
255document.getElementById('opexBWA').addEventListener('input', runOpex);
256document.getElementById('opexSalary').addEventListener('input', runOpex);
257document.getElementById('opexBenefits').addEventListener('input', runOpex);
258document.getElementById('opexCommunity').addEventListener('input', runOpex);
259runOpex();
260</script>
261
262</body>
263</html>