fullstackgtm 0.21.0 → 0.21.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,47 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
5
5
  and the project adheres to [Semantic Versioning](https://semver.org/).
6
6
  The path to 1.0 is planned in [docs/roadmap-to-1.0.md](./docs/roadmap-to-1.0.md).
7
7
 
8
+ ## [0.21.2] — 2026-06-12
9
+
10
+ Scatter interactivity + honest sizing fallbacks.
11
+
12
+ ### Changed
13
+
14
+ - **Hover tooltips on the strategic map**: rich tooltip (vendor, both axis
15
+ positions, LOUD count, estimated revenue/share with signal spread) follows
16
+ the cursor; hovering a bubble raises it to the front, so a bubble born
17
+ fully underneath a bigger one is one mouse-over from visible; hovering
18
+ dims the rest. Legend rows cross-highlight their bubbles.
19
+ - **No more implied share**: without scaleSignals the map renders UNIFORM
20
+ dots with the caption "Dot size carries no meaning on this map" — the
21
+ LOUD-count sizing fallback is gone (a map owner read it as market share;
22
+ the legend still lists LOUD counts). With partial coverage (majority of
23
+ vendors estimated), scale mode holds and signal-less vendors render as
24
+ minimal DASHED bubbles — visibly "no measurable scale", never silently
25
+ resized; their legend row shows "—".
26
+ - Numbers move above bubbles too small to contain them.
27
+
28
+ ## [0.21.1] — 2026-06-12
29
+
30
+ Report design pass: analyst-grade restraint, legible dense scatters.
31
+
32
+ ### Changed
33
+
34
+ - **De-slopped the field report**: removed the rotated stamp, paper-grain
35
+ background, parchment palette, letterspaced kickers, and editorial hero
36
+ voice. White page, hairline rules, plain headings — the design recedes,
37
+ the data reads.
38
+ - **Strategic map legibility**: bubbles are now numbered and colored
39
+ (Okabe–Ito colorblind-safe palette) with a legend table beside the chart
40
+ that doubles as the share table (number · color · vendor · est. share or
41
+ LOUD count, anchor bolded). Larger bubbles render first so overlapping
42
+ clusters stay readable; the number resolves what overlapping name labels
43
+ never could. Hover tooltips keep the names.
44
+ - **Axis pole labels can no longer collide**: wrapped to ≤2 short lines
45
+ (parentheticals dropped first), x poles at the bottom corners, y poles
46
+ rotated along the left margin — four positions, standard chart
47
+ convention.
48
+
8
49
  ## [0.21.0] — 2026-06-12
9
50
 
10
51
  Scale estimation v2 — dimensional, calibrated, SMB-bias-robust.
@@ -79,10 +79,64 @@ export function marketMapToMarkdown(config, set) {
79
79
  }
80
80
  return `${lines.join("\n")}\n`;
81
81
  }
82
- function svgScatter(points, ax, ay, anchor, mini) {
83
- const W = mini ? 330 : 700;
84
- const H = mini ? 250 : 460;
85
- const PAD = mini ? 34 : 56;
82
+ /**
83
+ * Okabe–Ito palette: categorical, colorblind-safe, print-stable. Vendors are
84
+ * numbered in legend order so a dense cluster stays readable — the number in
85
+ * the bubble resolves what overlapping labels never could.
86
+ */
87
+ const VENDOR_COLORS = [
88
+ "#0072b2", "#e69f00", "#009e73", "#d55e00", "#cc79a7",
89
+ "#56b4e9", "#b8a000", "#717171", "#882255", "#44aa99",
90
+ ];
91
+ /** Dark numerals on light fills, white on dark — simple luminance cut. */
92
+ function numeralColor(hex) {
93
+ const r = parseInt(hex.slice(1, 3), 16);
94
+ const g = parseInt(hex.slice(3, 5), 16);
95
+ const b = parseInt(hex.slice(5, 7), 16);
96
+ return 0.299 * r + 0.587 * g + 0.114 * b > 150 ? "#1c1c1c" : "#ffffff";
97
+ }
98
+ /**
99
+ * Wrap an axis-pole label into at most two short lines. Parentheticals are
100
+ * dropped first — poles like "REGULATED LEAD-GEN OPERATIONS (sales-led PEO)"
101
+ * keep their head, and nothing ever runs into the opposite corner.
102
+ */
103
+ function wrapPole(text, maxChars = 20) {
104
+ let cleaned = text.replace(/\s*\([^)]*\)/g, "").trim();
105
+ if (cleaned.length === 0)
106
+ cleaned = text.trim();
107
+ const words = cleaned.split(/\s+/);
108
+ const lines = [];
109
+ let current = "";
110
+ for (const word of words) {
111
+ if (current && (current + " " + word).length > maxChars) {
112
+ lines.push(current);
113
+ current = word;
114
+ if (lines.length === 2)
115
+ break;
116
+ }
117
+ else {
118
+ current = current ? `${current} ${word}` : word;
119
+ }
120
+ }
121
+ if (lines.length < 2 && current)
122
+ lines.push(current);
123
+ if (lines.length === 2 && current && lines[1] !== current)
124
+ lines[1] = `${lines[1]}…`;
125
+ return lines;
126
+ }
127
+ function poleText(lines, x, y, anchorMode, fs) {
128
+ const e = escapeHtml;
129
+ const spans = lines
130
+ .map((line, index) => `<tspan x="${x}" dy="${index === 0 ? 0 : fs + 2}">${e(line)}</tspan>`)
131
+ .join("");
132
+ return `<text class="ax-label" style="font-size:${fs}px" x="${x}" y="${y}" text-anchor="${anchorMode}">${spans}</text>`;
133
+ }
134
+ function svgScatter(points, ax, ay, anchor, colorByVendor, numberByVendor) {
135
+ const W = 640;
136
+ const H = 480;
137
+ const PAD_X = 56;
138
+ const PAD_TOP = 44;
139
+ const PAD_BOTTOM = 56;
86
140
  const range = (axis, values) => {
87
141
  if (axis.signed)
88
142
  return [-1.1, 1.1];
@@ -92,28 +146,53 @@ function svgScatter(points, ax, ay, anchor, mini) {
92
146
  };
93
147
  const [xLo, xHi] = range(ax, points.map((p) => p.x));
94
148
  const [yLo, yHi] = range(ay, points.map((p) => p.y));
95
- const sx = (x) => PAD + ((x - xLo) / (xHi - xLo)) * (W - 2 * PAD);
96
- const sy = (y) => H - PAD - ((y - yLo) / (yHi - yLo)) * (H - 2 * PAD);
97
- const fsLabel = mini ? 8.5 : 10.5;
98
- const fsAx = mini ? 8 : 10;
149
+ const sx = (x) => PAD_X + ((x - xLo) / (xHi - xLo)) * (W - 2 * PAD_X);
150
+ const sy = (y) => H - PAD_BOTTOM - ((y - yLo) / (yHi - yLo)) * (H - PAD_TOP - PAD_BOTTOM);
99
151
  const e = escapeHtml;
100
- const dots = points
152
+ // Big bubbles first so small ones stay clickable/visible on top; hover
153
+ // raises a bubble to the front (JS re-appends its <g>), so even a bubble
154
+ // born fully underneath a bigger one is one mouse-over from visible.
155
+ const ordered = [...points].sort((a, b) => b.size - a.size);
156
+ const dots = ordered
101
157
  .map((p) => {
102
- // Area-proportional: perceived bubble area tracks the size metric.
103
- const r = (mini ? 4 + 14 * Math.sqrt(p.size) : 8 + 26 * Math.sqrt(p.size));
104
- const cls = p.vendorId === anchor ? "dot-anchor" : "dot";
105
- return (`<circle class="${cls}" cx="${sx(p.x).toFixed(1)}" cy="${sy(p.y).toFixed(1)}" r="${r.toFixed(1)}"/>` +
106
- `<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>`);
158
+ const r = p.noScale ? 7 : 7 + 24 * Math.sqrt(p.size);
159
+ const color = colorByVendor.get(p.vendorId) ?? "#717171";
160
+ const number = numberByVendor.get(p.vendorId) ?? 0;
161
+ const cx = sx(p.x).toFixed(1);
162
+ const cy = sy(p.y);
163
+ const ring = p.vendorId === anchor ? ` stroke="#1c1c1c" stroke-width="2.5"` : ` stroke="#ffffff" stroke-width="1.5"`;
164
+ // No measurable scale: minimal dashed outline — visibly "no data", never a guess.
165
+ const fill = p.noScale ? ` fill="${color}" fill-opacity="0.2" stroke="${color}" stroke-width="1.5" stroke-dasharray="3 2"` : ` fill="${color}" fill-opacity="0.78"${ring}`;
166
+ // Numbers go inside when they fit, above the bubble when they don't.
167
+ const fs = Math.max(10, Math.min(14, r * 0.9));
168
+ const numberSvg = r >= 10 && !p.noScale
169
+ ? `<text x="${cx}" y="${(cy + fs * 0.36).toFixed(1)}" text-anchor="middle" font-size="${fs.toFixed(1)}" font-weight="700" fill="${numeralColor(color)}" style="pointer-events:none">${number}</text>`
170
+ : `<text x="${cx}" y="${(cy - r - 3).toFixed(1)}" text-anchor="middle" font-size="10" font-weight="700" fill="${color}" style="pointer-events:none">${number}</text>`;
171
+ return `<g class="bubble${p.noScale ? " no-scale" : ""}" data-v="${e(p.vendorId)}"><circle cx="${cx}" cy="${cy.toFixed(1)}" r="${r.toFixed(1)}"${fill}/>${numberSvg}</g>`;
107
172
  })
108
173
  .join("");
109
- const midX = ax.signed ? `<line class="axis-mid" x1="${sx(0).toFixed(0)}" y1="${PAD}" x2="${sx(0).toFixed(0)}" y2="${H - PAD}"/>` : "";
110
- const midY = ay.signed ? `<line class="axis-mid" x1="${PAD}" y1="${sy(0).toFixed(0)}" x2="${W - PAD}" y2="${sy(0).toFixed(0)}"/>` : "";
174
+ const midX = ax.signed ? `<line class="grid" x1="${sx(0).toFixed(0)}" y1="${PAD_TOP}" x2="${sx(0).toFixed(0)}" y2="${H - PAD_BOTTOM}"/>` : "";
175
+ const midY = ay.signed ? `<line class="grid" x1="${PAD_X}" y1="${sy(0).toFixed(0)}" x2="${W - PAD_X}" y2="${sy(0).toFixed(0)}"/>` : "";
176
+ // Pole labels in four positions that cannot collide: x poles horizontal at
177
+ // the bottom corners; y poles rotated along the left margin (positive
178
+ // reading up toward the top, negative near the bottom) — the standard
179
+ // chart convention, wrapped to ≤2 short lines each.
180
+ const fsPole = 10;
181
+ const xNeg = poleText(wrapPole(ax.negativePole), PAD_X, H - PAD_BOTTOM + 20, "start", fsPole);
182
+ const xPos = poleText(wrapPole(ax.positivePole), W - PAD_X, H - PAD_BOTTOM + 20, "end", fsPole);
183
+ const yLabel = (lines, yEdge, anchorMode) => {
184
+ const spans = lines
185
+ .map((line, index) => `<tspan x="${-yEdge}" dy="${index === 0 ? 0 : fsPole + 2}">${e(line)}</tspan>`)
186
+ .join("");
187
+ return `<text class="ax-label" style="font-size:${fsPole}px" transform="rotate(-90)" x="${-yEdge}" y="16" text-anchor="${anchorMode}">${spans}</text>`;
188
+ };
189
+ const yPos = yLabel(wrapPole(ay.positivePole, 26), PAD_TOP, "end");
190
+ const yNeg = ay.signed ? yLabel(wrapPole(ay.negativePole, 26), H - PAD_BOTTOM, "start") : "";
111
191
  return `<svg viewBox="0 0 ${W} ${H}" role="img" aria-label="${e(ax.label)} vs ${e(ay.label)}">
112
- <line class="axis" x1="${PAD}" y1="${H - PAD}" x2="${W - PAD}" y2="${H - PAD}"/>
113
- <line class="axis" x1="${PAD}" y1="${PAD}" x2="${PAD}" y2="${H - PAD}"/>${midX}${midY}
114
- <text class="ax-label" style="font-size:${fsAx}px" x="${PAD}" y="${H - 14}">&#8592; ${e(ax.negativePole)}</text>
115
- <text class="ax-label" style="font-size:${fsAx}px" x="${W - PAD}" y="${H - 14}" text-anchor="end">${e(ax.positivePole)} &#8594;</text>
116
- <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>
192
+ <rect x="${PAD_X}" y="${PAD_TOP}" width="${W - 2 * PAD_X}" height="${H - PAD_TOP - PAD_BOTTOM}" class="plot"/>
193
+ ${midX}${midY}
194
+ ${xNeg}${xPos}
195
+ ${yPos}${yNeg}
117
196
  ${dots}</svg>`;
118
197
  }
119
198
  function axisSectionsHtml(config, set) {
@@ -127,17 +206,27 @@ function axisSectionsHtml(config, set) {
127
206
  // when every placeable vendor has one; LOUD count otherwise — never mix
128
207
  // the two semantics on one chart.
129
208
  const scale = computeScaleIndex(config);
209
+ const scaleByVendor = new Map(scale.vendors.map((vendor) => [vendor.vendorId, vendor]));
130
210
  const scaleIndex = new Map(scale.vendors.map((vendor) => [vendor.vendorId, vendor.index]));
131
- const useScale = report.vendors.length > 0 && report.vendors.every((vendorId) => scaleIndex.get(vendorId) !== null && scaleIndex.get(vendorId) !== undefined);
211
+ const estimated = report.vendors.filter((vendorId) => typeof scaleIndex.get(vendorId) === "number");
212
+ // Scale mode needs a real majority of vendors estimated; the stragglers
213
+ // (idea-stage anchors, signal-less vendors) render as minimal dashed
214
+ // bubbles — visibly "no measurable scale", never silently re-sized.
215
+ const useScale = estimated.length >= 2 && estimated.length * 2 >= report.vendors.length;
132
216
  const loudCounts = new Map(report.vendors.map((vendorId) => [vendorId, messageBreadth(vendorId, set.observations).loudCount]));
133
- const maxLoud = Math.max(1, ...loudCounts.values());
134
217
  // Bubble areas stay proportional to the metric; dividing by the max just
135
218
  // spends the full visual range without distorting any ratio.
136
219
  const maxShare = Math.max(1e-9, ...report.vendors.map((vendorId) => scaleIndex.get(vendorId) ?? 0));
137
- const sizeOf = (vendorId) => useScale ? scaleIndex.get(vendorId) / maxShare : (loudCounts.get(vendorId) ?? 0) / maxLoud;
220
+ const sizeOf = (vendorId) => {
221
+ if (!useScale)
222
+ return 0.14; // uniform: size carries NO meaning without scale signals
223
+ const share = scaleIndex.get(vendorId);
224
+ return typeof share === "number" ? share / maxShare : 0;
225
+ };
226
+ const noScaleFor = (vendorId) => useScale && typeof scaleIndex.get(vendorId) !== "number";
138
227
  const sizeCaption = useScale
139
- ? `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)`
140
- : "Dot area &#8733; LOUD count";
228
+ ? `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). Dashed outline = no measurable scale signals.`
229
+ : "Dot size carries no meaning on this map (no scaleSignals in the config); the legend lists LOUD counts";
141
230
  const breadthAxis = {
142
231
  id: "breadth",
143
232
  label: "Message breadth",
@@ -175,15 +264,102 @@ function axisSectionsHtml(config, set) {
175
264
  x: xs.get(vendorId),
176
265
  y: ys.get(vendorId),
177
266
  size: sizeOf(vendorId),
267
+ noScale: noScaleFor(vendorId),
178
268
  }));
179
269
  };
180
270
  const [px, py] = config.primaryAxes ?? [axes[0].id, axes[1]?.id ?? "breadth"];
181
271
  const axInfo = axisInfo.get(px);
182
272
  const ayInfo = axisInfo.get(py);
183
273
  const statusOf = (id) => axes.find((axis) => axis.id === id)?.status ?? (id === "breadth" ? "derived" : "");
274
+ // Legend order doubles as bubble numbering: largest first, anchor bolded.
275
+ // The number inside each bubble resolves dense clusters that name labels
276
+ // never could; color is Okabe–Ito (colorblind-safe) keyed in the legend.
277
+ const points = pointsFor(px, py);
278
+ const legendOrder = [...points].sort((a, b) => b.size - a.size || a.name.localeCompare(b.name));
279
+ const numberByVendor = new Map(legendOrder.map((point, index) => [point.vendorId, index + 1]));
280
+ const colorByVendor = new Map(legendOrder.map((point, index) => [point.vendorId, VENDOR_COLORS[index % VENDOR_COLORS.length]]));
281
+ const legendRows = legendOrder
282
+ .map((point) => {
283
+ const number = numberByVendor.get(point.vendorId);
284
+ const color = colorByVendor.get(point.vendorId);
285
+ const isAnchor = point.vendorId === config.anchorVendor;
286
+ const share = scaleIndex.get(point.vendorId);
287
+ const measure = useScale
288
+ ? typeof share === "number"
289
+ ? `${(share * 100).toFixed(1)}%`
290
+ : "—"
291
+ : `${loudCounts.get(point.vendorId) ?? 0} loud`;
292
+ return `<tr data-v="${e(point.vendorId)}"${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>`;
293
+ })
294
+ .join("");
295
+ const legendMeasureHead = useScale ? "est. share" : "loud";
296
+ const money = (value) => value >= 1e9 ? `$${(value / 1e9).toFixed(1)}B` : value >= 1e6 ? `$${(value / 1e6).toFixed(1)}M` : `$${Math.round(value / 1e3)}K`;
297
+ const tipData = {};
298
+ for (const point of points) {
299
+ const vendorScale = scaleByVendor.get(point.vendorId);
300
+ const lines = [
301
+ `${axInfo.label}: ${point.x.toFixed(2)}`,
302
+ `${ayInfo.label}: ${point.y.toFixed(2)}`,
303
+ `LOUD claims: ${loudCounts.get(point.vendorId) ?? 0}`,
304
+ ];
305
+ if (useScale) {
306
+ if (vendorScale?.estimatedRevenue != null) {
307
+ lines.push(`est. revenue: ~${money(vendorScale.estimatedRevenue)} (${((scaleIndex.get(point.vendorId) ?? 0) * 100).toFixed(1)}% of set${vendorScale.uncertainty && vendorScale.uncertainty > 1 ? `, ×${vendorScale.uncertainty.toFixed(1)} signal spread` : ""})`);
308
+ }
309
+ else {
310
+ lines.push("est. revenue: no measurable scale signals");
311
+ }
312
+ }
313
+ tipData[point.vendorId] = { name: point.name, n: numberByVendor.get(point.vendorId) ?? 0, lines };
314
+ }
184
315
  const strategicMap = `<section>
185
- <h2><span class="no">03</span> Strategic map ${e(axInfo.label)} &#215; ${e(ayInfo.label)}</h2>
186
- <figure>${svgScatter(pointsFor(px, py), axInfo, ayInfo, config.anchorVendor, false)}
316
+ <h2>Strategic map: ${e(axInfo.label)} &#215; ${e(ayInfo.label)}</h2>
317
+ <figure class="map">
318
+ <div class="map-row">
319
+ ${svgScatter(points, axInfo, ayInfo, config.anchorVendor, colorByVendor, numberByVendor)}
320
+ <table class="legend"><thead><tr><th></th><th>vendor</th><th class="num">${legendMeasureHead}</th></tr></thead><tbody>${legendRows}</tbody></table>
321
+ </div>
322
+ <div class="map-tip" id="map-tip" hidden></div>
323
+ <script type="application/json" id="map-data">${JSON.stringify(tipData)}</script>
324
+ <script>
325
+ (function () {
326
+ var data = JSON.parse(document.getElementById("map-data").textContent);
327
+ var tip = document.getElementById("map-tip");
328
+ var fig = tip.closest("figure");
329
+ var bubbles = fig.querySelectorAll("g.bubble");
330
+ var rows = fig.querySelectorAll("table.legend tbody tr");
331
+ function show(v, evt) {
332
+ var d = data[v];
333
+ if (!d) return;
334
+ tip.innerHTML = "<b>" + d.n + " · " + d.name + "</b>" + d.lines.map(function (l) { return "<div>" + l + "</div>"; }).join("");
335
+ tip.hidden = false;
336
+ var box = fig.getBoundingClientRect();
337
+ tip.style.left = Math.min(evt.clientX - box.left + 14, box.width - tip.offsetWidth - 8) + "px";
338
+ tip.style.top = (evt.clientY - box.top + 14) + "px";
339
+ }
340
+ function focusOn(v) {
341
+ bubbles.forEach(function (b) {
342
+ b.classList.toggle("dim", b.getAttribute("data-v") !== v);
343
+ if (b.getAttribute("data-v") === v) b.parentNode.appendChild(b); // raise hidden bubbles
344
+ });
345
+ rows.forEach(function (r) { r.classList.toggle("hl", r.getAttribute("data-v") === v); });
346
+ }
347
+ function clear() {
348
+ tip.hidden = true;
349
+ bubbles.forEach(function (b) { b.classList.remove("dim"); });
350
+ rows.forEach(function (r) { r.classList.remove("hl"); });
351
+ }
352
+ bubbles.forEach(function (b) {
353
+ b.addEventListener("mousemove", function (evt) { show(b.getAttribute("data-v"), evt); });
354
+ b.addEventListener("mouseenter", function () { focusOn(b.getAttribute("data-v")); });
355
+ b.addEventListener("mouseleave", clear);
356
+ });
357
+ rows.forEach(function (r) {
358
+ r.addEventListener("mouseenter", function () { focusOn(r.getAttribute("data-v")); });
359
+ r.addEventListener("mouseleave", clear);
360
+ });
361
+ })();
362
+ </script>
187
363
  <figcaption>Positions computed from run ${e(set.runLabel)} observations: each axis is a per-claim scoring rubric
188
364
  in the market config; a vendor sits at the intensity-weighted mean (loud=1, quiet=&#189;) of the claims it
189
365
  voices. ${sizeCaption}. Axis status — ${e(axInfo.label)}: ${e(statusOf(px))}; ${e(ayInfo.label)}: ${e(statusOf(py))}.</figcaption>
@@ -206,7 +382,6 @@ export function marketMapToHtml(config, set) {
206
382
  const anchor = config.anchorVendor;
207
383
  const e = escapeHtml;
208
384
  const axisHtml = axisSectionsHtml(config, set);
209
- const appendixNo = axisHtml.report ? "04" : "03";
210
385
  const matrixRows = model.orderedClaimIds
211
386
  .map((claimId) => {
212
387
  const claim = claimsById.get(claimId);
@@ -253,81 +428,86 @@ export function marketMapToHtml(config, set) {
253
428
  <meta name="viewport" content="width=device-width, initial-scale=1">
254
429
  <title>Market map — ${e(config.category)} — ${e(set.runLabel)}</title>
255
430
  <style>
256
- :root { --paper:#f4efe4; --ink:#211d16; --ink-soft:#5a5244; --line:#c9bfa9; --accent:#b4441b; --green:#2e5339; --quiet:#8a7d63; }
431
+ :root { --ink:#1c1c1c; --soft:#6b6b6b; --line:#e3e1dc; --faint:#f7f6f4; --accent:#b3491f; --green:#2e5e43; }
257
432
  * { box-sizing:border-box; margin:0; }
258
- body { font-family:"Iowan Old Style","Palatino Linotype",Palatino,Georgia,serif; color:var(--ink); background:var(--paper);
259
- max-width:1080px; margin:0 auto; padding:0 48px 96px;
260
- background-image:radial-gradient(rgba(33,29,22,.028) 1px, transparent 1.2px); background-size:5px 5px; }
261
- .chip,.claim-meta,.ev-src,.lg,.stamp,.meta,th.vh span { font-family:"SF Mono",Menlo,Consolas,monospace; }
262
- header { padding:56px 0 28px; border-bottom:3px double var(--ink); position:relative; }
263
- .kicker { font-size:11px; letter-spacing:.32em; color:var(--accent); text-transform:uppercase; }
264
- h1 { font-size:44px; line-height:1.05; font-weight:600; margin:10px 0 6px; }
265
- h1 em { font-style:italic; color:var(--green); }
266
- .meta { font-size:11.5px; color:var(--ink-soft); display:flex; gap:24px; flex-wrap:wrap; margin-top:14px; }
267
- .stamp { position:absolute; right:0; top:58px; border:2px solid var(--accent); color:var(--accent); padding:7px 13px;
268
- font-size:11px; letter-spacing:.22em; transform:rotate(3.5deg); text-transform:uppercase; }
269
- section { margin-top:56px; }
270
- h2 { font-size:13px; letter-spacing:.26em; text-transform:uppercase; color:var(--ink-soft);
271
- border-bottom:1px solid var(--line); padding-bottom:9px; display:flex; gap:14px; align-items:baseline; }
272
- h2 .no { color:var(--accent); font-style:italic; font-size:15px; letter-spacing:0; }
273
- .fronts { display:grid; grid-template-columns:repeat(4,1fr); gap:1px; background:var(--line); border:1px solid var(--line); margin-top:22px; }
274
- .fcard { background:var(--paper); padding:18px 18px 14px; }
275
- .fcard b { display:block; font-size:42px; font-weight:600; line-height:1; }
276
- .fcard span { font-size:11px; letter-spacing:.18em; text-transform:uppercase; color:var(--ink-soft); }
433
+ body { font-family:"Iowan Old Style","Palatino Linotype",Palatino,Georgia,serif; color:var(--ink); background:#fff;
434
+ max-width:1060px; margin:0 auto; padding:0 44px 80px; font-size:15px; line-height:1.5; }
435
+ .mono,.claim-meta,.ev-src,.key,.meta,th.vh span,.chip,.legend { font-family:ui-monospace,"SF Mono",Menlo,Consolas,monospace; }
436
+ header { padding:44px 0 20px; border-bottom:1px solid var(--ink); }
437
+ h1 { font-size:27px; font-weight:600; line-height:1.2; }
438
+ .meta { font-size:11px; color:var(--soft); display:flex; gap:18px; flex-wrap:wrap; margin-top:8px; }
439
+ section { margin-top:44px; }
440
+ h2 { font-size:17px; font-weight:600; border-bottom:1px solid var(--line); padding-bottom:7px; margin-bottom:4px; }
441
+ .fronts { display:grid; grid-template-columns:repeat(4,1fr); gap:1px; background:var(--line); border:1px solid var(--line); margin-top:16px; }
442
+ .fcard { background:#fff; padding:14px 16px 12px; }
443
+ .fcard b { display:block; font-size:30px; font-weight:600; line-height:1.1; }
444
+ .fcard span { font-size:11px; color:var(--soft); }
277
445
  .fcard.open b { color:var(--accent); }
278
- .openlist { margin-top:18px; font-size:15.5px; line-height:1.55; }
279
- .openlist li { margin:4px 0 4px 20px; }
280
- .openlist .why { color:var(--ink-soft); font-size:13px; font-style:italic; }
281
- .legend { display:flex; gap:22px; flex-wrap:wrap; margin:18px 0 10px; font-size:10.5px; color:var(--ink-soft); }
282
- .lg { display:inline-flex; align-items:center; gap:7px; }
446
+ .openlist { margin-top:14px; font-size:14.5px; line-height:1.55; }
447
+ .openlist li { margin:3px 0 3px 20px; }
448
+ .openlist .why { color:var(--soft); font-size:13px; }
449
+ .key { display:flex; gap:20px; flex-wrap:wrap; margin:14px 0 8px; font-size:10.5px; color:var(--soft); }
450
+ .lg { display:inline-flex; align-items:center; gap:6px; }
283
451
  table { border-collapse:collapse; width:100%; margin-top:6px; }
284
- thead th { border-bottom:2px solid var(--ink); padding:6px 2px 10px; }
285
- th.vh span { writing-mode:vertical-rl; transform:rotate(195deg); font-size:10.5px; letter-spacing:.12em;
286
- text-transform:uppercase; color:var(--ink-soft); display:inline-block; }
452
+ thead th { border-bottom:1.5px solid var(--ink); padding:6px 2px 8px; font-weight:600; }
453
+ th.vh span { writing-mode:vertical-rl; transform:rotate(195deg); font-size:10.5px; color:var(--soft); display:inline-block; }
287
454
  th.vh.anchor-col span { color:var(--green); font-weight:700; }
288
- tbody th { text-align:left; font-weight:400; padding:7px 14px 7px 0; border-bottom:1px solid var(--line); max-width:330px; }
289
- .claim-cap { display:block; font-size:14.5px; }
290
- .claim-meta { display:block; font-size:9.5px; color:var(--quiet); letter-spacing:.08em; margin-top:2px; }
455
+ tbody th { text-align:left; font-weight:400; padding:6px 14px 6px 0; border-bottom:1px solid var(--line); max-width:330px; }
456
+ .claim-cap { display:block; font-size:14px; }
457
+ .claim-meta { display:block; font-size:9.5px; color:var(--soft); margin-top:1px; }
291
458
  td.cell { text-align:center; border-bottom:1px solid var(--line); padding:4px 2px; }
292
- td.cell.anchor-col { background:rgba(46,83,57,.06); }
459
+ td.cell.anchor-col { background:var(--faint); }
293
460
  td.front { border-bottom:1px solid var(--line); text-align:right; white-space:nowrap; }
294
- .g { display:inline-block; width:15px; height:15px; vertical-align:middle; }
461
+ .g { display:inline-block; width:14px; height:14px; vertical-align:middle; }
295
462
  .g-loud { background:var(--ink); }
296
- .g-quiet { box-shadow:inset 0 0 0 2px var(--quiet); }
297
- .g-absent { background:radial-gradient(circle at center, var(--line) 0 2.5px, transparent 3px); }
298
- .g-unobservable { background:repeating-linear-gradient(45deg, var(--line) 0 2px, transparent 2px 5px); }
463
+ .g-quiet { box-shadow:inset 0 0 0 2px #9a9a9a; }
464
+ .g-absent { background:radial-gradient(circle at center, #cfcdc8 0 2.5px, transparent 3px); }
465
+ .g-unobservable { background:repeating-linear-gradient(45deg, #cfcdc8 0 2px, transparent 2px 5px); }
299
466
  tr.front-open th .claim-cap { color:var(--accent); font-weight:600; }
300
- .chip { font-size:9px; letter-spacing:.16em; padding:3px 8px; border:1px solid currentColor; }
301
- .chip-open { color:var(--accent); } .chip-contested { color:#7a5a12; }
302
- .chip-owned { color:var(--green); } .chip-saturated { color:var(--ink-soft); } .chip-vacant { color:var(--quiet); }
303
- .ev { border-bottom:1px solid var(--line); padding:12px 0; }
304
- .ev-head { font-size:10.5px; letter-spacing:.1em; color:var(--accent); }
305
- .ev blockquote { font-style:italic; margin:6px 0; font-size:13.5px; line-height:1.5; }
306
- .ev-src { font-size:10px; color:var(--ink-soft); word-break:break-all; }
307
- figure { margin-top:22px; border:1px solid var(--line); background:rgba(255,255,255,.35); }
308
- .axis { stroke:var(--ink); stroke-width:1.5; }
309
- .axis-mid { stroke:var(--line); stroke-dasharray:3 5; }
310
- .ax-label { letter-spacing:.16em; fill:var(--ink-soft); font-family:"SF Mono",Menlo,Consolas,monospace; }
311
- .dot { fill:rgba(33,29,22,.78); }
312
- .dot-anchor { fill:var(--green); stroke:var(--ink); stroke-width:1.5; }
313
- .dot-label { fill:var(--ink); text-anchor:middle; letter-spacing:.04em; font-family:"SF Mono",Menlo,Consolas,monospace; }
314
- 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; }
315
- footer { margin-top:72px; border-top:3px double var(--ink); padding-top:14px; font-size:11px; color:var(--ink-soft);
467
+ .chip { font-size:9px; padding:2px 7px; border:1px solid currentColor; border-radius:2px; }
468
+ .chip-open { color:var(--accent); } .chip-contested { color:#8a6d1c; }
469
+ .chip-owned { color:var(--green); } .chip-saturated { color:var(--soft); } .chip-vacant { color:#9a9a9a; }
470
+ .ev { border-bottom:1px solid var(--line); padding:10px 0; }
471
+ .ev-head { font-size:10.5px; color:var(--soft); font-weight:600; }
472
+ .ev blockquote { margin:5px 0; font-size:13.5px; line-height:1.5; }
473
+ .ev-src { font-size:10px; color:var(--soft); word-break:break-all; }
474
+ figure.map { margin-top:16px; border:1px solid var(--line); position:relative; }
475
+ g.bubble { cursor:pointer; }
476
+ g.bubble.dim { opacity:0.25; transition:opacity .12s; }
477
+ table.legend tbody tr { cursor:default; }
478
+ table.legend tbody tr.hl td { background:var(--faint); }
479
+ .map-tip { position:absolute; z-index:5; background:#1c1c1c; color:#fff; font-size:11.5px; line-height:1.45;
480
+ padding:8px 10px; border-radius:3px; pointer-events:none; max-width:260px;
481
+ font-family:ui-monospace,"SF Mono",Menlo,Consolas,monospace; }
482
+ .map-tip b { display:block; margin-bottom:3px; }
483
+ .map-row { display:flex; gap:8px; align-items:flex-start; padding:10px 12px 0; }
484
+ .map-row svg { flex:1 1 62%; min-width:0; }
485
+ .plot { fill:var(--faint); stroke:var(--line); }
486
+ .grid { stroke:#d6d4cf; stroke-dasharray:3 5; }
487
+ .ax-label { fill:var(--soft); font-family:ui-monospace,"SF Mono",Menlo,Consolas,monospace; }
488
+ table.legend { flex:0 0 auto; width:auto; margin:8px 0 12px; font-size:12px; }
489
+ 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; }
490
+ table.legend td { padding:4px 8px 4px 0; border-bottom:1px solid var(--faint); white-space:nowrap; }
491
+ table.legend td.num, table.legend th.num { text-align:right; }
492
+ table.legend tr.anchor-row td { font-weight:700; }
493
+ .swatch { display:inline-flex; align-items:center; justify-content:center; width:20px; height:20px; border-radius:50%;
494
+ color:#fff; font-size:10.5px; font-weight:700; }
495
+ figcaption { font-size:11.5px; color:var(--soft); padding:10px 14px 12px; border-top:1px solid var(--line); line-height:1.5; }
496
+ footer { margin-top:60px; border-top:1px solid var(--ink); padding-top:12px; font-size:11px; color:var(--soft);
316
497
  display:flex; justify-content:space-between; gap:20px; flex-wrap:wrap; }
317
- @media print { body { max-width:none; padding:0 8mm; background:white; } section { break-inside:avoid-page; } tr { break-inside:avoid; } }
498
+ @media print { body { max-width:none; padding:0 8mm; } section { break-inside:avoid-page; } tr { break-inside:avoid; } .map-row { display:block; } .map-tip { display:none; } }
499
+ @media (max-width:760px) { .map-row { display:block; } }
318
500
  </style></head><body>
319
501
  <header>
320
- <div class="kicker">Full Stack GTM · Market Map</div>
321
- <h1>The <em>${e(config.category.replace(/-/g, " "))}</em> front map</h1>
502
+ <h1>Market map ${e(config.category.replace(/-/g, " "))}</h1>
322
503
  <div class="meta">
323
- <span>RUN ${e(set.runLabel.toUpperCase())}</span><span>OBSERVED ${e(set.runAt)}</span>
324
- <span>${config.vendors.length} VENDORS · ${config.claims.length} CLAIMS · ${set.observations.length} READINGS</span>
325
- <span>${unobservable} UNOBSERVABLE · EXTRACTOR ${e(set.extractor)}</span>
504
+ <span>run ${e(set.runLabel)}</span><span>observed ${e(set.runAt)}</span>
505
+ <span>${config.vendors.length} vendors · ${config.claims.length} claims · ${set.observations.length} readings · ${unobservable} unobservable</span>
506
+ <span>extractor: ${e(set.extractor)}</span>
326
507
  </div>
327
- <div class="stamp">Field Report</div>
328
508
  </header>
329
509
  <section>
330
- <h2><span class="no">01</span> Front summary</h2>
510
+ <h2>Front summary</h2>
331
511
  <div class="fronts">
332
512
  <div class="fcard open"><b>${counts.open + counts.vacant}</b><span>Open / vacant</span></div>
333
513
  <div class="fcard"><b>${counts.contested}</b><span>Contested</span></div>
@@ -337,8 +517,8 @@ footer { margin-top:72px; border-top:3px double var(--ink); padding-top:14px; fo
337
517
  <ul class="openlist">${openList}</ul>
338
518
  </section>
339
519
  <section>
340
- <h2><span class="no">02</span> Claim × vendor intensity matrix</h2>
341
- <div class="legend">
520
+ <h2>Claim × vendor intensity matrix</h2>
521
+ <div class="key">
342
522
  <span class="lg"><i class="g g-loud"></i>LOUD — hero-level claim</span>
343
523
  <span class="lg"><i class="g g-quiet"></i>QUIET — shipped, buried</span>
344
524
  <span class="lg"><i class="g g-absent"></i>ABSENT</span>
@@ -351,7 +531,7 @@ footer { margin-top:72px; border-top:3px double var(--ink); padding-top:14px; fo
351
531
  </section>
352
532
  ${axisHtml.strategicMap}
353
533
  <section>
354
- <h2><span class="no">${appendixNo}</span> Evidence appendix</h2>
534
+ <h2>Evidence appendix</h2>
355
535
  ${appendix}
356
536
  </section>
357
537
  <footer>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fullstackgtm",
3
- "version": "0.21.0",
3
+ "version": "0.21.2",
4
4
  "description": "Open-source agentic GTM ops framework: canonical GTM data model, pluggable deterministic audits, reviewable dry-run patch plans, approval-gated write-back with conflict detection, and cross-system entity resolution. HubSpot, Salesforce, and Stripe connectors included.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Full Stack GTM",
@@ -107,19 +107,73 @@ export function marketMapToMarkdown(config: MarketConfig, set: ObservationSet):
107
107
  }
108
108
 
109
109
  /** size is normalized [0,1]; rendered area-proportionally (radius ∝ √size). */
110
- type ScatterPoint = { vendorId: string; name: string; x: number; y: number; size: number };
110
+ type ScatterPoint = { vendorId: string; name: string; x: number; y: number; size: number; noScale?: boolean };
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,58 @@ 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; hover
189
+ // raises a bubble to the front (JS re-appends its <g>), so even a bubble
190
+ // born fully underneath a bigger one is one mouse-over from visible.
191
+ const ordered = [...points].sort((a, b) => b.size - a.size);
192
+ const dots = ordered
136
193
  .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";
140
- 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>`
143
- );
194
+ const r = p.noScale ? 7 : 7 + 24 * Math.sqrt(p.size);
195
+ const color = colorByVendor.get(p.vendorId) ?? "#717171";
196
+ const number = numberByVendor.get(p.vendorId) ?? 0;
197
+ const cx = sx(p.x).toFixed(1);
198
+ const cy = sy(p.y);
199
+ const ring = p.vendorId === anchor ? ` stroke="#1c1c1c" stroke-width="2.5"` : ` stroke="#ffffff" stroke-width="1.5"`;
200
+ // No measurable scale: minimal dashed outline — visibly "no data", never a guess.
201
+ const fill = p.noScale ? ` fill="${color}" fill-opacity="0.2" stroke="${color}" stroke-width="1.5" stroke-dasharray="3 2"` : ` fill="${color}" fill-opacity="0.78"${ring}`;
202
+ // Numbers go inside when they fit, above the bubble when they don't.
203
+ const fs = Math.max(10, Math.min(14, r * 0.9));
204
+ const numberSvg =
205
+ r >= 10 && !p.noScale
206
+ ? `<text x="${cx}" y="${(cy + fs * 0.36).toFixed(1)}" text-anchor="middle" font-size="${fs.toFixed(1)}" font-weight="700" fill="${numeralColor(color)}" style="pointer-events:none">${number}</text>`
207
+ : `<text x="${cx}" y="${(cy - r - 3).toFixed(1)}" text-anchor="middle" font-size="10" font-weight="700" fill="${color}" style="pointer-events:none">${number}</text>`;
208
+ return `<g class="bubble${p.noScale ? " no-scale" : ""}" data-v="${e(p.vendorId)}"><circle cx="${cx}" cy="${cy.toFixed(1)}" r="${r.toFixed(1)}"${fill}/>${numberSvg}</g>`;
144
209
  })
145
210
  .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)}"/>` : "";
211
+
212
+ const midX = ax.signed ? `<line class="grid" x1="${sx(0).toFixed(0)}" y1="${PAD_TOP}" x2="${sx(0).toFixed(0)}" y2="${H - PAD_BOTTOM}"/>` : "";
213
+ const midY = ay.signed ? `<line class="grid" x1="${PAD_X}" y1="${sy(0).toFixed(0)}" x2="${W - PAD_X}" y2="${sy(0).toFixed(0)}"/>` : "";
214
+
215
+ // Pole labels in four positions that cannot collide: x poles horizontal at
216
+ // the bottom corners; y poles rotated along the left margin (positive
217
+ // reading up toward the top, negative near the bottom) — the standard
218
+ // chart convention, wrapped to ≤2 short lines each.
219
+ const fsPole = 10;
220
+ const xNeg = poleText(wrapPole(ax.negativePole), PAD_X, H - PAD_BOTTOM + 20, "start", fsPole);
221
+ const xPos = poleText(wrapPole(ax.positivePole), W - PAD_X, H - PAD_BOTTOM + 20, "end", fsPole);
222
+ const yLabel = (lines: string[], yEdge: number, anchorMode: "start" | "end") => {
223
+ const spans = lines
224
+ .map((line, index) => `<tspan x="${-yEdge}" dy="${index === 0 ? 0 : fsPole + 2}">${e(line)}</tspan>`)
225
+ .join("");
226
+ return `<text class="ax-label" style="font-size:${fsPole}px" transform="rotate(-90)" x="${-yEdge}" y="16" text-anchor="${anchorMode}">${spans}</text>`;
227
+ };
228
+ const yPos = yLabel(wrapPole(ay.positivePole, 26), PAD_TOP, "end");
229
+ const yNeg = ay.signed ? yLabel(wrapPole(ay.negativePole, 26), H - PAD_BOTTOM, "start") : "";
230
+
148
231
  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>
232
+ <rect x="${PAD_X}" y="${PAD_TOP}" width="${W - 2 * PAD_X}" height="${H - PAD_TOP - PAD_BOTTOM}" class="plot"/>
233
+ ${midX}${midY}
234
+ ${xNeg}${xPos}
235
+ ${yPos}${yNeg}
154
236
  ${dots}</svg>`;
155
237
  }
156
238
 
@@ -168,18 +250,26 @@ function axisSectionsHtml(
168
250
  // when every placeable vendor has one; LOUD count otherwise — never mix
169
251
  // the two semantics on one chart.
170
252
  const scale = computeScaleIndex(config);
253
+ const scaleByVendor = new Map(scale.vendors.map((vendor) => [vendor.vendorId, vendor]));
171
254
  const scaleIndex = new Map(scale.vendors.map((vendor) => [vendor.vendorId, vendor.index]));
172
- const useScale = report.vendors.length > 0 && report.vendors.every((vendorId) => scaleIndex.get(vendorId) !== null && scaleIndex.get(vendorId) !== undefined);
255
+ const estimated = report.vendors.filter((vendorId) => typeof scaleIndex.get(vendorId) === "number");
256
+ // Scale mode needs a real majority of vendors estimated; the stragglers
257
+ // (idea-stage anchors, signal-less vendors) render as minimal dashed
258
+ // bubbles — visibly "no measurable scale", never silently re-sized.
259
+ const useScale = estimated.length >= 2 && estimated.length * 2 >= report.vendors.length;
173
260
  const loudCounts = new Map(report.vendors.map((vendorId) => [vendorId, messageBreadth(vendorId, set.observations).loudCount]));
174
- const maxLoud = Math.max(1, ...loudCounts.values());
175
261
  // Bubble areas stay proportional to the metric; dividing by the max just
176
262
  // spends the full visual range without distorting any ratio.
177
263
  const maxShare = Math.max(1e-9, ...report.vendors.map((vendorId) => scaleIndex.get(vendorId) ?? 0));
178
- const sizeOf = (vendorId: string): number =>
179
- useScale ? (scaleIndex.get(vendorId) as number) / maxShare : (loudCounts.get(vendorId) ?? 0) / maxLoud;
264
+ const sizeOf = (vendorId: string): number => {
265
+ if (!useScale) return 0.14; // uniform: size carries NO meaning without scale signals
266
+ const share = scaleIndex.get(vendorId);
267
+ return typeof share === "number" ? share / maxShare : 0;
268
+ };
269
+ const noScaleFor = (vendorId: string): boolean => useScale && typeof scaleIndex.get(vendorId) !== "number";
180
270
  const sizeCaption = useScale
181
- ? `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)`
182
- : "Dot area &#8733; LOUD count";
271
+ ? `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). Dashed outline = no measurable scale signals.`
272
+ : "Dot size carries no meaning on this map (no scaleSignals in the config); the legend lists LOUD counts";
183
273
 
184
274
  const breadthAxis: ScatterAxis & { id: string } = {
185
275
  id: "breadth",
@@ -222,6 +312,7 @@ function axisSectionsHtml(
222
312
  x: xs.get(vendorId) as number,
223
313
  y: ys.get(vendorId) as number,
224
314
  size: sizeOf(vendorId),
315
+ noScale: noScaleFor(vendorId),
225
316
  }));
226
317
  };
227
318
 
@@ -229,9 +320,100 @@ function axisSectionsHtml(
229
320
  const axInfo = axisInfo.get(px) as ScatterAxis & { id: string };
230
321
  const ayInfo = axisInfo.get(py) as ScatterAxis & { id: string };
231
322
  const statusOf = (id: string) => axes.find((axis) => axis.id === id)?.status ?? (id === "breadth" ? "derived" : "");
323
+
324
+ // Legend order doubles as bubble numbering: largest first, anchor bolded.
325
+ // The number inside each bubble resolves dense clusters that name labels
326
+ // never could; color is Okabe–Ito (colorblind-safe) keyed in the legend.
327
+ const points = pointsFor(px, py);
328
+ const legendOrder = [...points].sort((a, b) => b.size - a.size || a.name.localeCompare(b.name));
329
+ const numberByVendor = new Map(legendOrder.map((point, index) => [point.vendorId, index + 1]));
330
+ const colorByVendor = new Map(legendOrder.map((point, index) => [point.vendorId, VENDOR_COLORS[index % VENDOR_COLORS.length]]));
331
+ const legendRows = legendOrder
332
+ .map((point) => {
333
+ const number = numberByVendor.get(point.vendorId);
334
+ const color = colorByVendor.get(point.vendorId) as string;
335
+ const isAnchor = point.vendorId === config.anchorVendor;
336
+ const share = scaleIndex.get(point.vendorId);
337
+ const measure = useScale
338
+ ? typeof share === "number"
339
+ ? `${(share * 100).toFixed(1)}%`
340
+ : "—"
341
+ : `${loudCounts.get(point.vendorId) ?? 0} loud`;
342
+ return `<tr data-v="${e(point.vendorId)}"${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>`;
343
+ })
344
+ .join("");
345
+ const legendMeasureHead = useScale ? "est. share" : "loud";
346
+
347
+ const money = (value: number) =>
348
+ value >= 1e9 ? `$${(value / 1e9).toFixed(1)}B` : value >= 1e6 ? `$${(value / 1e6).toFixed(1)}M` : `$${Math.round(value / 1e3)}K`;
349
+ const tipData: Record<string, { name: string; n: number; lines: string[] }> = {};
350
+ for (const point of points) {
351
+ const vendorScale = scaleByVendor.get(point.vendorId);
352
+ const lines = [
353
+ `${axInfo.label}: ${point.x.toFixed(2)}`,
354
+ `${ayInfo.label}: ${point.y.toFixed(2)}`,
355
+ `LOUD claims: ${loudCounts.get(point.vendorId) ?? 0}`,
356
+ ];
357
+ if (useScale) {
358
+ if (vendorScale?.estimatedRevenue != null) {
359
+ lines.push(
360
+ `est. revenue: ~${money(vendorScale.estimatedRevenue)} (${(((scaleIndex.get(point.vendorId) as number) ?? 0) * 100).toFixed(1)}% of set${vendorScale.uncertainty && vendorScale.uncertainty > 1 ? `, ×${vendorScale.uncertainty.toFixed(1)} signal spread` : ""})`,
361
+ );
362
+ } else {
363
+ lines.push("est. revenue: no measurable scale signals");
364
+ }
365
+ }
366
+ tipData[point.vendorId] = { name: point.name, n: numberByVendor.get(point.vendorId) ?? 0, lines };
367
+ }
368
+
232
369
  const strategicMap = `<section>
233
- <h2><span class="no">03</span> Strategic map ${e(axInfo.label)} &#215; ${e(ayInfo.label)}</h2>
234
- <figure>${svgScatter(pointsFor(px, py), axInfo, ayInfo, config.anchorVendor, false)}
370
+ <h2>Strategic map: ${e(axInfo.label)} &#215; ${e(ayInfo.label)}</h2>
371
+ <figure class="map">
372
+ <div class="map-row">
373
+ ${svgScatter(points, axInfo, ayInfo, config.anchorVendor, colorByVendor, numberByVendor)}
374
+ <table class="legend"><thead><tr><th></th><th>vendor</th><th class="num">${legendMeasureHead}</th></tr></thead><tbody>${legendRows}</tbody></table>
375
+ </div>
376
+ <div class="map-tip" id="map-tip" hidden></div>
377
+ <script type="application/json" id="map-data">${JSON.stringify(tipData)}</script>
378
+ <script>
379
+ (function () {
380
+ var data = JSON.parse(document.getElementById("map-data").textContent);
381
+ var tip = document.getElementById("map-tip");
382
+ var fig = tip.closest("figure");
383
+ var bubbles = fig.querySelectorAll("g.bubble");
384
+ var rows = fig.querySelectorAll("table.legend tbody tr");
385
+ function show(v, evt) {
386
+ var d = data[v];
387
+ if (!d) return;
388
+ tip.innerHTML = "<b>" + d.n + " · " + d.name + "</b>" + d.lines.map(function (l) { return "<div>" + l + "</div>"; }).join("");
389
+ tip.hidden = false;
390
+ var box = fig.getBoundingClientRect();
391
+ tip.style.left = Math.min(evt.clientX - box.left + 14, box.width - tip.offsetWidth - 8) + "px";
392
+ tip.style.top = (evt.clientY - box.top + 14) + "px";
393
+ }
394
+ function focusOn(v) {
395
+ bubbles.forEach(function (b) {
396
+ b.classList.toggle("dim", b.getAttribute("data-v") !== v);
397
+ if (b.getAttribute("data-v") === v) b.parentNode.appendChild(b); // raise hidden bubbles
398
+ });
399
+ rows.forEach(function (r) { r.classList.toggle("hl", r.getAttribute("data-v") === v); });
400
+ }
401
+ function clear() {
402
+ tip.hidden = true;
403
+ bubbles.forEach(function (b) { b.classList.remove("dim"); });
404
+ rows.forEach(function (r) { r.classList.remove("hl"); });
405
+ }
406
+ bubbles.forEach(function (b) {
407
+ b.addEventListener("mousemove", function (evt) { show(b.getAttribute("data-v"), evt); });
408
+ b.addEventListener("mouseenter", function () { focusOn(b.getAttribute("data-v")); });
409
+ b.addEventListener("mouseleave", clear);
410
+ });
411
+ rows.forEach(function (r) {
412
+ r.addEventListener("mouseenter", function () { focusOn(r.getAttribute("data-v")); });
413
+ r.addEventListener("mouseleave", clear);
414
+ });
415
+ })();
416
+ </script>
235
417
  <figcaption>Positions computed from run ${e(set.runLabel)} observations: each axis is a per-claim scoring rubric
236
418
  in the market config; a vendor sits at the intensity-weighted mean (loud=1, quiet=&#189;) of the claims it
237
419
  voices. ${sizeCaption}. Axis status — ${e(axInfo.label)}: ${e(statusOf(px))}; ${e(ayInfo.label)}: ${e(statusOf(py))}.</figcaption>
@@ -255,7 +437,6 @@ export function marketMapToHtml(config: MarketConfig, set: ObservationSet): stri
255
437
  const anchor = config.anchorVendor;
256
438
  const e = escapeHtml;
257
439
  const axisHtml = axisSectionsHtml(config, set);
258
- const appendixNo = axisHtml.report ? "04" : "03";
259
440
 
260
441
  const matrixRows = model.orderedClaimIds
261
442
  .map((claimId) => {
@@ -315,81 +496,86 @@ export function marketMapToHtml(config: MarketConfig, set: ObservationSet): stri
315
496
  <meta name="viewport" content="width=device-width, initial-scale=1">
316
497
  <title>Market map — ${e(config.category)} — ${e(set.runLabel)}</title>
317
498
  <style>
318
- :root { --paper:#f4efe4; --ink:#211d16; --ink-soft:#5a5244; --line:#c9bfa9; --accent:#b4441b; --green:#2e5339; --quiet:#8a7d63; }
499
+ :root { --ink:#1c1c1c; --soft:#6b6b6b; --line:#e3e1dc; --faint:#f7f6f4; --accent:#b3491f; --green:#2e5e43; }
319
500
  * { box-sizing:border-box; margin:0; }
320
- body { font-family:"Iowan Old Style","Palatino Linotype",Palatino,Georgia,serif; color:var(--ink); background:var(--paper);
321
- max-width:1080px; margin:0 auto; padding:0 48px 96px;
322
- background-image:radial-gradient(rgba(33,29,22,.028) 1px, transparent 1.2px); background-size:5px 5px; }
323
- .chip,.claim-meta,.ev-src,.lg,.stamp,.meta,th.vh span { font-family:"SF Mono",Menlo,Consolas,monospace; }
324
- header { padding:56px 0 28px; border-bottom:3px double var(--ink); position:relative; }
325
- .kicker { font-size:11px; letter-spacing:.32em; color:var(--accent); text-transform:uppercase; }
326
- h1 { font-size:44px; line-height:1.05; font-weight:600; margin:10px 0 6px; }
327
- h1 em { font-style:italic; color:var(--green); }
328
- .meta { font-size:11.5px; color:var(--ink-soft); display:flex; gap:24px; flex-wrap:wrap; margin-top:14px; }
329
- .stamp { position:absolute; right:0; top:58px; border:2px solid var(--accent); color:var(--accent); padding:7px 13px;
330
- font-size:11px; letter-spacing:.22em; transform:rotate(3.5deg); text-transform:uppercase; }
331
- section { margin-top:56px; }
332
- h2 { font-size:13px; letter-spacing:.26em; text-transform:uppercase; color:var(--ink-soft);
333
- border-bottom:1px solid var(--line); padding-bottom:9px; display:flex; gap:14px; align-items:baseline; }
334
- h2 .no { color:var(--accent); font-style:italic; font-size:15px; letter-spacing:0; }
335
- .fronts { display:grid; grid-template-columns:repeat(4,1fr); gap:1px; background:var(--line); border:1px solid var(--line); margin-top:22px; }
336
- .fcard { background:var(--paper); padding:18px 18px 14px; }
337
- .fcard b { display:block; font-size:42px; font-weight:600; line-height:1; }
338
- .fcard span { font-size:11px; letter-spacing:.18em; text-transform:uppercase; color:var(--ink-soft); }
501
+ body { font-family:"Iowan Old Style","Palatino Linotype",Palatino,Georgia,serif; color:var(--ink); background:#fff;
502
+ max-width:1060px; margin:0 auto; padding:0 44px 80px; font-size:15px; line-height:1.5; }
503
+ .mono,.claim-meta,.ev-src,.key,.meta,th.vh span,.chip,.legend { font-family:ui-monospace,"SF Mono",Menlo,Consolas,monospace; }
504
+ header { padding:44px 0 20px; border-bottom:1px solid var(--ink); }
505
+ h1 { font-size:27px; font-weight:600; line-height:1.2; }
506
+ .meta { font-size:11px; color:var(--soft); display:flex; gap:18px; flex-wrap:wrap; margin-top:8px; }
507
+ section { margin-top:44px; }
508
+ h2 { font-size:17px; font-weight:600; border-bottom:1px solid var(--line); padding-bottom:7px; margin-bottom:4px; }
509
+ .fronts { display:grid; grid-template-columns:repeat(4,1fr); gap:1px; background:var(--line); border:1px solid var(--line); margin-top:16px; }
510
+ .fcard { background:#fff; padding:14px 16px 12px; }
511
+ .fcard b { display:block; font-size:30px; font-weight:600; line-height:1.1; }
512
+ .fcard span { font-size:11px; color:var(--soft); }
339
513
  .fcard.open b { color:var(--accent); }
340
- .openlist { margin-top:18px; font-size:15.5px; line-height:1.55; }
341
- .openlist li { margin:4px 0 4px 20px; }
342
- .openlist .why { color:var(--ink-soft); font-size:13px; font-style:italic; }
343
- .legend { display:flex; gap:22px; flex-wrap:wrap; margin:18px 0 10px; font-size:10.5px; color:var(--ink-soft); }
344
- .lg { display:inline-flex; align-items:center; gap:7px; }
514
+ .openlist { margin-top:14px; font-size:14.5px; line-height:1.55; }
515
+ .openlist li { margin:3px 0 3px 20px; }
516
+ .openlist .why { color:var(--soft); font-size:13px; }
517
+ .key { display:flex; gap:20px; flex-wrap:wrap; margin:14px 0 8px; font-size:10.5px; color:var(--soft); }
518
+ .lg { display:inline-flex; align-items:center; gap:6px; }
345
519
  table { border-collapse:collapse; width:100%; margin-top:6px; }
346
- thead th { border-bottom:2px solid var(--ink); padding:6px 2px 10px; }
347
- th.vh span { writing-mode:vertical-rl; transform:rotate(195deg); font-size:10.5px; letter-spacing:.12em;
348
- text-transform:uppercase; color:var(--ink-soft); display:inline-block; }
520
+ thead th { border-bottom:1.5px solid var(--ink); padding:6px 2px 8px; font-weight:600; }
521
+ th.vh span { writing-mode:vertical-rl; transform:rotate(195deg); font-size:10.5px; color:var(--soft); display:inline-block; }
349
522
  th.vh.anchor-col span { color:var(--green); font-weight:700; }
350
- tbody th { text-align:left; font-weight:400; padding:7px 14px 7px 0; border-bottom:1px solid var(--line); max-width:330px; }
351
- .claim-cap { display:block; font-size:14.5px; }
352
- .claim-meta { display:block; font-size:9.5px; color:var(--quiet); letter-spacing:.08em; margin-top:2px; }
523
+ tbody th { text-align:left; font-weight:400; padding:6px 14px 6px 0; border-bottom:1px solid var(--line); max-width:330px; }
524
+ .claim-cap { display:block; font-size:14px; }
525
+ .claim-meta { display:block; font-size:9.5px; color:var(--soft); margin-top:1px; }
353
526
  td.cell { text-align:center; border-bottom:1px solid var(--line); padding:4px 2px; }
354
- td.cell.anchor-col { background:rgba(46,83,57,.06); }
527
+ td.cell.anchor-col { background:var(--faint); }
355
528
  td.front { border-bottom:1px solid var(--line); text-align:right; white-space:nowrap; }
356
- .g { display:inline-block; width:15px; height:15px; vertical-align:middle; }
529
+ .g { display:inline-block; width:14px; height:14px; vertical-align:middle; }
357
530
  .g-loud { background:var(--ink); }
358
- .g-quiet { box-shadow:inset 0 0 0 2px var(--quiet); }
359
- .g-absent { background:radial-gradient(circle at center, var(--line) 0 2.5px, transparent 3px); }
360
- .g-unobservable { background:repeating-linear-gradient(45deg, var(--line) 0 2px, transparent 2px 5px); }
531
+ .g-quiet { box-shadow:inset 0 0 0 2px #9a9a9a; }
532
+ .g-absent { background:radial-gradient(circle at center, #cfcdc8 0 2.5px, transparent 3px); }
533
+ .g-unobservable { background:repeating-linear-gradient(45deg, #cfcdc8 0 2px, transparent 2px 5px); }
361
534
  tr.front-open th .claim-cap { color:var(--accent); font-weight:600; }
362
- .chip { font-size:9px; letter-spacing:.16em; padding:3px 8px; border:1px solid currentColor; }
363
- .chip-open { color:var(--accent); } .chip-contested { color:#7a5a12; }
364
- .chip-owned { color:var(--green); } .chip-saturated { color:var(--ink-soft); } .chip-vacant { color:var(--quiet); }
365
- .ev { border-bottom:1px solid var(--line); padding:12px 0; }
366
- .ev-head { font-size:10.5px; letter-spacing:.1em; color:var(--accent); }
367
- .ev blockquote { font-style:italic; margin:6px 0; font-size:13.5px; line-height:1.5; }
368
- .ev-src { font-size:10px; color:var(--ink-soft); word-break:break-all; }
369
- figure { margin-top:22px; border:1px solid var(--line); background:rgba(255,255,255,.35); }
370
- .axis { stroke:var(--ink); stroke-width:1.5; }
371
- .axis-mid { stroke:var(--line); stroke-dasharray:3 5; }
372
- .ax-label { letter-spacing:.16em; fill:var(--ink-soft); font-family:"SF Mono",Menlo,Consolas,monospace; }
373
- .dot { fill:rgba(33,29,22,.78); }
374
- .dot-anchor { fill:var(--green); stroke:var(--ink); stroke-width:1.5; }
375
- .dot-label { fill:var(--ink); text-anchor:middle; letter-spacing:.04em; font-family:"SF Mono",Menlo,Consolas,monospace; }
376
- 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; }
377
- footer { margin-top:72px; border-top:3px double var(--ink); padding-top:14px; font-size:11px; color:var(--ink-soft);
535
+ .chip { font-size:9px; padding:2px 7px; border:1px solid currentColor; border-radius:2px; }
536
+ .chip-open { color:var(--accent); } .chip-contested { color:#8a6d1c; }
537
+ .chip-owned { color:var(--green); } .chip-saturated { color:var(--soft); } .chip-vacant { color:#9a9a9a; }
538
+ .ev { border-bottom:1px solid var(--line); padding:10px 0; }
539
+ .ev-head { font-size:10.5px; color:var(--soft); font-weight:600; }
540
+ .ev blockquote { margin:5px 0; font-size:13.5px; line-height:1.5; }
541
+ .ev-src { font-size:10px; color:var(--soft); word-break:break-all; }
542
+ figure.map { margin-top:16px; border:1px solid var(--line); position:relative; }
543
+ g.bubble { cursor:pointer; }
544
+ g.bubble.dim { opacity:0.25; transition:opacity .12s; }
545
+ table.legend tbody tr { cursor:default; }
546
+ table.legend tbody tr.hl td { background:var(--faint); }
547
+ .map-tip { position:absolute; z-index:5; background:#1c1c1c; color:#fff; font-size:11.5px; line-height:1.45;
548
+ padding:8px 10px; border-radius:3px; pointer-events:none; max-width:260px;
549
+ font-family:ui-monospace,"SF Mono",Menlo,Consolas,monospace; }
550
+ .map-tip b { display:block; margin-bottom:3px; }
551
+ .map-row { display:flex; gap:8px; align-items:flex-start; padding:10px 12px 0; }
552
+ .map-row svg { flex:1 1 62%; min-width:0; }
553
+ .plot { fill:var(--faint); stroke:var(--line); }
554
+ .grid { stroke:#d6d4cf; stroke-dasharray:3 5; }
555
+ .ax-label { fill:var(--soft); font-family:ui-monospace,"SF Mono",Menlo,Consolas,monospace; }
556
+ table.legend { flex:0 0 auto; width:auto; margin:8px 0 12px; font-size:12px; }
557
+ 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; }
558
+ table.legend td { padding:4px 8px 4px 0; border-bottom:1px solid var(--faint); white-space:nowrap; }
559
+ table.legend td.num, table.legend th.num { text-align:right; }
560
+ table.legend tr.anchor-row td { font-weight:700; }
561
+ .swatch { display:inline-flex; align-items:center; justify-content:center; width:20px; height:20px; border-radius:50%;
562
+ color:#fff; font-size:10.5px; font-weight:700; }
563
+ figcaption { font-size:11.5px; color:var(--soft); padding:10px 14px 12px; border-top:1px solid var(--line); line-height:1.5; }
564
+ footer { margin-top:60px; border-top:1px solid var(--ink); padding-top:12px; font-size:11px; color:var(--soft);
378
565
  display:flex; justify-content:space-between; gap:20px; flex-wrap:wrap; }
379
- @media print { body { max-width:none; padding:0 8mm; background:white; } section { break-inside:avoid-page; } tr { break-inside:avoid; } }
566
+ @media print { body { max-width:none; padding:0 8mm; } section { break-inside:avoid-page; } tr { break-inside:avoid; } .map-row { display:block; } .map-tip { display:none; } }
567
+ @media (max-width:760px) { .map-row { display:block; } }
380
568
  </style></head><body>
381
569
  <header>
382
- <div class="kicker">Full Stack GTM · Market Map</div>
383
- <h1>The <em>${e(config.category.replace(/-/g, " "))}</em> front map</h1>
570
+ <h1>Market map ${e(config.category.replace(/-/g, " "))}</h1>
384
571
  <div class="meta">
385
- <span>RUN ${e(set.runLabel.toUpperCase())}</span><span>OBSERVED ${e(set.runAt)}</span>
386
- <span>${config.vendors.length} VENDORS · ${config.claims.length} CLAIMS · ${set.observations.length} READINGS</span>
387
- <span>${unobservable} UNOBSERVABLE · EXTRACTOR ${e(set.extractor)}</span>
572
+ <span>run ${e(set.runLabel)}</span><span>observed ${e(set.runAt)}</span>
573
+ <span>${config.vendors.length} vendors · ${config.claims.length} claims · ${set.observations.length} readings · ${unobservable} unobservable</span>
574
+ <span>extractor: ${e(set.extractor)}</span>
388
575
  </div>
389
- <div class="stamp">Field Report</div>
390
576
  </header>
391
577
  <section>
392
- <h2><span class="no">01</span> Front summary</h2>
578
+ <h2>Front summary</h2>
393
579
  <div class="fronts">
394
580
  <div class="fcard open"><b>${counts.open + counts.vacant}</b><span>Open / vacant</span></div>
395
581
  <div class="fcard"><b>${counts.contested}</b><span>Contested</span></div>
@@ -399,8 +585,8 @@ footer { margin-top:72px; border-top:3px double var(--ink); padding-top:14px; fo
399
585
  <ul class="openlist">${openList}</ul>
400
586
  </section>
401
587
  <section>
402
- <h2><span class="no">02</span> Claim × vendor intensity matrix</h2>
403
- <div class="legend">
588
+ <h2>Claim × vendor intensity matrix</h2>
589
+ <div class="key">
404
590
  <span class="lg"><i class="g g-loud"></i>LOUD — hero-level claim</span>
405
591
  <span class="lg"><i class="g g-quiet"></i>QUIET — shipped, buried</span>
406
592
  <span class="lg"><i class="g g-absent"></i>ABSENT</span>
@@ -413,7 +599,7 @@ footer { margin-top:72px; border-top:3px double var(--ink); padding-top:14px; fo
413
599
  </section>
414
600
  ${axisHtml.strategicMap}
415
601
  <section>
416
- <h2><span class="no">${appendixNo}</span> Evidence appendix</h2>
602
+ <h2>Evidence appendix</h2>
417
603
  ${appendix}
418
604
  </section>
419
605
  <footer>