fullstackgtm 0.20.0 → 0.21.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,58 @@ 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
+
29
+ ## [0.21.0] — 2026-06-12
30
+
31
+ Scale estimation v2 — dimensional, calibrated, SMB-bias-robust.
32
+
33
+ ### Changed
34
+
35
+ - **`market scale` estimates revenue, not a normalized blend.** v1 averaged
36
+ [0,1]-normalized signals, which quietly mixed dimensions: review/customer
37
+ counts proxy CUSTOMER COUNT while employees/revenue proxy REVENUE, so
38
+ many-small-customer vendors outranked fewer-bigger-customer ones (observed
39
+ live: a ~$20M SMB dialer outranked a ~$33M mid-market platform). v2
40
+ converts every signal into revenue space first: revenue signals are used
41
+ directly; headcount × a revenue-per-employee ratio; customer counts × a
42
+ revenue-per-customer ratio **calibrated within the set (median over
43
+ vendors that have both) and stratified by the vendor's new `acvBand`** —
44
+ revenue-per-review spans ~75× between SMB tools and enterprise suites,
45
+ which is the bias, killed at the source. Per-vendor output is an
46
+ estimated revenue (weighted geometric mean: revenue 3 / headcount 2 /
47
+ customers 1) with a disclosed max/min **uncertainty spread**, an index =
48
+ share of the set's summed estimates, and the full calibration table.
49
+ Uncalibratable metrics (no revenue pair anywhere) are skipped and named.
50
+ - Report bubbles: dot area ∝ estimated revenue share (normalized to the
51
+ set's max for visual range — ratios preserved); caption now says
52
+ "estimated revenue share … citable but NOT audited" and points at
53
+ `market scale` for the per-vendor estimates and spreads.
54
+ - New config fields: `MarketVendor.acvBand` ("smb" | "mid" | "enterprise"
55
+ by convention — usually obvious from the pricing page the map already
56
+ captures) and `ScaleSignal.dimension` override.
57
+ - SMB-bias regression test: many cheap-product reviews must not outrank
58
+ fewer expensive-product reviews when band calibration says otherwise.
59
+
8
60
  ## [0.20.0] — 2026-06-12
9
61
 
10
62
  The directive layer: the market map joined to your own CRM ground truth.
package/dist/index.d.ts CHANGED
@@ -24,7 +24,7 @@ export { captureMarket, computeFrontStates, createFileObservationStore, diffFron
24
24
  export { assessAxes, axesReportToText, axisPosition, messageBreadth, pcaTop2, pearson, type AxesReport, type AxisAssessment, type AxisPairing, type PrincipalComponent, } from "./marketAxes.ts";
25
25
  export { buildWorksheet, classifyMarket, type ClassifyMarketOptions, type ClassifyMarketResult, type MarketWorksheet, } from "./marketClassify.ts";
26
26
  export { computeDirectives, computeOverlayStats, directivesToPlan, overlayToMarkdown, type CallDocument, type ClaimMentionStats, type DirectiveStat, type DirectiveType, type MarketDirective, type OverlayOptions, type OverlayStats, type VendorMentionStats, } from "./marketOverlay.ts";
27
- export { computeScaleIndex, scaleReportToText, type ScaleReport, type VendorScale } from "./marketScale.ts";
27
+ export { computeScaleIndex, dimensionForMetric, scaleReportToText, type ScaleDimension, type ScaleReport, type SignalEstimate, type VendorScale, } from "./marketScale.ts";
28
28
  export { marketMapToHtml, marketMapToMarkdown } from "./marketReport.ts";
29
29
  export { suggestValues, type SuggestionConfidence, type ValueSuggestion } from "./suggest.ts";
30
30
  export type { ApprovalStatus, AuditFinding, AuditFindingSeverity, CanonicalAccount, CanonicalActivity, CanonicalContact, CanonicalDeal, CanonicalGtmSnapshot, CanonicalUser, CrmProvider, GtmAuditRule, GtmConnector, GtmEvidence, GtmEvidenceSourceSystem, GtmObjectType, GtmPolicy, GtmRuleContext, GtmRuleResult, GtmSnapshotIndex, PatchOperation, PatchOperationResult, PatchOperationType, PatchPlan, PatchPlanRun, PatchPlanRunStatus, PatchVerification, PipelineFinding, PipelineFindingStatus, PipelineFindingType, ProviderIdentity, RiskLevel, SourceFreshness, } from "./types.ts";
package/dist/index.js CHANGED
@@ -24,6 +24,6 @@ export { captureMarket, computeFrontStates, createFileObservationStore, diffFron
24
24
  export { assessAxes, axesReportToText, axisPosition, messageBreadth, pcaTop2, pearson, } from "./marketAxes.js";
25
25
  export { buildWorksheet, classifyMarket, } from "./marketClassify.js";
26
26
  export { computeDirectives, computeOverlayStats, directivesToPlan, overlayToMarkdown, } from "./marketOverlay.js";
27
- export { computeScaleIndex, scaleReportToText } from "./marketScale.js";
27
+ export { computeScaleIndex, dimensionForMetric, scaleReportToText, } from "./marketScale.js";
28
28
  export { marketMapToHtml, marketMapToMarkdown } from "./marketReport.js";
29
29
  export { suggestValues } from "./suggest.js";
package/dist/market.d.ts CHANGED
@@ -53,6 +53,12 @@ export type ScaleSignal = {
53
53
  quote: string;
54
54
  asOf: string;
55
55
  caveat?: string;
56
+ /**
57
+ * What the signal proxies: revenue (used directly), headcount, or
58
+ * customers (count-of-customers proxies like reviews). Inferred from the
59
+ * metric name when omitted; set explicitly for unusual metrics.
60
+ */
61
+ dimension?: "revenue" | "headcount" | "customers";
56
62
  };
57
63
  export type MarketVendor = {
58
64
  id: string;
@@ -67,6 +73,14 @@ export type MarketVendor = {
67
73
  aliases?: string[];
68
74
  /** Public scale signals; see ScaleSignal. */
69
75
  scaleSignals?: ScaleSignal[];
76
+ /**
77
+ * ACV stratum ("smb" | "mid" | "enterprise" by convention) used to
78
+ * calibrate customer-count → revenue conversion in the scale index.
79
+ * Revenue-per-customer differs ~75× between SMB tools and enterprise
80
+ * suites; stratifying kills the many-small-customers bias. Usually
81
+ * obvious from the vendor's own pricing page (which the map captures).
82
+ */
83
+ acvBand?: string;
70
84
  notes?: string;
71
85
  };
72
86
  export type MarketAxis = {
@@ -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) {
@@ -131,9 +203,12 @@ function axisSectionsHtml(config, set) {
131
203
  const useScale = report.vendors.length > 0 && report.vendors.every((vendorId) => scaleIndex.get(vendorId) !== null && scaleIndex.get(vendorId) !== undefined);
132
204
  const loudCounts = new Map(report.vendors.map((vendorId) => [vendorId, messageBreadth(vendorId, set.observations).loudCount]));
133
205
  const maxLoud = Math.max(1, ...loudCounts.values());
134
- const sizeOf = (vendorId) => useScale ? scaleIndex.get(vendorId) : (loudCounts.get(vendorId) ?? 0) / maxLoud;
206
+ // Bubble areas stay proportional to the metric; dividing by the max just
207
+ // spends the full visual range without distorting any ratio.
208
+ const maxShare = Math.max(1e-9, ...report.vendors.map((vendorId) => scaleIndex.get(vendorId) ?? 0));
209
+ const sizeOf = (vendorId) => useScale ? scaleIndex.get(vendorId) / maxShare : (loudCounts.get(vendorId) ?? 0) / maxLoud;
135
210
  const sizeCaption = useScale
136
- ? `Dot area &#8733; relative scale index (within this vendor set, from: ${e(scale.metricsUsed.join(", "))} citable signals, not true market share)`
211
+ ? `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)`
137
212
  : "Dot area &#8733; LOUD count";
138
213
  const breadthAxis = {
139
214
  id: "breadth",
@@ -178,9 +253,32 @@ function axisSectionsHtml(config, set) {
178
253
  const axInfo = axisInfo.get(px);
179
254
  const ayInfo = axisInfo.get(py);
180
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";
181
275
  const strategicMap = `<section>
182
- <h2><span class="no">03</span> Strategic map ${e(axInfo.label)} &#215; ${e(ayInfo.label)}</h2>
183
- <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>
184
282
  <figcaption>Positions computed from run ${e(set.runLabel)} observations: each axis is a per-claim scoring rubric
185
283
  in the market config; a vendor sits at the intensity-weighted mean (loud=1, quiet=&#189;) of the claims it
186
284
  voices. ${sizeCaption}. Axis status — ${e(axInfo.label)}: ${e(statusOf(px))}; ${e(ayInfo.label)}: ${e(statusOf(py))}.</figcaption>
@@ -203,7 +301,6 @@ export function marketMapToHtml(config, set) {
203
301
  const anchor = config.anchorVendor;
204
302
  const e = escapeHtml;
205
303
  const axisHtml = axisSectionsHtml(config, set);
206
- const appendixNo = axisHtml.report ? "04" : "03";
207
304
  const matrixRows = model.orderedClaimIds
208
305
  .map((claimId) => {
209
306
  const claim = claimsById.get(claimId);
@@ -250,81 +347,78 @@ export function marketMapToHtml(config, set) {
250
347
  <meta name="viewport" content="width=device-width, initial-scale=1">
251
348
  <title>Market map — ${e(config.category)} — ${e(set.runLabel)}</title>
252
349
  <style>
253
- :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; }
254
351
  * { box-sizing:border-box; margin:0; }
255
- body { font-family:"Iowan Old Style","Palatino Linotype",Palatino,Georgia,serif; color:var(--ink); background:var(--paper);
256
- max-width:1080px; margin:0 auto; padding:0 48px 96px;
257
- background-image:radial-gradient(rgba(33,29,22,.028) 1px, transparent 1.2px); background-size:5px 5px; }
258
- .chip,.claim-meta,.ev-src,.lg,.stamp,.meta,th.vh span { font-family:"SF Mono",Menlo,Consolas,monospace; }
259
- header { padding:56px 0 28px; border-bottom:3px double var(--ink); position:relative; }
260
- .kicker { font-size:11px; letter-spacing:.32em; color:var(--accent); text-transform:uppercase; }
261
- h1 { font-size:44px; line-height:1.05; font-weight:600; margin:10px 0 6px; }
262
- h1 em { font-style:italic; color:var(--green); }
263
- .meta { font-size:11.5px; color:var(--ink-soft); display:flex; gap:24px; flex-wrap:wrap; margin-top:14px; }
264
- .stamp { position:absolute; right:0; top:58px; border:2px solid var(--accent); color:var(--accent); padding:7px 13px;
265
- font-size:11px; letter-spacing:.22em; transform:rotate(3.5deg); text-transform:uppercase; }
266
- section { margin-top:56px; }
267
- h2 { font-size:13px; letter-spacing:.26em; text-transform:uppercase; color:var(--ink-soft);
268
- border-bottom:1px solid var(--line); padding-bottom:9px; display:flex; gap:14px; align-items:baseline; }
269
- h2 .no { color:var(--accent); font-style:italic; font-size:15px; letter-spacing:0; }
270
- .fronts { display:grid; grid-template-columns:repeat(4,1fr); gap:1px; background:var(--line); border:1px solid var(--line); margin-top:22px; }
271
- .fcard { background:var(--paper); padding:18px 18px 14px; }
272
- .fcard b { display:block; font-size:42px; font-weight:600; line-height:1; }
273
- .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); }
274
364
  .fcard.open b { color:var(--accent); }
275
- .openlist { margin-top:18px; font-size:15.5px; line-height:1.55; }
276
- .openlist li { margin:4px 0 4px 20px; }
277
- .openlist .why { color:var(--ink-soft); font-size:13px; font-style:italic; }
278
- .legend { display:flex; gap:22px; flex-wrap:wrap; margin:18px 0 10px; font-size:10.5px; color:var(--ink-soft); }
279
- .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; }
280
370
  table { border-collapse:collapse; width:100%; margin-top:6px; }
281
- thead th { border-bottom:2px solid var(--ink); padding:6px 2px 10px; }
282
- th.vh span { writing-mode:vertical-rl; transform:rotate(195deg); font-size:10.5px; letter-spacing:.12em;
283
- 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; }
284
373
  th.vh.anchor-col span { color:var(--green); font-weight:700; }
285
- tbody th { text-align:left; font-weight:400; padding:7px 14px 7px 0; border-bottom:1px solid var(--line); max-width:330px; }
286
- .claim-cap { display:block; font-size:14.5px; }
287
- .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; }
288
377
  td.cell { text-align:center; border-bottom:1px solid var(--line); padding:4px 2px; }
289
- td.cell.anchor-col { background:rgba(46,83,57,.06); }
378
+ td.cell.anchor-col { background:var(--faint); }
290
379
  td.front { border-bottom:1px solid var(--line); text-align:right; white-space:nowrap; }
291
- .g { display:inline-block; width:15px; height:15px; vertical-align:middle; }
380
+ .g { display:inline-block; width:14px; height:14px; vertical-align:middle; }
292
381
  .g-loud { background:var(--ink); }
293
- .g-quiet { box-shadow:inset 0 0 0 2px var(--quiet); }
294
- .g-absent { background:radial-gradient(circle at center, var(--line) 0 2.5px, transparent 3px); }
295
- .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); }
296
385
  tr.front-open th .claim-cap { color:var(--accent); font-weight:600; }
297
- .chip { font-size:9px; letter-spacing:.16em; padding:3px 8px; border:1px solid currentColor; }
298
- .chip-open { color:var(--accent); } .chip-contested { color:#7a5a12; }
299
- .chip-owned { color:var(--green); } .chip-saturated { color:var(--ink-soft); } .chip-vacant { color:var(--quiet); }
300
- .ev { border-bottom:1px solid var(--line); padding:12px 0; }
301
- .ev-head { font-size:10.5px; letter-spacing:.1em; color:var(--accent); }
302
- .ev blockquote { font-style:italic; margin:6px 0; font-size:13.5px; line-height:1.5; }
303
- .ev-src { font-size:10px; color:var(--ink-soft); word-break:break-all; }
304
- figure { margin-top:22px; border:1px solid var(--line); background:rgba(255,255,255,.35); }
305
- .axis { stroke:var(--ink); stroke-width:1.5; }
306
- .axis-mid { stroke:var(--line); stroke-dasharray:3 5; }
307
- .ax-label { letter-spacing:.16em; fill:var(--ink-soft); font-family:"SF Mono",Menlo,Consolas,monospace; }
308
- .dot { fill:rgba(33,29,22,.78); }
309
- .dot-anchor { fill:var(--green); stroke:var(--ink); stroke-width:1.5; }
310
- .dot-label { fill:var(--ink); text-anchor:middle; letter-spacing:.04em; font-family:"SF Mono",Menlo,Consolas,monospace; }
311
- 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; }
312
- 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);
313
408
  display:flex; justify-content:space-between; gap:20px; flex-wrap:wrap; }
314
- @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; } }
315
411
  </style></head><body>
316
412
  <header>
317
- <div class="kicker">Full Stack GTM · Market Map</div>
318
- <h1>The <em>${e(config.category.replace(/-/g, " "))}</em> front map</h1>
413
+ <h1>Market map ${e(config.category.replace(/-/g, " "))}</h1>
319
414
  <div class="meta">
320
- <span>RUN ${e(set.runLabel.toUpperCase())}</span><span>OBSERVED ${e(set.runAt)}</span>
321
- <span>${config.vendors.length} VENDORS · ${config.claims.length} CLAIMS · ${set.observations.length} READINGS</span>
322
- <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>
323
418
  </div>
324
- <div class="stamp">Field Report</div>
325
419
  </header>
326
420
  <section>
327
- <h2><span class="no">01</span> Front summary</h2>
421
+ <h2>Front summary</h2>
328
422
  <div class="fronts">
329
423
  <div class="fcard open"><b>${counts.open + counts.vacant}</b><span>Open / vacant</span></div>
330
424
  <div class="fcard"><b>${counts.contested}</b><span>Contested</span></div>
@@ -334,8 +428,8 @@ footer { margin-top:72px; border-top:3px double var(--ink); padding-top:14px; fo
334
428
  <ul class="openlist">${openList}</ul>
335
429
  </section>
336
430
  <section>
337
- <h2><span class="no">02</span> Claim × vendor intensity matrix</h2>
338
- <div class="legend">
431
+ <h2>Claim × vendor intensity matrix</h2>
432
+ <div class="key">
339
433
  <span class="lg"><i class="g g-loud"></i>LOUD — hero-level claim</span>
340
434
  <span class="lg"><i class="g g-quiet"></i>QUIET — shipped, buried</span>
341
435
  <span class="lg"><i class="g g-absent"></i>ABSENT</span>
@@ -348,7 +442,7 @@ footer { margin-top:72px; border-top:3px double var(--ink); padding-top:14px; fo
348
442
  </section>
349
443
  ${axisHtml.strategicMap}
350
444
  <section>
351
- <h2><span class="no">${appendixNo}</span> Evidence appendix</h2>
445
+ <h2>Evidence appendix</h2>
352
446
  ${appendix}
353
447
  </section>
354
448
  <footer>
@@ -1,41 +1,70 @@
1
1
  import type { MarketConfig, ScaleSignal } from "./market.ts";
2
2
  /**
3
- * Relative scale index over the mapped vendor set — the honest version of
4
- * "bubble size = market share". True segment market share is unknowable from
5
- * public data for mostly-private vendor sets, so this computes a composite
6
- * index from whatever citable signals exist per vendor (review counts,
7
- * headcount, disclosed revenue, self-reported customers), each of which is
8
- * biased in a different direction; the composite triangulates.
3
+ * Relative scale estimation over the mapped vendor set — v2, dimensional.
9
4
  *
10
- * Method, deterministic and auditable:
11
- * 1. Per metric, log10(value + 1) these signals span orders of magnitude.
12
- * 2. Normalize each metric to [0, 1] across the vendors that HAVE it
13
- * (min–max within the set; a metric only one vendor has is skipped —
14
- * it cannot rank anyone).
15
- * 3. A vendor's index = arithmetic mean of its normalized metric scores
16
- * (mean-of-normalized rather than geometric-of-raw so missing signals
17
- * neither punish nor reward), reported with coverage (which metrics).
5
+ * v1 normalized every signal onto [0,1] and averaged, which quietly mixed
6
+ * dimensions: review/customer counts proxy CUSTOMER COUNT (N), while
7
+ * employees and revenue proxy REVENUE (N × ACV). Averaging the two inflates
8
+ * many-small-customer vendors against few-big-customer ones the SMB bias.
18
9
  *
19
- * Vendors with zero signals get index null the report falls back to its
20
- * LOUD-count sizing for the whole map rather than mixing semantics.
10
+ * v2 converts every signal into REVENUE SPACE before combining:
11
+ *
12
+ * 1. Signals are classed by dimension: revenue (used directly),
13
+ * headcount (× revenue-per-employee), customers (× revenue-per-customer).
14
+ * 2. Conversion ratios are CALIBRATED within the set, per metric, as the
15
+ * median ratio over vendors that have both the metric and a revenue
16
+ * signal — and customer-dimension ratios are stratified by each
17
+ * vendor's `acvBand` (smb / mid / enterprise), because revenue-per-
18
+ * review spans ~75× between SMB tools and enterprise suites. A band
19
+ * without calibration pairs falls back to the global median; a metric
20
+ * with no pairs anywhere is unusable and reported as skipped.
21
+ * 3. A vendor's estimated revenue is the weighted geometric mean of its
22
+ * per-signal estimates (revenue weight 3, headcount 2, customers 1 —
23
+ * reliability order), with an uncertainty band = max/min estimate
24
+ * ratio, reported, never hidden.
25
+ * 4. index = share of the set's summed estimated revenue; bubbles render
26
+ * area-proportional to it. Labeled "estimated revenue share" with the
27
+ * calibration disclosed — still never "market share" unqualified:
28
+ * it is revenue share OF THE MAPPED SET, from citable-but-unaudited
29
+ * signals.
30
+ *
31
+ * Deterministic and auditable end to end: same config, same estimates.
21
32
  */
33
+ export type ScaleDimension = "revenue" | "headcount" | "customers";
34
+ export declare function dimensionForMetric(metric: string): ScaleDimension;
35
+ export type SignalEstimate = {
36
+ metric: string;
37
+ dimension: ScaleDimension;
38
+ rawValue: number;
39
+ /** Revenue-per-unit ratio applied (1 for revenue signals); null = unusable. */
40
+ ratio: number | null;
41
+ estimatedRevenue: number | null;
42
+ /** What calibrated the ratio: "direct", "band:<name>", "global", "fallback". */
43
+ calibration: string;
44
+ };
22
45
  export type VendorScale = {
23
46
  vendorId: string;
24
- /** [0, 1] within the mapped set; null when the vendor has no usable signals. */
47
+ acvBand?: string;
48
+ estimates: SignalEstimate[];
49
+ /** Weighted geometric mean of usable estimates; null with no usable signals. */
50
+ estimatedRevenue: number | null;
51
+ /** max/min across usable estimates — 1 = perfect agreement among signals. */
52
+ uncertainty: number | null;
53
+ /** Share of the set's summed estimated revenue; drives bubble area. */
25
54
  index: number | null;
26
- /** Metrics that contributed, with their normalized scores. */
27
- coverage: Array<{
28
- metric: string;
29
- value: number;
30
- normalized: number;
31
- }>;
32
55
  signals: ScaleSignal[];
33
56
  };
34
57
  export type ScaleReport = {
35
58
  vendors: VendorScale[];
36
- /** Metrics used (present for ≥2 vendors) and metrics skipped (singletons). */
37
59
  metricsUsed: string[];
38
60
  metricsSkipped: string[];
61
+ /** Calibrated ratios for the appendix: metric × stratum → revenue-per-unit. */
62
+ calibrations: Array<{
63
+ metric: string;
64
+ stratum: string;
65
+ revenuePerUnit: number;
66
+ pairs: number;
67
+ }>;
39
68
  complete: boolean;
40
69
  };
41
70
  export declare function computeScaleIndex(config: MarketConfig): ScaleReport;