fullstackgtm 0.20.0 → 0.21.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.
@@ -110,16 +110,70 @@ export function marketMapToMarkdown(config: MarketConfig, set: ObservationSet):
110
110
  type ScatterPoint = { vendorId: string; name: string; x: number; y: number; size: number };
111
111
  type ScatterAxis = { label: string; negativePole: string; positivePole: string; signed: boolean };
112
112
 
113
+ /**
114
+ * Okabe–Ito palette: categorical, colorblind-safe, print-stable. Vendors are
115
+ * numbered in legend order so a dense cluster stays readable — the number in
116
+ * the bubble resolves what overlapping labels never could.
117
+ */
118
+ const VENDOR_COLORS = [
119
+ "#0072b2", "#e69f00", "#009e73", "#d55e00", "#cc79a7",
120
+ "#56b4e9", "#b8a000", "#717171", "#882255", "#44aa99",
121
+ ];
122
+
123
+ /** Dark numerals on light fills, white on dark — simple luminance cut. */
124
+ function numeralColor(hex: string): string {
125
+ const r = parseInt(hex.slice(1, 3), 16);
126
+ const g = parseInt(hex.slice(3, 5), 16);
127
+ const b = parseInt(hex.slice(5, 7), 16);
128
+ return 0.299 * r + 0.587 * g + 0.114 * b > 150 ? "#1c1c1c" : "#ffffff";
129
+ }
130
+
131
+ /**
132
+ * Wrap an axis-pole label into at most two short lines. Parentheticals are
133
+ * dropped first — poles like "REGULATED LEAD-GEN OPERATIONS (sales-led PEO)"
134
+ * keep their head, and nothing ever runs into the opposite corner.
135
+ */
136
+ function wrapPole(text: string, maxChars = 20): string[] {
137
+ let cleaned = text.replace(/\s*\([^)]*\)/g, "").trim();
138
+ if (cleaned.length === 0) cleaned = text.trim();
139
+ const words = cleaned.split(/\s+/);
140
+ const lines: string[] = [];
141
+ let current = "";
142
+ for (const word of words) {
143
+ if (current && (current + " " + word).length > maxChars) {
144
+ lines.push(current);
145
+ current = word;
146
+ if (lines.length === 2) break;
147
+ } else {
148
+ current = current ? `${current} ${word}` : word;
149
+ }
150
+ }
151
+ if (lines.length < 2 && current) lines.push(current);
152
+ if (lines.length === 2 && current && lines[1] !== current) lines[1] = `${lines[1]}…`;
153
+ return lines;
154
+ }
155
+
156
+ function poleText(lines: string[], x: number, y: number, anchorMode: string, fs: number): string {
157
+ const e = escapeHtml;
158
+ const spans = lines
159
+ .map((line, index) => `<tspan x="${x}" dy="${index === 0 ? 0 : fs + 2}">${e(line)}</tspan>`)
160
+ .join("");
161
+ return `<text class="ax-label" style="font-size:${fs}px" x="${x}" y="${y}" text-anchor="${anchorMode}">${spans}</text>`;
162
+ }
163
+
113
164
  function svgScatter(
114
165
  points: ScatterPoint[],
115
166
  ax: ScatterAxis,
116
167
  ay: ScatterAxis,
117
168
  anchor: string | undefined,
118
- mini: boolean,
169
+ colorByVendor: Map<string, string>,
170
+ numberByVendor: Map<string, number>,
119
171
  ): string {
120
- const W = mini ? 330 : 700;
121
- const H = mini ? 250 : 460;
122
- const PAD = mini ? 34 : 56;
172
+ const W = 640;
173
+ const H = 480;
174
+ const PAD_X = 56;
175
+ const PAD_TOP = 44;
176
+ const PAD_BOTTOM = 56;
123
177
  const range = (axis: ScatterAxis, values: number[]): [number, number] => {
124
178
  if (axis.signed) return [-1.1, 1.1];
125
179
  if (values.length === 0) return [0, 1];
@@ -127,30 +181,52 @@ function svgScatter(
127
181
  };
128
182
  const [xLo, xHi] = range(ax, points.map((p) => p.x));
129
183
  const [yLo, yHi] = range(ay, points.map((p) => p.y));
130
- const sx = (x: number) => PAD + ((x - xLo) / (xHi - xLo)) * (W - 2 * PAD);
131
- const sy = (y: number) => H - PAD - ((y - yLo) / (yHi - yLo)) * (H - 2 * PAD);
132
- const fsLabel = mini ? 8.5 : 10.5;
133
- const fsAx = mini ? 8 : 10;
184
+ const sx = (x: number) => PAD_X + ((x - xLo) / (xHi - xLo)) * (W - 2 * PAD_X);
185
+ const sy = (y: number) => H - PAD_BOTTOM - ((y - yLo) / (yHi - yLo)) * (H - PAD_TOP - PAD_BOTTOM);
134
186
  const e = escapeHtml;
135
- const dots = points
187
+
188
+ // Big bubbles first so small ones stay clickable/visible on top.
189
+ const ordered = [...points].sort((a, b) => b.size - a.size);
190
+ const dots = ordered
136
191
  .map((p) => {
137
- // Area-proportional: perceived bubble area tracks the size metric.
138
- const r = (mini ? 4 + 14 * Math.sqrt(p.size) : 8 + 26 * Math.sqrt(p.size));
139
- const cls = p.vendorId === anchor ? "dot-anchor" : "dot";
192
+ const r = 7 + 24 * Math.sqrt(p.size);
193
+ const color = colorByVendor.get(p.vendorId) ?? "#717171";
194
+ const ring = p.vendorId === anchor ? ` stroke="#1c1c1c" stroke-width="2.5"` : ` stroke="#ffffff" stroke-width="1.5"`;
195
+ const number = numberByVendor.get(p.vendorId) ?? 0;
196
+ const fs = Math.max(10, Math.min(14, r * 0.9));
140
197
  return (
141
- `<circle class="${cls}" cx="${sx(p.x).toFixed(1)}" cy="${sy(p.y).toFixed(1)}" r="${r.toFixed(1)}"/>` +
142
- `<text class="dot-label" style="font-size:${fsLabel}px" x="${sx(p.x).toFixed(1)}" y="${(sy(p.y) - r - 4).toFixed(1)}">${e(p.name)}</text>`
198
+ `<circle cx="${sx(p.x).toFixed(1)}" cy="${sy(p.y).toFixed(1)}" r="${r.toFixed(1)}" fill="${color}" fill-opacity="0.88"${ring}>` +
199
+ `<title>${e(p.name)}</title></circle>` +
200
+ `<text x="${sx(p.x).toFixed(1)}" y="${(sy(p.y) + fs * 0.36).toFixed(1)}" text-anchor="middle" ` +
201
+ `font-size="${fs.toFixed(1)}" font-weight="700" fill="${numeralColor(color)}" style="pointer-events:none">${number}</text>`
143
202
  );
144
203
  })
145
204
  .join("");
146
- const midX = ax.signed ? `<line class="axis-mid" x1="${sx(0).toFixed(0)}" y1="${PAD}" x2="${sx(0).toFixed(0)}" y2="${H - PAD}"/>` : "";
147
- const midY = ay.signed ? `<line class="axis-mid" x1="${PAD}" y1="${sy(0).toFixed(0)}" x2="${W - PAD}" y2="${sy(0).toFixed(0)}"/>` : "";
205
+
206
+ const midX = ax.signed ? `<line class="grid" x1="${sx(0).toFixed(0)}" y1="${PAD_TOP}" x2="${sx(0).toFixed(0)}" y2="${H - PAD_BOTTOM}"/>` : "";
207
+ const midY = ay.signed ? `<line class="grid" x1="${PAD_X}" y1="${sy(0).toFixed(0)}" x2="${W - PAD_X}" y2="${sy(0).toFixed(0)}"/>` : "";
208
+
209
+ // Pole labels in four positions that cannot collide: x poles horizontal at
210
+ // the bottom corners; y poles rotated along the left margin (positive
211
+ // reading up toward the top, negative near the bottom) — the standard
212
+ // chart convention, wrapped to ≤2 short lines each.
213
+ const fsPole = 10;
214
+ const xNeg = poleText(wrapPole(ax.negativePole), PAD_X, H - PAD_BOTTOM + 20, "start", fsPole);
215
+ const xPos = poleText(wrapPole(ax.positivePole), W - PAD_X, H - PAD_BOTTOM + 20, "end", fsPole);
216
+ const yLabel = (lines: string[], yEdge: number, anchorMode: "start" | "end") => {
217
+ const spans = lines
218
+ .map((line, index) => `<tspan x="${-yEdge}" dy="${index === 0 ? 0 : fsPole + 2}">${e(line)}</tspan>`)
219
+ .join("");
220
+ return `<text class="ax-label" style="font-size:${fsPole}px" transform="rotate(-90)" x="${-yEdge}" y="16" text-anchor="${anchorMode}">${spans}</text>`;
221
+ };
222
+ const yPos = yLabel(wrapPole(ay.positivePole, 26), PAD_TOP, "end");
223
+ const yNeg = ay.signed ? yLabel(wrapPole(ay.negativePole, 26), H - PAD_BOTTOM, "start") : "";
224
+
148
225
  return `<svg viewBox="0 0 ${W} ${H}" role="img" aria-label="${e(ax.label)} vs ${e(ay.label)}">
149
- <line class="axis" x1="${PAD}" y1="${H - PAD}" x2="${W - PAD}" y2="${H - PAD}"/>
150
- <line class="axis" x1="${PAD}" y1="${PAD}" x2="${PAD}" y2="${H - PAD}"/>${midX}${midY}
151
- <text class="ax-label" style="font-size:${fsAx}px" x="${PAD}" y="${H - 14}">&#8592; ${e(ax.negativePole)}</text>
152
- <text class="ax-label" style="font-size:${fsAx}px" x="${W - PAD}" y="${H - 14}" text-anchor="end">${e(ax.positivePole)} &#8594;</text>
153
- <text class="ax-label" style="font-size:${fsAx}px" x="${PAD}" y="${PAD - 10}">&#8593; ${e(ay.positivePole)}${ay.signed ? ` &#183; &#8595; ${e(ay.negativePole)}` : ""}</text>
226
+ <rect x="${PAD_X}" y="${PAD_TOP}" width="${W - 2 * PAD_X}" height="${H - PAD_TOP - PAD_BOTTOM}" class="plot"/>
227
+ ${midX}${midY}
228
+ ${xNeg}${xPos}
229
+ ${yPos}${yNeg}
154
230
  ${dots}</svg>`;
155
231
  }
156
232
 
@@ -172,10 +248,13 @@ function axisSectionsHtml(
172
248
  const useScale = report.vendors.length > 0 && report.vendors.every((vendorId) => scaleIndex.get(vendorId) !== null && scaleIndex.get(vendorId) !== undefined);
173
249
  const loudCounts = new Map(report.vendors.map((vendorId) => [vendorId, messageBreadth(vendorId, set.observations).loudCount]));
174
250
  const maxLoud = Math.max(1, ...loudCounts.values());
251
+ // Bubble areas stay proportional to the metric; dividing by the max just
252
+ // spends the full visual range without distorting any ratio.
253
+ const maxShare = Math.max(1e-9, ...report.vendors.map((vendorId) => scaleIndex.get(vendorId) ?? 0));
175
254
  const sizeOf = (vendorId: string): number =>
176
- useScale ? (scaleIndex.get(vendorId) as number) : (loudCounts.get(vendorId) ?? 0) / maxLoud;
255
+ useScale ? (scaleIndex.get(vendorId) as number) / maxShare : (loudCounts.get(vendorId) ?? 0) / maxLoud;
177
256
  const sizeCaption = useScale
178
- ? `Dot area &#8733; relative scale index (within this vendor set, from: ${e(scale.metricsUsed.join(", "))} citable signals, not true market share)`
257
+ ? `Dot area &#8733; estimated revenue share of this vendor set (signals: ${e(scale.metricsUsed.join(", "))}; calibrated within-set, ACV-band stratified, citable but NOT audited — see \`market scale\` for estimates and spreads)`
179
258
  : "Dot area &#8733; LOUD count";
180
259
 
181
260
  const breadthAxis: ScatterAxis & { id: string } = {
@@ -226,9 +305,34 @@ function axisSectionsHtml(
226
305
  const axInfo = axisInfo.get(px) as ScatterAxis & { id: string };
227
306
  const ayInfo = axisInfo.get(py) as ScatterAxis & { id: string };
228
307
  const statusOf = (id: string) => axes.find((axis) => axis.id === id)?.status ?? (id === "breadth" ? "derived" : "");
308
+
309
+ // Legend order doubles as bubble numbering: largest first, anchor bolded.
310
+ // The number inside each bubble resolves dense clusters that name labels
311
+ // never could; color is Okabe–Ito (colorblind-safe) keyed in the legend.
312
+ const points = pointsFor(px, py);
313
+ const legendOrder = [...points].sort((a, b) => b.size - a.size || a.name.localeCompare(b.name));
314
+ const numberByVendor = new Map(legendOrder.map((point, index) => [point.vendorId, index + 1]));
315
+ const colorByVendor = new Map(legendOrder.map((point, index) => [point.vendorId, VENDOR_COLORS[index % VENDOR_COLORS.length]]));
316
+ const legendRows = legendOrder
317
+ .map((point) => {
318
+ const number = numberByVendor.get(point.vendorId);
319
+ const color = colorByVendor.get(point.vendorId) as string;
320
+ const isAnchor = point.vendorId === config.anchorVendor;
321
+ const measure = useScale
322
+ ? `${(((scaleIndex.get(point.vendorId) as number) ?? 0) * 100).toFixed(1)}%`
323
+ : `${loudCounts.get(point.vendorId) ?? 0} loud`;
324
+ return `<tr${isAnchor ? ' class="anchor-row"' : ""}><td><span class="swatch" style="background:${color};color:${numeralColor(color)}">${number}</span></td><td>${e(point.name)}${isAnchor ? " ·&nbsp;anchor" : ""}</td><td class="num">${measure}</td></tr>`;
325
+ })
326
+ .join("");
327
+ const legendMeasureHead = useScale ? "est. share" : "loud";
328
+
229
329
  const strategicMap = `<section>
230
- <h2><span class="no">03</span> Strategic map ${e(axInfo.label)} &#215; ${e(ayInfo.label)}</h2>
231
- <figure>${svgScatter(pointsFor(px, py), axInfo, ayInfo, config.anchorVendor, false)}
330
+ <h2>Strategic map: ${e(axInfo.label)} &#215; ${e(ayInfo.label)}</h2>
331
+ <figure class="map">
332
+ <div class="map-row">
333
+ ${svgScatter(points, axInfo, ayInfo, config.anchorVendor, colorByVendor, numberByVendor)}
334
+ <table class="legend"><thead><tr><th></th><th>vendor</th><th class="num">${legendMeasureHead}</th></tr></thead><tbody>${legendRows}</tbody></table>
335
+ </div>
232
336
  <figcaption>Positions computed from run ${e(set.runLabel)} observations: each axis is a per-claim scoring rubric
233
337
  in the market config; a vendor sits at the intensity-weighted mean (loud=1, quiet=&#189;) of the claims it
234
338
  voices. ${sizeCaption}. Axis status — ${e(axInfo.label)}: ${e(statusOf(px))}; ${e(ayInfo.label)}: ${e(statusOf(py))}.</figcaption>
@@ -252,7 +356,6 @@ export function marketMapToHtml(config: MarketConfig, set: ObservationSet): stri
252
356
  const anchor = config.anchorVendor;
253
357
  const e = escapeHtml;
254
358
  const axisHtml = axisSectionsHtml(config, set);
255
- const appendixNo = axisHtml.report ? "04" : "03";
256
359
 
257
360
  const matrixRows = model.orderedClaimIds
258
361
  .map((claimId) => {
@@ -312,81 +415,78 @@ export function marketMapToHtml(config: MarketConfig, set: ObservationSet): stri
312
415
  <meta name="viewport" content="width=device-width, initial-scale=1">
313
416
  <title>Market map — ${e(config.category)} — ${e(set.runLabel)}</title>
314
417
  <style>
315
- :root { --paper:#f4efe4; --ink:#211d16; --ink-soft:#5a5244; --line:#c9bfa9; --accent:#b4441b; --green:#2e5339; --quiet:#8a7d63; }
418
+ :root { --ink:#1c1c1c; --soft:#6b6b6b; --line:#e3e1dc; --faint:#f7f6f4; --accent:#b3491f; --green:#2e5e43; }
316
419
  * { box-sizing:border-box; margin:0; }
317
- body { font-family:"Iowan Old Style","Palatino Linotype",Palatino,Georgia,serif; color:var(--ink); background:var(--paper);
318
- max-width:1080px; margin:0 auto; padding:0 48px 96px;
319
- background-image:radial-gradient(rgba(33,29,22,.028) 1px, transparent 1.2px); background-size:5px 5px; }
320
- .chip,.claim-meta,.ev-src,.lg,.stamp,.meta,th.vh span { font-family:"SF Mono",Menlo,Consolas,monospace; }
321
- header { padding:56px 0 28px; border-bottom:3px double var(--ink); position:relative; }
322
- .kicker { font-size:11px; letter-spacing:.32em; color:var(--accent); text-transform:uppercase; }
323
- h1 { font-size:44px; line-height:1.05; font-weight:600; margin:10px 0 6px; }
324
- h1 em { font-style:italic; color:var(--green); }
325
- .meta { font-size:11.5px; color:var(--ink-soft); display:flex; gap:24px; flex-wrap:wrap; margin-top:14px; }
326
- .stamp { position:absolute; right:0; top:58px; border:2px solid var(--accent); color:var(--accent); padding:7px 13px;
327
- font-size:11px; letter-spacing:.22em; transform:rotate(3.5deg); text-transform:uppercase; }
328
- section { margin-top:56px; }
329
- h2 { font-size:13px; letter-spacing:.26em; text-transform:uppercase; color:var(--ink-soft);
330
- border-bottom:1px solid var(--line); padding-bottom:9px; display:flex; gap:14px; align-items:baseline; }
331
- h2 .no { color:var(--accent); font-style:italic; font-size:15px; letter-spacing:0; }
332
- .fronts { display:grid; grid-template-columns:repeat(4,1fr); gap:1px; background:var(--line); border:1px solid var(--line); margin-top:22px; }
333
- .fcard { background:var(--paper); padding:18px 18px 14px; }
334
- .fcard b { display:block; font-size:42px; font-weight:600; line-height:1; }
335
- .fcard span { font-size:11px; letter-spacing:.18em; text-transform:uppercase; color:var(--ink-soft); }
420
+ body { font-family:"Iowan Old Style","Palatino Linotype",Palatino,Georgia,serif; color:var(--ink); background:#fff;
421
+ max-width:1060px; margin:0 auto; padding:0 44px 80px; font-size:15px; line-height:1.5; }
422
+ .mono,.claim-meta,.ev-src,.key,.meta,th.vh span,.chip,.legend { font-family:ui-monospace,"SF Mono",Menlo,Consolas,monospace; }
423
+ header { padding:44px 0 20px; border-bottom:1px solid var(--ink); }
424
+ h1 { font-size:27px; font-weight:600; line-height:1.2; }
425
+ .meta { font-size:11px; color:var(--soft); display:flex; gap:18px; flex-wrap:wrap; margin-top:8px; }
426
+ section { margin-top:44px; }
427
+ h2 { font-size:17px; font-weight:600; border-bottom:1px solid var(--line); padding-bottom:7px; margin-bottom:4px; }
428
+ .fronts { display:grid; grid-template-columns:repeat(4,1fr); gap:1px; background:var(--line); border:1px solid var(--line); margin-top:16px; }
429
+ .fcard { background:#fff; padding:14px 16px 12px; }
430
+ .fcard b { display:block; font-size:30px; font-weight:600; line-height:1.1; }
431
+ .fcard span { font-size:11px; color:var(--soft); }
336
432
  .fcard.open b { color:var(--accent); }
337
- .openlist { margin-top:18px; font-size:15.5px; line-height:1.55; }
338
- .openlist li { margin:4px 0 4px 20px; }
339
- .openlist .why { color:var(--ink-soft); font-size:13px; font-style:italic; }
340
- .legend { display:flex; gap:22px; flex-wrap:wrap; margin:18px 0 10px; font-size:10.5px; color:var(--ink-soft); }
341
- .lg { display:inline-flex; align-items:center; gap:7px; }
433
+ .openlist { margin-top:14px; font-size:14.5px; line-height:1.55; }
434
+ .openlist li { margin:3px 0 3px 20px; }
435
+ .openlist .why { color:var(--soft); font-size:13px; }
436
+ .key { display:flex; gap:20px; flex-wrap:wrap; margin:14px 0 8px; font-size:10.5px; color:var(--soft); }
437
+ .lg { display:inline-flex; align-items:center; gap:6px; }
342
438
  table { border-collapse:collapse; width:100%; margin-top:6px; }
343
- thead th { border-bottom:2px solid var(--ink); padding:6px 2px 10px; }
344
- th.vh span { writing-mode:vertical-rl; transform:rotate(195deg); font-size:10.5px; letter-spacing:.12em;
345
- text-transform:uppercase; color:var(--ink-soft); display:inline-block; }
439
+ thead th { border-bottom:1.5px solid var(--ink); padding:6px 2px 8px; font-weight:600; }
440
+ th.vh span { writing-mode:vertical-rl; transform:rotate(195deg); font-size:10.5px; color:var(--soft); display:inline-block; }
346
441
  th.vh.anchor-col span { color:var(--green); font-weight:700; }
347
- tbody th { text-align:left; font-weight:400; padding:7px 14px 7px 0; border-bottom:1px solid var(--line); max-width:330px; }
348
- .claim-cap { display:block; font-size:14.5px; }
349
- .claim-meta { display:block; font-size:9.5px; color:var(--quiet); letter-spacing:.08em; margin-top:2px; }
442
+ tbody th { text-align:left; font-weight:400; padding:6px 14px 6px 0; border-bottom:1px solid var(--line); max-width:330px; }
443
+ .claim-cap { display:block; font-size:14px; }
444
+ .claim-meta { display:block; font-size:9.5px; color:var(--soft); margin-top:1px; }
350
445
  td.cell { text-align:center; border-bottom:1px solid var(--line); padding:4px 2px; }
351
- td.cell.anchor-col { background:rgba(46,83,57,.06); }
446
+ td.cell.anchor-col { background:var(--faint); }
352
447
  td.front { border-bottom:1px solid var(--line); text-align:right; white-space:nowrap; }
353
- .g { display:inline-block; width:15px; height:15px; vertical-align:middle; }
448
+ .g { display:inline-block; width:14px; height:14px; vertical-align:middle; }
354
449
  .g-loud { background:var(--ink); }
355
- .g-quiet { box-shadow:inset 0 0 0 2px var(--quiet); }
356
- .g-absent { background:radial-gradient(circle at center, var(--line) 0 2.5px, transparent 3px); }
357
- .g-unobservable { background:repeating-linear-gradient(45deg, var(--line) 0 2px, transparent 2px 5px); }
450
+ .g-quiet { box-shadow:inset 0 0 0 2px #9a9a9a; }
451
+ .g-absent { background:radial-gradient(circle at center, #cfcdc8 0 2.5px, transparent 3px); }
452
+ .g-unobservable { background:repeating-linear-gradient(45deg, #cfcdc8 0 2px, transparent 2px 5px); }
358
453
  tr.front-open th .claim-cap { color:var(--accent); font-weight:600; }
359
- .chip { font-size:9px; letter-spacing:.16em; padding:3px 8px; border:1px solid currentColor; }
360
- .chip-open { color:var(--accent); } .chip-contested { color:#7a5a12; }
361
- .chip-owned { color:var(--green); } .chip-saturated { color:var(--ink-soft); } .chip-vacant { color:var(--quiet); }
362
- .ev { border-bottom:1px solid var(--line); padding:12px 0; }
363
- .ev-head { font-size:10.5px; letter-spacing:.1em; color:var(--accent); }
364
- .ev blockquote { font-style:italic; margin:6px 0; font-size:13.5px; line-height:1.5; }
365
- .ev-src { font-size:10px; color:var(--ink-soft); word-break:break-all; }
366
- figure { margin-top:22px; border:1px solid var(--line); background:rgba(255,255,255,.35); }
367
- .axis { stroke:var(--ink); stroke-width:1.5; }
368
- .axis-mid { stroke:var(--line); stroke-dasharray:3 5; }
369
- .ax-label { letter-spacing:.16em; fill:var(--ink-soft); font-family:"SF Mono",Menlo,Consolas,monospace; }
370
- .dot { fill:rgba(33,29,22,.78); }
371
- .dot-anchor { fill:var(--green); stroke:var(--ink); stroke-width:1.5; }
372
- .dot-label { fill:var(--ink); text-anchor:middle; letter-spacing:.04em; font-family:"SF Mono",Menlo,Consolas,monospace; }
373
- figcaption { font-size:12px; color:var(--ink-soft); padding:12px 16px 14px; font-style:italic; border-top:1px solid var(--line); line-height:1.5; }
374
- footer { margin-top:72px; border-top:3px double var(--ink); padding-top:14px; font-size:11px; color:var(--ink-soft);
454
+ .chip { font-size:9px; padding:2px 7px; border:1px solid currentColor; border-radius:2px; }
455
+ .chip-open { color:var(--accent); } .chip-contested { color:#8a6d1c; }
456
+ .chip-owned { color:var(--green); } .chip-saturated { color:var(--soft); } .chip-vacant { color:#9a9a9a; }
457
+ .ev { border-bottom:1px solid var(--line); padding:10px 0; }
458
+ .ev-head { font-size:10.5px; color:var(--soft); font-weight:600; }
459
+ .ev blockquote { margin:5px 0; font-size:13.5px; line-height:1.5; }
460
+ .ev-src { font-size:10px; color:var(--soft); word-break:break-all; }
461
+ figure.map { margin-top:16px; border:1px solid var(--line); }
462
+ .map-row { display:flex; gap:8px; align-items:flex-start; padding:10px 12px 0; }
463
+ .map-row svg { flex:1 1 62%; min-width:0; }
464
+ .plot { fill:var(--faint); stroke:var(--line); }
465
+ .grid { stroke:#d6d4cf; stroke-dasharray:3 5; }
466
+ .ax-label { fill:var(--soft); font-family:ui-monospace,"SF Mono",Menlo,Consolas,monospace; }
467
+ table.legend { flex:0 0 auto; width:auto; margin:8px 0 12px; font-size:12px; }
468
+ table.legend thead th { font-size:10px; color:var(--soft); font-weight:600; border-bottom:1px solid var(--line); padding:2px 8px 5px 0; text-align:left; }
469
+ table.legend td { padding:4px 8px 4px 0; border-bottom:1px solid var(--faint); white-space:nowrap; }
470
+ table.legend td.num, table.legend th.num { text-align:right; }
471
+ table.legend tr.anchor-row td { font-weight:700; }
472
+ .swatch { display:inline-flex; align-items:center; justify-content:center; width:20px; height:20px; border-radius:50%;
473
+ color:#fff; font-size:10.5px; font-weight:700; }
474
+ figcaption { font-size:11.5px; color:var(--soft); padding:10px 14px 12px; border-top:1px solid var(--line); line-height:1.5; }
475
+ footer { margin-top:60px; border-top:1px solid var(--ink); padding-top:12px; font-size:11px; color:var(--soft);
375
476
  display:flex; justify-content:space-between; gap:20px; flex-wrap:wrap; }
376
- @media print { body { max-width:none; padding:0 8mm; background:white; } section { break-inside:avoid-page; } tr { break-inside:avoid; } }
477
+ @media print { body { max-width:none; padding:0 8mm; } section { break-inside:avoid-page; } tr { break-inside:avoid; } .map-row { display:block; } }
478
+ @media (max-width:760px) { .map-row { display:block; } }
377
479
  </style></head><body>
378
480
  <header>
379
- <div class="kicker">Full Stack GTM · Market Map</div>
380
- <h1>The <em>${e(config.category.replace(/-/g, " "))}</em> front map</h1>
481
+ <h1>Market map ${e(config.category.replace(/-/g, " "))}</h1>
381
482
  <div class="meta">
382
- <span>RUN ${e(set.runLabel.toUpperCase())}</span><span>OBSERVED ${e(set.runAt)}</span>
383
- <span>${config.vendors.length} VENDORS · ${config.claims.length} CLAIMS · ${set.observations.length} READINGS</span>
384
- <span>${unobservable} UNOBSERVABLE · EXTRACTOR ${e(set.extractor)}</span>
483
+ <span>run ${e(set.runLabel)}</span><span>observed ${e(set.runAt)}</span>
484
+ <span>${config.vendors.length} vendors · ${config.claims.length} claims · ${set.observations.length} readings · ${unobservable} unobservable</span>
485
+ <span>extractor: ${e(set.extractor)}</span>
385
486
  </div>
386
- <div class="stamp">Field Report</div>
387
487
  </header>
388
488
  <section>
389
- <h2><span class="no">01</span> Front summary</h2>
489
+ <h2>Front summary</h2>
390
490
  <div class="fronts">
391
491
  <div class="fcard open"><b>${counts.open + counts.vacant}</b><span>Open / vacant</span></div>
392
492
  <div class="fcard"><b>${counts.contested}</b><span>Contested</span></div>
@@ -396,8 +496,8 @@ footer { margin-top:72px; border-top:3px double var(--ink); padding-top:14px; fo
396
496
  <ul class="openlist">${openList}</ul>
397
497
  </section>
398
498
  <section>
399
- <h2><span class="no">02</span> Claim × vendor intensity matrix</h2>
400
- <div class="legend">
499
+ <h2>Claim × vendor intensity matrix</h2>
500
+ <div class="key">
401
501
  <span class="lg"><i class="g g-loud"></i>LOUD — hero-level claim</span>
402
502
  <span class="lg"><i class="g g-quiet"></i>QUIET — shipped, buried</span>
403
503
  <span class="lg"><i class="g g-absent"></i>ABSENT</span>
@@ -410,7 +510,7 @@ footer { margin-top:72px; border-top:3px double var(--ink); padding-top:14px; fo
410
510
  </section>
411
511
  ${axisHtml.strategicMap}
412
512
  <section>
413
- <h2><span class="no">${appendixNo}</span> Evidence appendix</h2>
513
+ <h2>Evidence appendix</h2>
414
514
  ${appendix}
415
515
  </section>
416
516
  <footer>