fullstackgtm 0.21.1 → 0.22.0

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.
@@ -107,7 +107,7 @@ 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
113
  /**
@@ -185,21 +185,27 @@ function svgScatter(
185
185
  const sy = (y: number) => H - PAD_BOTTOM - ((y - yLo) / (yHi - yLo)) * (H - PAD_TOP - PAD_BOTTOM);
186
186
  const e = escapeHtml;
187
187
 
188
- // Big bubbles first so small ones stay clickable/visible on top.
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.
189
191
  const ordered = [...points].sort((a, b) => b.size - a.size);
190
192
  const dots = ordered
191
193
  .map((p) => {
192
- const r = 7 + 24 * Math.sqrt(p.size);
194
+ const r = p.noScale ? 7 : 7 + 24 * Math.sqrt(p.size);
193
195
  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
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.
196
203
  const fs = Math.max(10, Math.min(14, r * 0.9));
197
- return (
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>`
202
- );
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>`;
203
209
  })
204
210
  .join("");
205
211
 
@@ -244,18 +250,26 @@ function axisSectionsHtml(
244
250
  // when every placeable vendor has one; LOUD count otherwise — never mix
245
251
  // the two semantics on one chart.
246
252
  const scale = computeScaleIndex(config);
253
+ const scaleByVendor = new Map(scale.vendors.map((vendor) => [vendor.vendorId, vendor]));
247
254
  const scaleIndex = new Map(scale.vendors.map((vendor) => [vendor.vendorId, vendor.index]));
248
- 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;
249
260
  const loudCounts = new Map(report.vendors.map((vendorId) => [vendorId, messageBreadth(vendorId, set.observations).loudCount]));
250
- const maxLoud = Math.max(1, ...loudCounts.values());
251
261
  // Bubble areas stay proportional to the metric; dividing by the max just
252
262
  // spends the full visual range without distorting any ratio.
253
263
  const maxShare = Math.max(1e-9, ...report.vendors.map((vendorId) => scaleIndex.get(vendorId) ?? 0));
254
- const sizeOf = (vendorId: string): number =>
255
- 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";
256
270
  const sizeCaption = useScale
257
- ? `Dot area &#8733; estimated revenue share of this vendor set (signals: ${e(scale.metricsUsed.join(", "))}; calibrated within-set, ACV-band stratified, citable but NOT audited — see \`market scale\` for estimates and spreads)`
258
- : "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";
259
273
 
260
274
  const breadthAxis: ScatterAxis & { id: string } = {
261
275
  id: "breadth",
@@ -298,6 +312,7 @@ function axisSectionsHtml(
298
312
  x: xs.get(vendorId) as number,
299
313
  y: ys.get(vendorId) as number,
300
314
  size: sizeOf(vendorId),
315
+ noScale: noScaleFor(vendorId),
301
316
  }));
302
317
  };
303
318
 
@@ -318,14 +333,39 @@ function axisSectionsHtml(
318
333
  const number = numberByVendor.get(point.vendorId);
319
334
  const color = colorByVendor.get(point.vendorId) as string;
320
335
  const isAnchor = point.vendorId === config.anchorVendor;
336
+ const share = scaleIndex.get(point.vendorId);
321
337
  const measure = useScale
322
- ? `${(((scaleIndex.get(point.vendorId) as number) ?? 0) * 100).toFixed(1)}%`
338
+ ? typeof share === "number"
339
+ ? `${(share * 100).toFixed(1)}%`
340
+ : "—"
323
341
  : `${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>`;
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>`;
325
343
  })
326
344
  .join("");
327
345
  const legendMeasureHead = useScale ? "est. share" : "loud";
328
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
+
329
369
  const strategicMap = `<section>
330
370
  <h2>Strategic map: ${e(axInfo.label)} &#215; ${e(ayInfo.label)}</h2>
331
371
  <figure class="map">
@@ -333,6 +373,47 @@ function axisSectionsHtml(
333
373
  ${svgScatter(points, axInfo, ayInfo, config.anchorVendor, colorByVendor, numberByVendor)}
334
374
  <table class="legend"><thead><tr><th></th><th>vendor</th><th class="num">${legendMeasureHead}</th></tr></thead><tbody>${legendRows}</tbody></table>
335
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>
336
417
  <figcaption>Positions computed from run ${e(set.runLabel)} observations: each axis is a per-claim scoring rubric
337
418
  in the market config; a vendor sits at the intensity-weighted mean (loud=1, quiet=&#189;) of the claims it
338
419
  voices. ${sizeCaption}. Axis status — ${e(axInfo.label)}: ${e(statusOf(px))}; ${e(ayInfo.label)}: ${e(statusOf(py))}.</figcaption>
@@ -357,50 +438,112 @@ export function marketMapToHtml(config: MarketConfig, set: ObservationSet): stri
357
438
  const e = escapeHtml;
358
439
  const axisHtml = axisSectionsHtml(config, set);
359
440
 
360
- const matrixRows = model.orderedClaimIds
361
- .map((claimId) => {
362
- const claim = claimsById.get(claimId);
363
- if (!claim) return "";
364
- const state = stateByClaim.get(claimId) ?? "vacant";
365
- const cells = config.vendors
366
- .map((vendor) => {
367
- const intensity = model.cell(vendor.id, claimId)?.intensity ?? "unobservable";
368
- const anchorClass = vendor.id === anchor ? " anchor-col" : "";
369
- return `<td class="cell${anchorClass}"><span class="g g-${intensity}" title="${e(vendor.name)}: ${intensity}"></span></td>`;
370
- })
371
- .join("");
372
- return (
373
- `<tr class="front-${state}"><th scope="row"><span class="claim-cap">${e(claim.capability.split(":")[0])}</span>` +
374
- `<span class="claim-meta">${e(claim.icp)} · ${e(claim.pricingStructure)}</span></th>${cells}` +
375
- `<td class="front"><span class="chip chip-${state}">${state.toUpperCase()}</span></td></tr>`
376
- );
377
- })
378
- .join("");
441
+ const vendorNamesById = new Map(config.vendors.map((vendor) => [vendor.id, vendor.name]));
442
+ const frontByClaim = new Map(model.fronts.map((front) => [front.claimId, front]));
443
+
444
+ const matrixRow = (claimId: string): string => {
445
+ const claim = claimsById.get(claimId);
446
+ if (!claim) return "";
447
+ const state = stateByClaim.get(claimId) ?? "vacant";
448
+ const cells = config.vendors
449
+ .map((vendor) => {
450
+ const intensity = model.cell(vendor.id, claimId)?.intensity ?? "unobservable";
451
+ const anchorClass = vendor.id === anchor ? " anchor-col" : "";
452
+ return `<td class="cell${anchorClass}"><span class="g g-${intensity}" title="${e(vendor.name)}: ${intensity}"></span></td>`;
453
+ })
454
+ .join("");
455
+ return (
456
+ `<tr class="front-${state}"><th scope="row"><span class="claim-cap">${e(claim.capability.split(":")[0])}</span>` +
457
+ `<span class="claim-meta">${e(claim.icp)} · ${e(claim.pricingStructure)}</span></th>${cells}` +
458
+ `<td class="front"><span class="chip chip-${state}">${state.toUpperCase()}</span></td></tr>`
459
+ );
460
+ };
379
461
 
380
- const openList = model.orderedClaimIds
381
- .filter((claimId) => {
382
- const state = stateByClaim.get(claimId);
383
- return state === "open" || state === "vacant";
384
- })
462
+ // Claims grouped by front state, each group a collapsed <details> whose
463
+ // summary carries the stats a skimmer needs; the full matrix is one click
464
+ // away, not a wall the reader must climb to reach the takeaway.
465
+ const GROUPS: Array<{ states: FrontState[]; title: string; blurb: string }> = [
466
+ { states: ["open", "vacant"], title: "Open ground", blurb: "no vendor is loud here" },
467
+ { states: ["contested"], title: "Contested fronts", blurb: "2–3 vendors loud" },
468
+ { states: ["owned"], title: "Owned fronts", blurb: "exactly one vendor loud" },
469
+ { states: ["saturated"], title: "Saturated fronts", blurb: "4+ vendors loud" },
470
+ ];
471
+ const groupedMatrix = GROUPS.map((group) => {
472
+ const claimIds = model.orderedClaimIds.filter((claimId) => group.states.includes(stateByClaim.get(claimId) ?? "vacant"));
473
+ if (claimIds.length === 0) return "";
474
+ const anchorLoud = anchor
475
+ ? claimIds.filter((claimId) => model.cell(anchor, claimId)?.intensity === "loud").length
476
+ : 0;
477
+ const anchorNote = anchor ? ` · ${vendorNamesById.get(anchor) ?? anchor} loud on ${anchorLoud}` : "";
478
+ return `<details class="claim-group"><summary><b>${e(group.title)}</b> — ${claimIds.length} claim${claimIds.length === 1 ? "" : "s"} <span class="sum-soft">(${e(group.blurb)}${anchorNote})</span></summary>
479
+ <table><thead><tr><th></th>${"${vendorHeads}"}<th></th></tr></thead><tbody>${claimIds.map(matrixRow).join("")}</tbody></table>
480
+ </details>`;
481
+ }).join("");
482
+
483
+ // The closing argument: walk from open ground to a reasoned target list.
484
+ const openFronts = model.orderedClaimIds.filter((claimId) => {
485
+ const state = stateByClaim.get(claimId);
486
+ return state === "open" || state === "vacant";
487
+ });
488
+ const targetItems = openFronts
385
489
  .map((claimId) => {
386
490
  const claim = claimsById.get(claimId);
387
- return `<li><b>${e(claim?.capability.split(":")[0] ?? claimId)}</b> <span class="why">— no vendor is loud here; ${e(claim?.icp ?? "")} cell</span></li>`;
491
+ const front = frontByClaim.get(claimId);
492
+ if (!claim || !front) return "";
493
+ const quietNames = front.quietVendorIds.map((id) => vendorNamesById.get(id) ?? id);
494
+ const anchorIntensity = anchor ? model.cell(anchor, claimId)?.intensity ?? "unobservable" : null;
495
+ const nearest =
496
+ quietNames.length > 0
497
+ ? `Closest contenders (quiet): ${quietNames.join(", ")}.`
498
+ : "Nobody even ships it quietly — vacant ground.";
499
+ const move =
500
+ anchorIntensity === "quiet"
501
+ ? `${e(vendorNamesById.get(anchor as string) ?? "The anchor")} already ships this quietly — a promote-to-loud candidate.`
502
+ : anchorIntensity === "absent"
503
+ ? "Unclaimed by the anchor: a first-mover messaging opportunity if the capability is real or buildable."
504
+ : "";
505
+ return `<li><b>${e(claim.capability.split(":")[0])}</b> <span class="sum-soft">(${e(claim.icp)} · ${e(claim.pricingStructure)})</span><br>
506
+ No vendor is loud on this claim. ${e(nearest)} ${move}</li>`;
388
507
  })
389
508
  .join("");
509
+ const heldFronts = anchor
510
+ ? model.fronts.filter((front) => front.state === "owned" && front.loudVendorIds[0] === anchor)
511
+ : [];
512
+ const heldLine =
513
+ heldFronts.length > 0
514
+ ? `<p><b>Held ground:</b> ${e(vendorNamesById.get(anchor as string) ?? "the anchor")} is the sole loud vendor on ${heldFronts
515
+ .map((front) => `<i>${e(claimsById.get(front.claimId)?.capability.split(":")[0] ?? front.claimId)}</i>`)
516
+ .join(", ")} — positions to defend, not abandon.</p>`
517
+ : "";
518
+ const crowdLine =
519
+ counts.saturated > 0
520
+ ? `<p><b>Crowded ground:</b> ${counts.saturated} claim${counts.saturated === 1 ? " is" : "s are"} saturated (4+ vendors loud) — message budget spent there buys the least differentiation.</p>`
521
+ : "";
522
+ const takeaway = `<section>
523
+ <h2>Where to attack</h2>
524
+ <p class="lede">${openFronts.length === 0 ? "No open fronts this run — every claim has at least one loud vendor. Watch the drift between runs for windows opening." : `${openFronts.length} claim${openFronts.length === 1 ? "" : "s"} in this category ${openFronts.length === 1 ? "is" : "are"} open: buyers can be reached there without out-shouting anyone.`}</p>
525
+ <ul class="targets">${targetItems}</ul>
526
+ ${heldLine}
527
+ ${crowdLine}
528
+ <p class="sum-soft">These are messaging fronts, not verdicts — join the map to CRM ground truth (\`market overlay\`) for evidence-backed OCCUPY / PROMOTE / URGENT / RETREAT directives with win-rate stats.</p>
529
+ </section>`;
390
530
 
391
- const appendix = model.orderedClaimIds
392
- .flatMap((claimId) =>
393
- config.vendors.flatMap((vendor) => {
531
+ // Evidence grouped by vendor, collapsed: receipts on demand, not a scroll wall.
532
+ const appendixGroups = config.vendors
533
+ .map((vendor) => {
534
+ const items = model.orderedClaimIds.flatMap((claimId) => {
394
535
  const obs = model.cell(vendor.id, claimId);
395
536
  if (!obs || obs.evidence.length === 0) return [];
396
537
  return obs.evidence.map(
397
538
  (evidence) =>
398
- `<div class="ev"><span class="ev-head">${e(vendor.name)} · ${e(claimId)} · ${obs.intensity.toUpperCase()} (${obs.confidence})</span>` +
539
+ `<div class="ev"><span class="ev-head">${e(claimId)} · ${obs.intensity.toUpperCase()} (${obs.confidence})</span>` +
399
540
  `<blockquote>“${e(evidence.text)}”</blockquote>` +
400
541
  `<span class="ev-src">${e(String(evidence.metadata?.url ?? ""))} · capture ${e(String(evidence.metadata?.captureHash ?? "").slice(0, 12))}</span></div>`,
401
542
  );
402
- }),
403
- )
543
+ });
544
+ if (items.length === 0) return "";
545
+ return `<details class="ev-group"><summary><b>${e(vendor.name)}</b> <span class="sum-soft">— ${items.length} quoted span${items.length === 1 ? "" : "s"}</span></summary>${items.join("")}</details>`;
546
+ })
404
547
  .join("");
405
548
 
406
549
  const vendorHeads = config.vendors
@@ -425,14 +568,16 @@ h1 { font-size:27px; font-weight:600; line-height:1.2; }
425
568
  .meta { font-size:11px; color:var(--soft); display:flex; gap:18px; flex-wrap:wrap; margin-top:8px; }
426
569
  section { margin-top:44px; }
427
570
  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); }
432
- .fcard.open b { color:var(--accent); }
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; }
571
+ details.claim-group, details.ev-group { border:1px solid var(--line); border-radius:3px; margin-top:10px; }
572
+ details.claim-group summary, details.ev-group summary { cursor:pointer; padding:10px 14px; font-size:14px; list-style-position:inside; }
573
+ details.claim-group summary:hover, details.ev-group summary:hover { background:var(--faint); }
574
+ details.claim-group[open] summary, details.ev-group[open] summary { border-bottom:1px solid var(--line); }
575
+ details.claim-group table { margin:4px 12px 12px; width:calc(100% - 24px); }
576
+ details.ev-group .ev { margin:0 14px; }
577
+ .sum-soft { color:var(--soft); font-size:12px; }
578
+ .lede { font-size:16px; margin-top:12px; }
579
+ .targets { margin-top:12px; font-size:14.5px; line-height:1.6; }
580
+ .targets li { margin:10px 0 10px 20px; }
436
581
  .key { display:flex; gap:20px; flex-wrap:wrap; margin:14px 0 8px; font-size:10.5px; color:var(--soft); }
437
582
  .lg { display:inline-flex; align-items:center; gap:6px; }
438
583
  table { border-collapse:collapse; width:100%; margin-top:6px; }
@@ -458,7 +603,15 @@ tr.front-open th .claim-cap { color:var(--accent); font-weight:600; }
458
603
  .ev-head { font-size:10.5px; color:var(--soft); font-weight:600; }
459
604
  .ev blockquote { margin:5px 0; font-size:13.5px; line-height:1.5; }
460
605
  .ev-src { font-size:10px; color:var(--soft); word-break:break-all; }
461
- figure.map { margin-top:16px; border:1px solid var(--line); }
606
+ figure.map { margin-top:16px; border:1px solid var(--line); position:relative; }
607
+ g.bubble { cursor:pointer; }
608
+ g.bubble.dim { opacity:0.25; transition:opacity .12s; }
609
+ table.legend tbody tr { cursor:default; }
610
+ table.legend tbody tr.hl td { background:var(--faint); }
611
+ .map-tip { position:absolute; z-index:5; background:#1c1c1c; color:#fff; font-size:11.5px; line-height:1.45;
612
+ padding:8px 10px; border-radius:3px; pointer-events:none; max-width:260px;
613
+ font-family:ui-monospace,"SF Mono",Menlo,Consolas,monospace; }
614
+ .map-tip b { display:block; margin-bottom:3px; }
462
615
  .map-row { display:flex; gap:8px; align-items:flex-start; padding:10px 12px 0; }
463
616
  .map-row svg { flex:1 1 62%; min-width:0; }
464
617
  .plot { fill:var(--faint); stroke:var(--line); }
@@ -474,7 +627,7 @@ table.legend tr.anchor-row td { font-weight:700; }
474
627
  figcaption { font-size:11.5px; color:var(--soft); padding:10px 14px 12px; border-top:1px solid var(--line); line-height:1.5; }
475
628
  footer { margin-top:60px; border-top:1px solid var(--ink); padding-top:12px; font-size:11px; color:var(--soft);
476
629
  display:flex; justify-content:space-between; gap:20px; flex-wrap:wrap; }
477
- @media print { body { max-width:none; padding:0 8mm; } section { break-inside:avoid-page; } tr { break-inside:avoid; } .map-row { display:block; } }
630
+ @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; } }
478
631
  @media (max-width:760px) { .map-row { display:block; } }
479
632
  </style></head><body>
480
633
  <header>
@@ -485,34 +638,24 @@ footer { margin-top:60px; border-top:1px solid var(--ink); padding-top:12px; fon
485
638
  <span>extractor: ${e(set.extractor)}</span>
486
639
  </div>
487
640
  </header>
641
+ ${axisHtml.strategicMap}
488
642
  <section>
489
- <h2>Front summary</h2>
490
- <div class="fronts">
491
- <div class="fcard open"><b>${counts.open + counts.vacant}</b><span>Open / vacant</span></div>
492
- <div class="fcard"><b>${counts.contested}</b><span>Contested</span></div>
493
- <div class="fcard"><b>${counts.owned}</b><span>Owned</span></div>
494
- <div class="fcard"><b>${counts.saturated}</b><span>Saturated</span></div>
495
- </div>
496
- <ul class="openlist">${openList}</ul>
497
- </section>
498
- <section>
499
- <h2>Claim × vendor intensity matrix</h2>
643
+ <h2>Claims, front by front</h2>
500
644
  <div class="key">
501
645
  <span class="lg"><i class="g g-loud"></i>LOUD — hero-level claim</span>
502
646
  <span class="lg"><i class="g g-quiet"></i>QUIET — shipped, buried</span>
503
647
  <span class="lg"><i class="g g-absent"></i>ABSENT</span>
504
648
  <span class="lg"><i class="g g-unobservable"></i>UNOBSERVABLE — capture failed</span>
505
649
  </div>
506
- <table>
507
- <thead><tr><th></th>${vendorHeads}<th></th></tr></thead>
508
- <tbody>${matrixRows}</tbody>
509
- </table>
650
+ ${groupedMatrix}
510
651
  </section>
511
- ${axisHtml.strategicMap}
652
+ ${takeaway}
512
653
  <section>
513
654
  <h2>Evidence appendix</h2>
514
- ${appendix}
655
+ <p class="sum-soft">Every loud/quiet reading is grounded in a verbatim span from a stored page capture; expand a vendor for its receipts.</p>
656
+ ${appendixGroups}
515
657
  </section>
658
+ <script>window.addEventListener("beforeprint",function(){document.querySelectorAll("details").forEach(function(d){d.open=true;});});</script>
516
659
  <footer>
517
660
  <span>Generated by fullstackgtm market · deterministic render of ${e(set.runLabel)}</span>
518
661
  <span>Front rule v1: 0 loud=open · 1=owned · 2–3=contested · ≥4=saturated</span>
@@ -0,0 +1,117 @@
1
+ /**
2
+ * The ownership-handoff playbook: `reassign` compiles one bulk-update-style
3
+ * dry-run plan PER object type (accounts, contacts, deals by default) that
4
+ * moves every record owned by --from to --to. It NEVER writes — each plan
5
+ * flows through the same plans-approve → apply gate, and because every plan
6
+ * carries its full filter, eligibility (extra --where scoping AND the
7
+ * --except-deal-stage exclusion) is re-verified per record against a FRESH
8
+ * snapshot at apply time, with mid-apply rechecks. A record that drifts into
9
+ * the exception set mid-run surfaces as a conflict, not a bad write.
10
+ *
11
+ * --except-deal-stage <stage> excludes in-flight business end to end:
12
+ * - deal plans drop deals in that stage (stage!=<stage>), and
13
+ * - ALL plans drop records whose account has an OPEN deal in that stage
14
+ * (accounts: openDealStages!~<stage>; deals/contacts:
15
+ * account.openDealStages!~<stage>).
16
+ *
17
+ * Deal plans cover OPEN deals only unless includeClosedDeals is set: closed
18
+ * deals keep their historical owner in a handoff.
19
+ *
20
+ * Extra --where clauses are account-scoped where needed: a clause on a field
21
+ * that only exists on accounts (e.g. domain~.de) is lifted to the relational
22
+ * pseudo-field (account.domain~.de) for contact and deal plans, so one
23
+ * invocation scopes all three object types consistently.
24
+ */
25
+ import { buildBulkUpdatePlan, isFilterableField, parseWhere } from "./bulkUpdate.ts";
26
+ import type { CanonicalGtmSnapshot, PatchPlan } from "./types.ts";
27
+
28
+ export type ReassignObjectType = "account" | "contact" | "deal";
29
+
30
+ export type ReassignOptions = {
31
+ /** the owner whose records are being handed off */
32
+ fromOwnerId: string;
33
+ /** the receiving owner — must be a known user in the snapshot */
34
+ toOwnerId: string;
35
+ /** which object types to compile plans for (default all three) */
36
+ objects?: ReassignObjectType[];
37
+ /** extra --where scoping, AND-ed into every plan (account fields lifted) */
38
+ where?: string[];
39
+ /** exclude records tied to an open deal in this stage (see module doc) */
40
+ exceptDealStage?: string;
41
+ /** also reassign closed deals (default false: history keeps its owner) */
42
+ includeClosedDeals?: boolean;
43
+ reason?: string;
44
+ maxOperations?: number;
45
+ };
46
+
47
+ const ALL_OBJECTS: ReassignObjectType[] = ["account", "contact", "deal"];
48
+
49
+ /**
50
+ * Scope an extra --where clause to one object type: pass through when the
51
+ * field is valid there, lift account-only fields to account.<field> for
52
+ * contacts and deals. Unknown fields fall through to buildBulkUpdatePlan's
53
+ * strict validation, which throws with the valid-field list.
54
+ */
55
+ function scopeWhere(objectType: ReassignObjectType, raw: string): string {
56
+ const clause = parseWhere(raw);
57
+ if (isFilterableField(objectType, clause.field)) return raw;
58
+ if (objectType !== "account" && isFilterableField(objectType, `account.${clause.field}`)) {
59
+ return `account.${raw}`;
60
+ }
61
+ return raw;
62
+ }
63
+
64
+ export function buildReassignPlans(
65
+ snapshot: CanonicalGtmSnapshot,
66
+ options: ReassignOptions,
67
+ ): PatchPlan[] {
68
+ if (!options.fromOwnerId || !options.toOwnerId) {
69
+ throw new Error("reassign requires both --from <ownerId> and --to <ownerId>.");
70
+ }
71
+ if (options.fromOwnerId === options.toOwnerId) {
72
+ throw new Error("reassign --from and --to are the same owner — nothing to hand off.");
73
+ }
74
+ // The receiving owner must exist: a typo'd --to would otherwise write an
75
+ // invalid owner onto every matched record.
76
+ if (!snapshot.users.some((user) => user.id === options.toOwnerId)) {
77
+ throw new Error(
78
+ `reassign --to ${options.toOwnerId} is not a known user in the snapshot. Known users: ${snapshot.users
79
+ .map((user) => `${user.id} (${user.name})`)
80
+ .join(", ") || "none"}.`,
81
+ );
82
+ }
83
+ const objects = options.objects ?? ALL_OBJECTS;
84
+ for (const objectType of objects) {
85
+ if (!ALL_OBJECTS.includes(objectType)) {
86
+ throw new Error(`reassign --objects supports account, contact, deal — got "${objectType}".`);
87
+ }
88
+ }
89
+
90
+ return objects.map((objectType) => {
91
+ const where = [`ownerId=${options.fromOwnerId}`];
92
+ if (objectType === "deal" && !options.includeClosedDeals) {
93
+ where.push("isClosed=false"); // closed deals keep their historical owner
94
+ }
95
+ for (const extra of options.where ?? []) {
96
+ where.push(scopeWhere(objectType, extra));
97
+ }
98
+ if (options.exceptDealStage) {
99
+ const stage = options.exceptDealStage;
100
+ if (objectType === "deal") where.push(`stage!=${stage}`);
101
+ where.push(
102
+ objectType === "account"
103
+ ? `openDealStages!~${stage}`
104
+ : `account.openDealStages!~${stage}`,
105
+ );
106
+ }
107
+ return buildBulkUpdatePlan(snapshot, {
108
+ objectType,
109
+ where,
110
+ set: { ownerId: options.toOwnerId },
111
+ reason:
112
+ options.reason ??
113
+ `reassign: hand off ${objectType}s from owner ${options.fromOwnerId} to ${options.toOwnerId}`,
114
+ maxOperations: options.maxOperations,
115
+ });
116
+ });
117
+ }
package/src/suggest.ts CHANGED
@@ -114,11 +114,40 @@ function suggestDealAccount(
114
114
  return { ...base, suggestedValue: null, confidence: "none", reason: "Deal not found in the snapshot." };
115
115
  }
116
116
 
117
- // Convention: "Contact Name - Company Name". Both signals below are
118
- // independent; agreement upgrades confidence, conflict downgrades it.
119
- const separatorIndex = deal.name.indexOf(" - ");
120
- const left = separatorIndex >= 0 ? deal.name.slice(0, separatorIndex) : deal.name;
121
- const right = separatorIndex >= 0 ? deal.name.slice(separatorIndex + 3).trim() : "";
117
+ // Convention 1: "Contact Name - Company Name" (hyphen, en or em dash).
118
+ // Both signals below are independent; agreement upgrades confidence,
119
+ // conflict downgrades it.
120
+ const separator = [" - ", " – ", " "]
121
+ .map((s) => ({ s, index: deal.name.indexOf(s) }))
122
+ .filter(({ index }) => index >= 0)
123
+ .sort((a, b) => a.index - b.index)[0];
124
+ const left = separator ? deal.name.slice(0, separator.index).trim() : deal.name;
125
+ const right = separator ? deal.name.slice(separator.index + separator.s.length).trim() : "";
126
+
127
+ // Convention 2: "Company - Deal descriptor" (e.g. "Globex – Expansion",
128
+ // "Hooli – New Business"). When the right side is purely deal-descriptor
129
+ // words, the company is on the LEFT. Only an exact account-name match
130
+ // counts as high; an unknown left side proposes a create — the engine
131
+ // never guesses at an existing record.
132
+ if (left && right && isDealDescriptor(right)) {
133
+ const leftMatch = accountsByNorm.get(normalize(left));
134
+ if (leftMatch) {
135
+ return {
136
+ ...base,
137
+ suggestedValue: leftMatch.id,
138
+ confidence: "high",
139
+ reason: `Deal name leads with the exact account name "${leftMatch.name}" ("${right}" is a deal descriptor, not a company).`,
140
+ };
141
+ }
142
+ if (!contactsByName.get(normalize(left))) {
143
+ return {
144
+ ...base,
145
+ suggestedValue: `create:${left}`,
146
+ confidence: "create",
147
+ reason: `No account named "${left}" exists ("${right}" is a deal descriptor) — approving creates the company, then links.`,
148
+ };
149
+ }
150
+ }
122
151
 
123
152
  // Signal 1: company-name match against account names.
124
153
  let nameMatch: { id: string; name: string } | null = null;
@@ -189,6 +218,22 @@ function suggestDealAccount(
189
218
  reason: `No account named "${right}" exists and contact "${left}" has none — approving creates the company, then links.`,
190
219
  };
191
220
  }
221
+ // Convention 3: "<Company> <descriptor…>" without a separator (e.g.
222
+ // "INITECH renewal"): strip trailing descriptor words and accept only an
223
+ // exact account-name match on what remains.
224
+ if (!separator) {
225
+ const words = normalize(deal.name).split(" ").filter(Boolean);
226
+ while (words.length > 1 && DEAL_DESCRIPTOR_WORDS.has(words[words.length - 1])) words.pop();
227
+ const strippedMatch = words.length > 0 ? accountsByNorm.get(words.join(" ")) : undefined;
228
+ if (strippedMatch) {
229
+ return {
230
+ ...base,
231
+ suggestedValue: strippedMatch.id,
232
+ confidence: "high",
233
+ reason: `Deal name leads with the exact account name "${strippedMatch.name}" (trailing words are deal descriptors).`,
234
+ };
235
+ }
236
+ }
192
237
  return {
193
238
  ...base,
194
239
  suggestedValue: null,
@@ -199,6 +244,25 @@ function suggestDealAccount(
199
244
  };
200
245
  }
201
246
 
247
+ /**
248
+ * Words that describe the deal rather than name a company. Used to recognize
249
+ * the "Company - Deal descriptor" naming convention: a segment counts as a
250
+ * descriptor only when EVERY word is in this list, so any real company name
251
+ * ("Brand New Startup") falls through to the contact/company conventions.
252
+ */
253
+ const DEAL_DESCRIPTOR_WORDS = new Set([
254
+ "renewal", "expansion", "pilot", "annual", "monthly", "platform", "new", "business",
255
+ "add", "on", "addon", "upsell", "upgrade", "trial", "poc", "proof", "concept",
256
+ "subscription", "license", "licence", "contract", "opportunity", "deal", "engagement",
257
+ "implementation", "onboarding", "services", "inbound", "outbound",
258
+ "q1", "q2", "q3", "q4",
259
+ ]);
260
+
261
+ function isDealDescriptor(segment: string): boolean {
262
+ const words = normalize(segment).split(" ").filter(Boolean);
263
+ return words.length > 0 && words.every((word) => DEAL_DESCRIPTOR_WORDS.has(word));
264
+ }
265
+
202
266
  /**
203
267
  * Survivor selection for merge_records. Ranking is deterministic and
204
268
  * evidence-based: most complete record first (count of populated canonical