fullstackgtm 0.21.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.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,27 @@ 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.1] — 2026-06-12
9
+
10
+ Report design pass: analyst-grade restraint, legible dense scatters.
11
+
12
+ ### Changed
13
+
14
+ - **De-slopped the field report**: removed the rotated stamp, paper-grain
15
+ background, parchment palette, letterspaced kickers, and editorial hero
16
+ voice. White page, hairline rules, plain headings — the design recedes,
17
+ the data reads.
18
+ - **Strategic map legibility**: bubbles are now numbered and colored
19
+ (Okabe–Ito colorblind-safe palette) with a legend table beside the chart
20
+ that doubles as the share table (number · color · vendor · est. share or
21
+ LOUD count, anchor bolded). Larger bubbles render first so overlapping
22
+ clusters stay readable; the number resolves what overlapping name labels
23
+ never could. Hover tooltips keep the names.
24
+ - **Axis pole labels can no longer collide**: wrapped to ≤2 short lines
25
+ (parentheticals dropped first), x poles at the bottom corners, y poles
26
+ rotated along the left margin — four positions, standard chart
27
+ convention.
28
+
8
29
  ## [0.21.0] — 2026-06-12
9
30
 
10
31
  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,46 @@ 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.
153
+ const ordered = [...points].sort((a, b) => b.size - a.size);
154
+ const dots = ordered
101
155
  .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>`);
156
+ const r = 7 + 24 * Math.sqrt(p.size);
157
+ const color = colorByVendor.get(p.vendorId) ?? "#717171";
158
+ const ring = p.vendorId === anchor ? ` stroke="#1c1c1c" stroke-width="2.5"` : ` stroke="#ffffff" stroke-width="1.5"`;
159
+ const number = numberByVendor.get(p.vendorId) ?? 0;
160
+ const fs = Math.max(10, Math.min(14, r * 0.9));
161
+ return (`<circle cx="${sx(p.x).toFixed(1)}" cy="${sy(p.y).toFixed(1)}" r="${r.toFixed(1)}" fill="${color}" fill-opacity="0.88"${ring}>` +
162
+ `<title>${e(p.name)}</title></circle>` +
163
+ `<text x="${sx(p.x).toFixed(1)}" y="${(sy(p.y) + fs * 0.36).toFixed(1)}" text-anchor="middle" ` +
164
+ `font-size="${fs.toFixed(1)}" font-weight="700" fill="${numeralColor(color)}" style="pointer-events:none">${number}</text>`);
107
165
  })
108
166
  .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)}"/>` : "";
167
+ const midX = ax.signed ? `<line class="grid" x1="${sx(0).toFixed(0)}" y1="${PAD_TOP}" x2="${sx(0).toFixed(0)}" y2="${H - PAD_BOTTOM}"/>` : "";
168
+ const midY = ay.signed ? `<line class="grid" x1="${PAD_X}" y1="${sy(0).toFixed(0)}" x2="${W - PAD_X}" y2="${sy(0).toFixed(0)}"/>` : "";
169
+ // Pole labels in four positions that cannot collide: x poles horizontal at
170
+ // the bottom corners; y poles rotated along the left margin (positive
171
+ // reading up toward the top, negative near the bottom) — the standard
172
+ // chart convention, wrapped to ≤2 short lines each.
173
+ const fsPole = 10;
174
+ const xNeg = poleText(wrapPole(ax.negativePole), PAD_X, H - PAD_BOTTOM + 20, "start", fsPole);
175
+ const xPos = poleText(wrapPole(ax.positivePole), W - PAD_X, H - PAD_BOTTOM + 20, "end", fsPole);
176
+ const yLabel = (lines, yEdge, anchorMode) => {
177
+ const spans = lines
178
+ .map((line, index) => `<tspan x="${-yEdge}" dy="${index === 0 ? 0 : fsPole + 2}">${e(line)}</tspan>`)
179
+ .join("");
180
+ return `<text class="ax-label" style="font-size:${fsPole}px" transform="rotate(-90)" x="${-yEdge}" y="16" text-anchor="${anchorMode}">${spans}</text>`;
181
+ };
182
+ const yPos = yLabel(wrapPole(ay.positivePole, 26), PAD_TOP, "end");
183
+ const yNeg = ay.signed ? yLabel(wrapPole(ay.negativePole, 26), H - PAD_BOTTOM, "start") : "";
111
184
  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>
185
+ <rect x="${PAD_X}" y="${PAD_TOP}" width="${W - 2 * PAD_X}" height="${H - PAD_TOP - PAD_BOTTOM}" class="plot"/>
186
+ ${midX}${midY}
187
+ ${xNeg}${xPos}
188
+ ${yPos}${yNeg}
117
189
  ${dots}</svg>`;
118
190
  }
119
191
  function axisSectionsHtml(config, set) {
@@ -181,9 +253,32 @@ function axisSectionsHtml(config, set) {
181
253
  const axInfo = axisInfo.get(px);
182
254
  const ayInfo = axisInfo.get(py);
183
255
  const statusOf = (id) => axes.find((axis) => axis.id === id)?.status ?? (id === "breadth" ? "derived" : "");
256
+ // Legend order doubles as bubble numbering: largest first, anchor bolded.
257
+ // The number inside each bubble resolves dense clusters that name labels
258
+ // never could; color is Okabe–Ito (colorblind-safe) keyed in the legend.
259
+ const points = pointsFor(px, py);
260
+ const legendOrder = [...points].sort((a, b) => b.size - a.size || a.name.localeCompare(b.name));
261
+ const numberByVendor = new Map(legendOrder.map((point, index) => [point.vendorId, index + 1]));
262
+ const colorByVendor = new Map(legendOrder.map((point, index) => [point.vendorId, VENDOR_COLORS[index % VENDOR_COLORS.length]]));
263
+ const legendRows = legendOrder
264
+ .map((point) => {
265
+ const number = numberByVendor.get(point.vendorId);
266
+ const color = colorByVendor.get(point.vendorId);
267
+ const isAnchor = point.vendorId === config.anchorVendor;
268
+ const measure = useScale
269
+ ? `${((scaleIndex.get(point.vendorId) ?? 0) * 100).toFixed(1)}%`
270
+ : `${loudCounts.get(point.vendorId) ?? 0} loud`;
271
+ 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>`;
272
+ })
273
+ .join("");
274
+ const legendMeasureHead = useScale ? "est. share" : "loud";
184
275
  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)}
276
+ <h2>Strategic map: ${e(axInfo.label)} &#215; ${e(ayInfo.label)}</h2>
277
+ <figure class="map">
278
+ <div class="map-row">
279
+ ${svgScatter(points, axInfo, ayInfo, config.anchorVendor, colorByVendor, numberByVendor)}
280
+ <table class="legend"><thead><tr><th></th><th>vendor</th><th class="num">${legendMeasureHead}</th></tr></thead><tbody>${legendRows}</tbody></table>
281
+ </div>
187
282
  <figcaption>Positions computed from run ${e(set.runLabel)} observations: each axis is a per-claim scoring rubric
188
283
  in the market config; a vendor sits at the intensity-weighted mean (loud=1, quiet=&#189;) of the claims it
189
284
  voices. ${sizeCaption}. Axis status — ${e(axInfo.label)}: ${e(statusOf(px))}; ${e(ayInfo.label)}: ${e(statusOf(py))}.</figcaption>
@@ -206,7 +301,6 @@ export function marketMapToHtml(config, set) {
206
301
  const anchor = config.anchorVendor;
207
302
  const e = escapeHtml;
208
303
  const axisHtml = axisSectionsHtml(config, set);
209
- const appendixNo = axisHtml.report ? "04" : "03";
210
304
  const matrixRows = model.orderedClaimIds
211
305
  .map((claimId) => {
212
306
  const claim = claimsById.get(claimId);
@@ -253,81 +347,78 @@ export function marketMapToHtml(config, set) {
253
347
  <meta name="viewport" content="width=device-width, initial-scale=1">
254
348
  <title>Market map — ${e(config.category)} — ${e(set.runLabel)}</title>
255
349
  <style>
256
- :root { --paper:#f4efe4; --ink:#211d16; --ink-soft:#5a5244; --line:#c9bfa9; --accent:#b4441b; --green:#2e5339; --quiet:#8a7d63; }
350
+ :root { --ink:#1c1c1c; --soft:#6b6b6b; --line:#e3e1dc; --faint:#f7f6f4; --accent:#b3491f; --green:#2e5e43; }
257
351
  * { 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); }
352
+ body { font-family:"Iowan Old Style","Palatino Linotype",Palatino,Georgia,serif; color:var(--ink); background:#fff;
353
+ max-width:1060px; margin:0 auto; padding:0 44px 80px; font-size:15px; line-height:1.5; }
354
+ .mono,.claim-meta,.ev-src,.key,.meta,th.vh span,.chip,.legend { font-family:ui-monospace,"SF Mono",Menlo,Consolas,monospace; }
355
+ header { padding:44px 0 20px; border-bottom:1px solid var(--ink); }
356
+ h1 { font-size:27px; font-weight:600; line-height:1.2; }
357
+ .meta { font-size:11px; color:var(--soft); display:flex; gap:18px; flex-wrap:wrap; margin-top:8px; }
358
+ section { margin-top:44px; }
359
+ h2 { font-size:17px; font-weight:600; border-bottom:1px solid var(--line); padding-bottom:7px; margin-bottom:4px; }
360
+ .fronts { display:grid; grid-template-columns:repeat(4,1fr); gap:1px; background:var(--line); border:1px solid var(--line); margin-top:16px; }
361
+ .fcard { background:#fff; padding:14px 16px 12px; }
362
+ .fcard b { display:block; font-size:30px; font-weight:600; line-height:1.1; }
363
+ .fcard span { font-size:11px; color:var(--soft); }
277
364
  .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; }
365
+ .openlist { margin-top:14px; font-size:14.5px; line-height:1.55; }
366
+ .openlist li { margin:3px 0 3px 20px; }
367
+ .openlist .why { color:var(--soft); font-size:13px; }
368
+ .key { display:flex; gap:20px; flex-wrap:wrap; margin:14px 0 8px; font-size:10.5px; color:var(--soft); }
369
+ .lg { display:inline-flex; align-items:center; gap:6px; }
283
370
  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; }
371
+ thead th { border-bottom:1.5px solid var(--ink); padding:6px 2px 8px; font-weight:600; }
372
+ th.vh span { writing-mode:vertical-rl; transform:rotate(195deg); font-size:10.5px; color:var(--soft); display:inline-block; }
287
373
  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; }
374
+ tbody th { text-align:left; font-weight:400; padding:6px 14px 6px 0; border-bottom:1px solid var(--line); max-width:330px; }
375
+ .claim-cap { display:block; font-size:14px; }
376
+ .claim-meta { display:block; font-size:9.5px; color:var(--soft); margin-top:1px; }
291
377
  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); }
378
+ td.cell.anchor-col { background:var(--faint); }
293
379
  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; }
380
+ .g { display:inline-block; width:14px; height:14px; vertical-align:middle; }
295
381
  .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); }
382
+ .g-quiet { box-shadow:inset 0 0 0 2px #9a9a9a; }
383
+ .g-absent { background:radial-gradient(circle at center, #cfcdc8 0 2.5px, transparent 3px); }
384
+ .g-unobservable { background:repeating-linear-gradient(45deg, #cfcdc8 0 2px, transparent 2px 5px); }
299
385
  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);
386
+ .chip { font-size:9px; padding:2px 7px; border:1px solid currentColor; border-radius:2px; }
387
+ .chip-open { color:var(--accent); } .chip-contested { color:#8a6d1c; }
388
+ .chip-owned { color:var(--green); } .chip-saturated { color:var(--soft); } .chip-vacant { color:#9a9a9a; }
389
+ .ev { border-bottom:1px solid var(--line); padding:10px 0; }
390
+ .ev-head { font-size:10.5px; color:var(--soft); font-weight:600; }
391
+ .ev blockquote { margin:5px 0; font-size:13.5px; line-height:1.5; }
392
+ .ev-src { font-size:10px; color:var(--soft); word-break:break-all; }
393
+ figure.map { margin-top:16px; border:1px solid var(--line); }
394
+ .map-row { display:flex; gap:8px; align-items:flex-start; padding:10px 12px 0; }
395
+ .map-row svg { flex:1 1 62%; min-width:0; }
396
+ .plot { fill:var(--faint); stroke:var(--line); }
397
+ .grid { stroke:#d6d4cf; stroke-dasharray:3 5; }
398
+ .ax-label { fill:var(--soft); font-family:ui-monospace,"SF Mono",Menlo,Consolas,monospace; }
399
+ table.legend { flex:0 0 auto; width:auto; margin:8px 0 12px; font-size:12px; }
400
+ 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; }
401
+ table.legend td { padding:4px 8px 4px 0; border-bottom:1px solid var(--faint); white-space:nowrap; }
402
+ table.legend td.num, table.legend th.num { text-align:right; }
403
+ table.legend tr.anchor-row td { font-weight:700; }
404
+ .swatch { display:inline-flex; align-items:center; justify-content:center; width:20px; height:20px; border-radius:50%;
405
+ color:#fff; font-size:10.5px; font-weight:700; }
406
+ figcaption { font-size:11.5px; color:var(--soft); padding:10px 14px 12px; border-top:1px solid var(--line); line-height:1.5; }
407
+ footer { margin-top:60px; border-top:1px solid var(--ink); padding-top:12px; font-size:11px; color:var(--soft);
316
408
  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; } }
409
+ @media print { body { max-width:none; padding:0 8mm; } section { break-inside:avoid-page; } tr { break-inside:avoid; } .map-row { display:block; } }
410
+ @media (max-width:760px) { .map-row { display:block; } }
318
411
  </style></head><body>
319
412
  <header>
320
- <div class="kicker">Full Stack GTM · Market Map</div>
321
- <h1>The <em>${e(config.category.replace(/-/g, " "))}</em> front map</h1>
413
+ <h1>Market map ${e(config.category.replace(/-/g, " "))}</h1>
322
414
  <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>
415
+ <span>run ${e(set.runLabel)}</span><span>observed ${e(set.runAt)}</span>
416
+ <span>${config.vendors.length} vendors · ${config.claims.length} claims · ${set.observations.length} readings · ${unobservable} unobservable</span>
417
+ <span>extractor: ${e(set.extractor)}</span>
326
418
  </div>
327
- <div class="stamp">Field Report</div>
328
419
  </header>
329
420
  <section>
330
- <h2><span class="no">01</span> Front summary</h2>
421
+ <h2>Front summary</h2>
331
422
  <div class="fronts">
332
423
  <div class="fcard open"><b>${counts.open + counts.vacant}</b><span>Open / vacant</span></div>
333
424
  <div class="fcard"><b>${counts.contested}</b><span>Contested</span></div>
@@ -337,8 +428,8 @@ footer { margin-top:72px; border-top:3px double var(--ink); padding-top:14px; fo
337
428
  <ul class="openlist">${openList}</ul>
338
429
  </section>
339
430
  <section>
340
- <h2><span class="no">02</span> Claim × vendor intensity matrix</h2>
341
- <div class="legend">
431
+ <h2>Claim × vendor intensity matrix</h2>
432
+ <div class="key">
342
433
  <span class="lg"><i class="g g-loud"></i>LOUD — hero-level claim</span>
343
434
  <span class="lg"><i class="g g-quiet"></i>QUIET — shipped, buried</span>
344
435
  <span class="lg"><i class="g g-absent"></i>ABSENT</span>
@@ -351,7 +442,7 @@ footer { margin-top:72px; border-top:3px double var(--ink); padding-top:14px; fo
351
442
  </section>
352
443
  ${axisHtml.strategicMap}
353
444
  <section>
354
- <h2><span class="no">${appendixNo}</span> Evidence appendix</h2>
445
+ <h2>Evidence appendix</h2>
355
446
  ${appendix}
356
447
  </section>
357
448
  <footer>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fullstackgtm",
3
- "version": "0.21.0",
3
+ "version": "0.21.1",
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",
@@ -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
 
@@ -229,9 +305,34 @@ function axisSectionsHtml(
229
305
  const axInfo = axisInfo.get(px) as ScatterAxis & { id: string };
230
306
  const ayInfo = axisInfo.get(py) as ScatterAxis & { id: string };
231
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
+
232
329
  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)}
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>
235
336
  <figcaption>Positions computed from run ${e(set.runLabel)} observations: each axis is a per-claim scoring rubric
236
337
  in the market config; a vendor sits at the intensity-weighted mean (loud=1, quiet=&#189;) of the claims it
237
338
  voices. ${sizeCaption}. Axis status — ${e(axInfo.label)}: ${e(statusOf(px))}; ${e(ayInfo.label)}: ${e(statusOf(py))}.</figcaption>
@@ -255,7 +356,6 @@ export function marketMapToHtml(config: MarketConfig, set: ObservationSet): stri
255
356
  const anchor = config.anchorVendor;
256
357
  const e = escapeHtml;
257
358
  const axisHtml = axisSectionsHtml(config, set);
258
- const appendixNo = axisHtml.report ? "04" : "03";
259
359
 
260
360
  const matrixRows = model.orderedClaimIds
261
361
  .map((claimId) => {
@@ -315,81 +415,78 @@ export function marketMapToHtml(config: MarketConfig, set: ObservationSet): stri
315
415
  <meta name="viewport" content="width=device-width, initial-scale=1">
316
416
  <title>Market map — ${e(config.category)} — ${e(set.runLabel)}</title>
317
417
  <style>
318
- :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; }
319
419
  * { 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); }
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); }
339
432
  .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; }
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; }
345
438
  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; }
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; }
349
441
  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; }
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; }
353
445
  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); }
446
+ td.cell.anchor-col { background:var(--faint); }
355
447
  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; }
448
+ .g { display:inline-block; width:14px; height:14px; vertical-align:middle; }
357
449
  .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); }
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); }
361
453
  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);
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);
378
476
  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; } }
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; } }
380
479
  </style></head><body>
381
480
  <header>
382
- <div class="kicker">Full Stack GTM · Market Map</div>
383
- <h1>The <em>${e(config.category.replace(/-/g, " "))}</em> front map</h1>
481
+ <h1>Market map ${e(config.category.replace(/-/g, " "))}</h1>
384
482
  <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>
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>
388
486
  </div>
389
- <div class="stamp">Field Report</div>
390
487
  </header>
391
488
  <section>
392
- <h2><span class="no">01</span> Front summary</h2>
489
+ <h2>Front summary</h2>
393
490
  <div class="fronts">
394
491
  <div class="fcard open"><b>${counts.open + counts.vacant}</b><span>Open / vacant</span></div>
395
492
  <div class="fcard"><b>${counts.contested}</b><span>Contested</span></div>
@@ -399,8 +496,8 @@ footer { margin-top:72px; border-top:3px double var(--ink); padding-top:14px; fo
399
496
  <ul class="openlist">${openList}</ul>
400
497
  </section>
401
498
  <section>
402
- <h2><span class="no">02</span> Claim × vendor intensity matrix</h2>
403
- <div class="legend">
499
+ <h2>Claim × vendor intensity matrix</h2>
500
+ <div class="key">
404
501
  <span class="lg"><i class="g g-loud"></i>LOUD — hero-level claim</span>
405
502
  <span class="lg"><i class="g g-quiet"></i>QUIET — shipped, buried</span>
406
503
  <span class="lg"><i class="g g-absent"></i>ABSENT</span>
@@ -413,7 +510,7 @@ footer { margin-top:72px; border-top:3px double var(--ink); padding-top:14px; fo
413
510
  </section>
414
511
  ${axisHtml.strategicMap}
415
512
  <section>
416
- <h2><span class="no">${appendixNo}</span> Evidence appendix</h2>
513
+ <h2>Evidence appendix</h2>
417
514
  ${appendix}
418
515
  </section>
419
516
  <footer>