fullstackgtm 0.31.0 → 0.33.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.
package/src/cli.ts CHANGED
@@ -41,6 +41,14 @@ import { auditReportToHtml, auditReportToMarkdown, type ReportOptions } from "./
41
41
  import { builtinAuditRules } from "./rules.ts";
42
42
  import { sampleSnapshot } from "./sampleData.ts";
43
43
  import { normalizeTranscript, parseCall, suggestCallDeal, type ExtractedCallInsight, type ParsedCall } from "./calls.ts";
44
+ import {
45
+ classifyCall,
46
+ rubricForCallType,
47
+ rubricPresets,
48
+ CALL_TYPES,
49
+ CALL_TYPE_IDS,
50
+ type CallType,
51
+ } from "./callTypes.ts";
44
52
  import {
45
53
  captureMarket,
46
54
  computeFrontStates,
@@ -67,6 +75,7 @@ import { buildWorksheet, classifyMarket } from "./marketClassify.ts";
67
75
  import { marketMapToHtml, marketMapToMarkdown } from "./marketReport.ts";
68
76
  import {
69
77
  DEFAULT_RUBRIC,
78
+ classifyCallLlm,
70
79
  detectProviderFromKey,
71
80
  extractInsightsLlm,
72
81
  parseRubric,
@@ -75,6 +84,7 @@ import {
75
84
  validateLlmKey,
76
85
  type CallScorecard,
77
86
  type LlmProvider,
87
+ type Rubric,
78
88
  } from "./llm.ts";
79
89
  import {
80
90
  buildEnrichPlan,
@@ -159,7 +169,8 @@ Usage:
159
169
  fullstackgtm diff --before <a.json> --after <b.json> [--json] [--fail-on-new-findings]
160
170
  fullstackgtm merge --input <a.json> --input <b.json> [...] --out <merged.json> [--json]
161
171
  fullstackgtm call parse --transcript <file> [--title t] [--source fathom|granola|...] [--model m] [--deterministic] [--json|--ndjson] [--out <path>]
162
- fullstackgtm call score --transcript <file>|--call <parsed.json> [--rubric <rubric.json>] [--model m] [--json|--out <path>]
172
+ fullstackgtm call classify --transcript <file>|--call <parsed.json> [--llm] [--deterministic] [--json]
173
+ fullstackgtm call score --transcript <file>|--call <parsed.json> [--call-type <t>] [--rubric <rubric.json>] [--model m] [--json|--out <path>]
163
174
  fullstackgtm call link --attendees <a@x.com,...> | --domain <x.com> [source options] [--json]
164
175
  fullstackgtm call plan --transcript <file>|--call <parsed.json> --deal <id> [source options] [--save|--json]
165
176
  calls become evidence: LLM extraction by default (bring your own
@@ -649,14 +660,20 @@ function parseValueOverrides(args: string[]) {
649
660
  async function callCommand(args: string[]) {
650
661
  const [subcommand, ...rest] = args;
651
662
  if (args.includes("--help") || args.includes("-h")) {
652
- console.log(`call parse --transcript <file> [--title t] [--source s] [--model m] [--deterministic] [--json|--ndjson] [--out <path>]
653
- call score --transcript <file>|--call <parsed.json> [--rubric <rubric.json>] [--model m] [--json|--out <path>]
654
- call link --attendees <a@x.com,...> | --domain <x.com> [source options] [--json]
655
- call plan --transcript <file>|--call <parsed.json> --deal <id> [source options] [--save|--json]
663
+ console.log(`call parse --transcript <file> [--title t] [--source s] [--model m] [--deterministic] [--json|--ndjson] [--out <path>]
664
+ call classify --transcript <file>|--call <parsed.json> [--llm] [--deterministic] [--json] [--list]
665
+ call score --transcript <file>|--call <parsed.json> [--call-type <t>] [--rubric <rubric.json>] [--model m] [--json|--out <path>] [--list-rubrics]
666
+ call link --attendees <a@x.com,...> | --domain <x.com> [source options] [--json]
667
+ call plan --transcript <file>|--call <parsed.json> --deal <id> [source options] [--save|--json]
668
+
669
+ classify picks the call type (deterministic signals; --llm for a model tiebreak).
670
+ score auto-selects the type-specific rubric from that classification unless you
671
+ pass --call-type or --rubric. Call types: ${CALL_TYPE_IDS.join(", ")}.
656
672
 
657
673
  parse/score default to LLM extraction (Anthropic or OpenAI key via env,
658
- \`login anthropic|openai\`, or a one-time prompt). parse --deterministic is
659
- the free keyword baseline; score always needs a key (scoring is LLM work).`);
674
+ \`login anthropic|openai\`, or a one-time prompt). parse --deterministic is the
675
+ free keyword baseline and classify --deterministic needs no key.
676
+ score always needs a key (scoring is LLM work).`);
660
677
  return;
661
678
  }
662
679
 
@@ -692,6 +709,64 @@ the free keyword baseline; score always needs a key (scoring is LLM work).`);
692
709
  });
693
710
  };
694
711
 
712
+ // Reconstruct plain transcript text from either a --transcript file (any
713
+ // dialect, normalized) or a parsed --call JSON. Shared by classify + score.
714
+ const loadTranscriptText = (): string => {
715
+ const transcriptPath = option(rest, "--transcript");
716
+ if (transcriptPath) {
717
+ return normalizeTranscript(readFileSync(resolve(process.cwd(), transcriptPath), "utf8"));
718
+ }
719
+ const callPath = option(rest, "--call");
720
+ if (!callPath) throw new Error(`call ${subcommand} requires --transcript <file> or --call <parsed.json>`);
721
+ const parsed = JSON.parse(readFileSync(resolve(process.cwd(), callPath), "utf8")) as ParsedCall;
722
+ return parsed.segments.map((s) => (s.speaker ? `${s.speaker}: ${s.text}` : s.text)).join("\n");
723
+ };
724
+
725
+ if (subcommand === "classify") {
726
+ if (rest.includes("--list")) {
727
+ const lines = CALL_TYPES.map((d) => `${d.id.padEnd(22)} ${d.name} — ${d.definition}`);
728
+ console.log(rest.includes("--json") ? JSON.stringify(CALL_TYPES, null, 2) : lines.join("\n"));
729
+ return;
730
+ }
731
+ const transcriptText = loadTranscriptText();
732
+ const title = option(rest, "--title") ?? undefined;
733
+ const deterministic = classifyCall(transcriptText);
734
+ // LLM tiebreak: explicit --llm, or auto when the deterministic pass is unsure
735
+ // and a key is available (never required — deterministic always answers).
736
+ const wantLlm = rest.includes("--llm") || (!rest.includes("--deterministic") && deterministic.confidence !== "high" && Boolean(resolveLlmCredential()));
737
+ let result: {
738
+ type: CallType;
739
+ confidence: string;
740
+ reason: string;
741
+ method: string;
742
+ candidates?: typeof deterministic.candidates;
743
+ model?: string;
744
+ } = deterministic;
745
+ if (wantLlm) {
746
+ const credential = await requireLlmCredential("score");
747
+ const llm = await classifyCallLlm(transcriptText, CALL_TYPES, {
748
+ ...credential,
749
+ model: option(rest, "--model") ?? undefined,
750
+ title,
751
+ });
752
+ result = { type: llm.type, confidence: "high", reason: llm.reason, method: "llm", model: llm.model };
753
+ }
754
+ if (rest.includes("--json")) {
755
+ console.log(JSON.stringify(result, null, 2));
756
+ return;
757
+ }
758
+ const def = CALL_TYPES.find((d) => d.id === result.type);
759
+ console.log(`Call type: ${def?.name ?? result.type} (${result.type})`);
760
+ console.log(`Confidence: ${result.confidence} · via ${result.method}${result.model ? ` (${result.model})` : ""}`);
761
+ console.log(`Why: ${result.reason}`);
762
+ if (result.method === "deterministic" && deterministic.candidates.length > 1) {
763
+ const others = deterministic.candidates.slice(1, 4).map((c) => `${c.type} (${c.score})`).join(", ");
764
+ console.log(`Other matches: ${others}`);
765
+ }
766
+ console.log(`\nScore it with this rubric: fullstackgtm call score ${option(rest, "--transcript") ? `--transcript ${option(rest, "--transcript")}` : `--call ${option(rest, "--call")}`} --call-type ${result.type}`);
767
+ return;
768
+ }
769
+
695
770
  if (subcommand === "parse") {
696
771
  const parsed = await loadParsedCall();
697
772
  const outPath = option(rest, "--out");
@@ -779,9 +854,13 @@ the free keyword baseline; score always needs a key (scoring is LLM work).`);
779
854
  }
780
855
 
781
856
  if (subcommand === "score") {
782
- // Rubric problems surface before any credential or API work.
857
+ if (rest.includes("--list-rubrics")) {
858
+ console.log(JSON.stringify(rubricPresets(), null, 2));
859
+ return;
860
+ }
861
+ // Explicit-rubric problems surface before any credential or API work.
783
862
  const rubricPath = option(rest, "--rubric");
784
- let rubric = DEFAULT_RUBRIC;
863
+ let rubric: Rubric | undefined;
785
864
  if (rubricPath) {
786
865
  const rubricRaw = readFileSync(resolve(process.cwd(), rubricPath), "utf8");
787
866
  try {
@@ -792,6 +871,10 @@ the free keyword baseline; score always needs a key (scoring is LLM work).`);
792
871
  );
793
872
  }
794
873
  }
874
+ const callTypeOpt = option(rest, "--call-type") as CallType | undefined;
875
+ if (callTypeOpt && !CALL_TYPE_IDS.includes(callTypeOpt)) {
876
+ throw new Error(`Unknown --call-type "${callTypeOpt}". One of: ${CALL_TYPE_IDS.join(", ")}.`);
877
+ }
795
878
  const credential = await requireLlmCredential("score");
796
879
  const transcriptPath = option(rest, "--transcript");
797
880
  let transcriptText: string;
@@ -807,6 +890,17 @@ the free keyword baseline; score always needs a key (scoring is LLM work).`);
807
890
  .join("\n");
808
891
  title = title ?? parsed.title;
809
892
  }
893
+ // Rubric selection: explicit --rubric wins, then --call-type, else the
894
+ // deterministic classifier picks the type-specific preset. No generic
895
+ // discovery rubric silently applied to a renewal anymore.
896
+ if (!rubric) {
897
+ const type = callTypeOpt ?? classifyCall(transcriptText).type;
898
+ rubric = rubricForCallType(type, DEFAULT_RUBRIC);
899
+ if (!rest.includes("--json")) {
900
+ const how = callTypeOpt ? `--call-type ${callTypeOpt}` : `auto-classified as ${type}`;
901
+ console.error(`Scoring with the "${rubric.name ?? "Generic"}" rubric (${how}). Override with --rubric <file> or --call-type <type>.`);
902
+ }
903
+ }
810
904
  const scorecard = await scoreCallLlm(transcriptText, rubric, {
811
905
  ...credential,
812
906
  model: option(rest, "--model") ?? undefined,
@@ -822,7 +916,7 @@ the free keyword baseline; score always needs a key (scoring is LLM work).`);
822
916
  return;
823
917
  }
824
918
 
825
- throw new Error(`call supports: parse, link, plan, score (got ${subcommand ?? "nothing"})`);
919
+ throw new Error(`call supports: parse, classify, link, plan, score (got ${subcommand ?? "nothing"})`);
826
920
  }
827
921
 
828
922
  /**
@@ -862,10 +956,14 @@ async function requireLlmCredential(
862
956
  }
863
957
 
864
958
  function renderScorecard(scorecard: CallScorecard, title?: string): string {
959
+ const bandText = scorecard.band ? ` — ${scorecard.band.label}` : "";
960
+ const rubricLine = scorecard.rubricName ? `Rubric: ${scorecard.rubricName}${scorecard.callType ? ` (${scorecard.callType})` : ""}` : "";
865
961
  const lines = [
866
962
  `# Coaching Scorecard${title ? ` — ${title}` : ""}`,
867
963
  "",
868
- `**Overall: ${scorecard.overallScore}/${scorecard.scale}** (model: ${scorecard.model})`,
964
+ `**Overall: ${scorecard.overallScore}/${scorecard.scale}${bandText}** (model: ${scorecard.model})`,
965
+ ...(scorecard.band?.meaning ? [`> ${scorecard.band.meaning}`] : []),
966
+ ...(rubricLine ? ["", `_${rubricLine}_`] : []),
869
967
  "",
870
968
  "| Dimension | Score | | Coaching note |",
871
969
  "| --- | --- | --- | --- |",
package/src/index.ts CHANGED
@@ -179,6 +179,7 @@ export { sampleSnapshot } from "./sampleData.ts";
179
179
  export {
180
180
  DEFAULT_MODELS,
181
181
  DEFAULT_RUBRIC,
182
+ classifyCallLlm,
182
183
  detectProviderFromKey,
183
184
  extractInsightsLlm,
184
185
  forcedToolCall,
@@ -187,12 +188,27 @@ export {
187
188
  scoreCallLlm,
188
189
  validateLlmKey,
189
190
  type CallScorecard,
191
+ type LlmCallClassification,
190
192
  type LlmCredential,
191
193
  type LlmExtractedInsight,
192
194
  type LlmProvider,
193
195
  type Rubric,
196
+ type RubricDimension,
197
+ type ScoreBand,
194
198
  type ScoredDimension,
195
199
  } from "./llm.ts";
200
+ export {
201
+ classifyCall,
202
+ rubricForCallType,
203
+ rubricPresets,
204
+ bandForScore,
205
+ CALL_TYPES,
206
+ CALL_TYPE_IDS,
207
+ BANDS_5,
208
+ type CallType,
209
+ type CallTypeDef,
210
+ type CallClassification,
211
+ } from "./callTypes.ts";
196
212
  export { resolveRecord, type ResolveCandidate, type ResolveMatch, type ResolveResult } from "./resolve.ts";
197
213
  export {
198
214
  captureMarket,
@@ -275,6 +291,20 @@ export {
275
291
  type VendorScale,
276
292
  } from "./marketScale.ts";
277
293
  export { marketMapToHtml, marketMapToMarkdown } from "./marketReport.ts";
294
+ export {
295
+ registrableDomain,
296
+ categoryKeywords,
297
+ pickCategoryPage,
298
+ extractLogoUrl,
299
+ resolveFinalUrl,
300
+ detectDrift,
301
+ findCategoryPageInSitemap,
302
+ findCategoryPage,
303
+ fetchLogoDataUri,
304
+ type FetchText,
305
+ type FetchBytes,
306
+ type ResolveUrl,
307
+ } from "./marketSourcing.ts";
278
308
  export {
279
309
  computeMissedFirings,
280
310
  createFileScheduleRunStore,
package/src/llm.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { getCredential } from "./credentials.ts";
2
2
  import type { CallInsightType, ExtractedCallInsight } from "./calls.ts";
3
+ import { CALL_TYPE_IDS, type CallType } from "./callTypes.ts";
3
4
 
4
5
  /**
5
6
  * LLM-powered call extraction and scoring. Bring-your-own-key, two providers
@@ -170,13 +171,37 @@ function actionGroundedInEvidence(text: string, evidence: string): boolean {
170
171
 
171
172
  // ── Rubric scoring ─────────────────────────────────────────────────────────
172
173
 
174
+ /** A qualitative band over the weighted overall (e.g. "developing" at >=2). */
175
+ export type ScoreBand = { label: string; min: number; meaning?: string };
176
+
177
+ export type RubricDimension = {
178
+ name: string;
179
+ weight: number;
180
+ rubric: string;
181
+ /** Anchored behavioral examples of a top score — sharpens the model and cuts variance. */
182
+ anchorsHigh?: string[];
183
+ /** Anchored behavioral examples of a bottom score. */
184
+ anchorsLow?: string[];
185
+ /** Verbatim phrases to listen for — tightens evidence grounding. */
186
+ evidenceCues?: string[];
187
+ /** Reflective questions surfaced to the rep alongside the score. */
188
+ coachingPrompts?: string[];
189
+ };
190
+
173
191
  export type Rubric = {
174
192
  scale: number;
175
- dimensions: Array<{ name: string; weight: number; rubric: string }>;
193
+ /** Display name (e.g. the call type this rubric is built for). */
194
+ name?: string;
195
+ /** The call type this rubric scores, when type-specific. */
196
+ callType?: CallType;
197
+ dimensions: RubricDimension[];
198
+ /** Optional qualitative bands over the weighted overall. */
199
+ bands?: ScoreBand[];
176
200
  };
177
201
 
178
202
  export const DEFAULT_RUBRIC: Rubric = {
179
203
  scale: 5,
204
+ name: "Generic",
180
205
  dimensions: [
181
206
  { name: "Depth of Discovery", weight: 1.2, rubric: "Did the rep uncover concrete pain, current process, and cost of inaction with specifics — 5 — or stay at surface level — 1?" },
182
207
  { name: "Next Steps & Commitment", weight: 1.2, rubric: "Did the call end with a specific, time-bound, mutually agreed next step (5) or vague intentions (1)?" },
@@ -200,6 +225,11 @@ export type CallScorecard = {
200
225
  /** Weighted average, computed deterministically client-side. */
201
226
  overallScore: number;
202
227
  scale: number;
228
+ /** Qualitative band for the overall, computed client-side from the rubric's bands. */
229
+ band?: ScoreBand;
230
+ /** The rubric used, for provenance in reports. */
231
+ rubricName?: string;
232
+ callType?: CallType;
203
233
  highlights: string[];
204
234
  missedItems: string[];
205
235
  model: string;
@@ -236,9 +266,16 @@ export async function scoreCallLlm(
236
266
  const model = options.model ?? DEFAULT_MODELS[options.provider];
237
267
  const text = truncateTranscript(transcript);
238
268
  const rubricText = rubric.dimensions
239
- .map((d) => `- ${d.name} (weight ${d.weight}): ${d.rubric}`)
269
+ .map((d) => {
270
+ const lines = [`- ${d.name} (weight ${d.weight}): ${d.rubric}`];
271
+ if (d.anchorsHigh?.length) lines.push(` a ${rubric.scale} looks like: ${d.anchorsHigh.join("; ")}`);
272
+ if (d.anchorsLow?.length) lines.push(` a 1 looks like: ${d.anchorsLow.join("; ")}`);
273
+ if (d.evidenceCues?.length) lines.push(` listen for: ${d.evidenceCues.join(", ")}`);
274
+ return lines.join("\n");
275
+ })
240
276
  .join("\n");
241
- const prompt = `Score this sales call against the rubric. Score every dimension 1-${rubric.scale}. Ground every score in verbatim quotes; if the transcript gives no signal for a dimension, score it low and say why in the coaching note.\n\nRubric:\n${rubricText}\n\n${options.title ? `Call: ${options.title}\n` : ""}Transcript:\n${text}`;
277
+ const heading = rubric.name ? `${rubric.name} call` : "sales call";
278
+ const prompt = `Score this ${heading} against the rubric. Score every dimension 1-${rubric.scale}, calibrating to the anchored examples where given. Ground every score in verbatim quotes; if the transcript gives no signal for a dimension, score it low and say why in the coaching note.\n\nRubric:\n${rubricText}\n\n${options.title ? `Call: ${options.title}\n` : ""}Transcript:\n${text}`;
242
279
  const result = (await forcedToolCall(prompt, "score_call", SCORE_SCHEMA(rubric.scale, rubric.dimensions), model, options)) as {
243
280
  dimensions?: Array<{ name: string; score: number; evidence?: string[]; coaching_note?: string }>;
244
281
  highlights?: string[];
@@ -259,31 +296,91 @@ export async function scoreCallLlm(
259
296
  const totalWeight = dimensions.reduce((sum, d) => sum + d.weight, 0);
260
297
  const overallScore =
261
298
  Math.round((dimensions.reduce((sum, d) => sum + d.score * d.weight, 0) / totalWeight) * 100) / 100;
299
+ const band = rubric.bands?.length
300
+ ? [...rubric.bands].sort((a, b) => b.min - a.min).find((b) => overallScore >= b.min)
301
+ : undefined;
262
302
  return {
263
303
  dimensions,
264
304
  overallScore,
265
305
  scale: rubric.scale,
306
+ band,
307
+ rubricName: rubric.name,
308
+ callType: rubric.callType,
266
309
  highlights: result.highlights ?? [],
267
310
  missedItems: result.missed_items ?? [],
268
311
  model,
269
312
  };
270
313
  }
271
314
 
315
+ const strArray = (value: unknown): string[] | undefined =>
316
+ Array.isArray(value) && value.length ? value.map((v) => String(v)) : undefined;
317
+
272
318
  export function parseRubric(json: string): Rubric {
273
319
  const parsed = JSON.parse(json) as Partial<Rubric>;
274
320
  if (!Array.isArray(parsed.dimensions) || parsed.dimensions.length === 0) {
275
321
  throw new Error("Rubric needs a dimensions array: { scale, dimensions: [{ name, weight, rubric }] }");
276
322
  }
323
+ const bands = Array.isArray(parsed.bands)
324
+ ? parsed.bands
325
+ .filter((b) => b && typeof b.min === "number" && b.label)
326
+ .map((b) => ({ label: String(b.label), min: Number(b.min), meaning: b.meaning ? String(b.meaning) : undefined }))
327
+ : undefined;
277
328
  return {
278
329
  scale: parsed.scale ?? 5,
330
+ name: parsed.name ? String(parsed.name) : undefined,
331
+ callType: parsed.callType,
332
+ bands: bands?.length ? bands : undefined,
279
333
  dimensions: parsed.dimensions.map((d) => ({
280
334
  name: String(d.name),
281
335
  weight: typeof d.weight === "number" ? d.weight : 1,
282
336
  rubric: String(d.rubric ?? ""),
337
+ anchorsHigh: strArray(d.anchorsHigh),
338
+ anchorsLow: strArray(d.anchorsLow),
339
+ evidenceCues: strArray(d.evidenceCues),
340
+ coachingPrompts: strArray(d.coachingPrompts),
283
341
  })),
284
342
  };
285
343
  }
286
344
 
345
+ // ── Call-type classification (LLM tiebreak) ────────────────────────────────
346
+
347
+ const CLASSIFY_SCHEMA = {
348
+ type: "object",
349
+ required: ["type", "reason"],
350
+ properties: {
351
+ type: { type: "string", enum: CALL_TYPE_IDS },
352
+ reason: { type: "string", description: "One sentence, citing what in the transcript decided it." },
353
+ },
354
+ } as const;
355
+
356
+ export type LlmCallClassification = { type: CallType; reason: string; model: string; method: "llm" };
357
+
358
+ /**
359
+ * Model tiebreak for call-type classification — the opt-in counterpart to the
360
+ * deterministic `classifyCall`. Same forced-tool-call seam as every other LLM
361
+ * feature; returns the canonical CallType plus a one-line reason.
362
+ */
363
+ export async function classifyCallLlm(
364
+ transcript: string,
365
+ defs: Array<{ id: string; name: string; definition: string }>,
366
+ options: LlmCallOptions & { title?: string },
367
+ ): Promise<LlmCallClassification> {
368
+ const model = options.model ?? DEFAULT_MODELS[options.provider];
369
+ const text = truncateTranscript(transcript);
370
+ const taxonomy = defs.map((d) => `- ${d.id} (${d.name}): ${d.definition}`).join("\n");
371
+ const prompt = `Classify this sales call into exactly one type from the taxonomy. Pick the single best fit; use "other" only if none apply.\n\nTaxonomy:\n${taxonomy}\n\n${options.title ? `Call: ${options.title}\n` : ""}Transcript:\n${text}`;
372
+ const result = (await forcedToolCall(prompt, "classify_call", CLASSIFY_SCHEMA, model, options)) as {
373
+ type?: CallType;
374
+ reason?: string;
375
+ };
376
+ return {
377
+ type: (result.type as CallType) ?? "other",
378
+ reason: result.reason ?? "Model did not give a reason.",
379
+ model,
380
+ method: "llm",
381
+ };
382
+ }
383
+
287
384
  // ── Provider plumbing (raw fetch, forced tool calls) ───────────────────────
288
385
 
289
386
  /**
@@ -196,6 +196,7 @@ function svgScatter(
196
196
  anchor: string | undefined,
197
197
  colorByVendor: Map<string, string>,
198
198
  numberByVendor: Map<string, number>,
199
+ logoByVendor: Map<string, string | undefined>,
199
200
  ): string {
200
201
  const W = 640;
201
202
  const H = 480;
@@ -217,23 +218,45 @@ function svgScatter(
217
218
  // raises a bubble to the front (JS re-appends its <g>), so even a bubble
218
219
  // born fully underneath a bigger one is one mouse-over from visible.
219
220
  const ordered = [...points].sort((a, b) => b.size - a.size);
221
+ const clipDefs: string[] = [];
220
222
  const dots = ordered
221
- .map((p) => {
223
+ .map((p, i) => {
222
224
  const r = p.noScale ? 7 : 7 + 24 * Math.sqrt(p.size);
223
225
  const color = colorByVendor.get(p.vendorId) ?? "#717171";
224
226
  const number = numberByVendor.get(p.vendorId) ?? 0;
225
- const cx = sx(p.x).toFixed(1);
226
- const cy = sy(p.y);
227
+ const cxN = sx(p.x);
228
+ const cyN = sy(p.y);
229
+ const cx = cxN.toFixed(1);
230
+ const cy = cyN.toFixed(1);
227
231
  const ring = p.vendorId === anchor ? ` stroke="#1c1c1c" stroke-width="2.5"` : ` stroke="#ffffff" stroke-width="1.5"`;
228
232
  // No measurable scale: minimal dashed outline — visibly "no data", never a guess.
229
233
  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}`;
234
+ const circle = `<circle cx="${cx}" cy="${cy}" r="${r.toFixed(1)}"${fill}/>`;
235
+
236
+ // When the vendor has a brand logo and the bubble is big enough to read
237
+ // it, the logo IS the in-bubble label: a white disc clipped to the
238
+ // circle carries it, a colored rim still ties the dot to its legend
239
+ // color, and the legend number moves just above so the cross-reference
240
+ // survives. Small or logo-less dots keep the numbered-bubble treatment.
241
+ const logo = logoByVendor.get(p.vendorId);
242
+ const hasLogo = !p.noScale && r >= 12 && typeof logo === "string" && logo.startsWith("data:image/");
243
+ if (hasLogo) {
244
+ const ri = r - Math.max(3, r * 0.14);
245
+ const clipId = `bclip-${i}`;
246
+ clipDefs.push(`<clipPath id="${clipId}"><circle cx="${cx}" cy="${cy}" r="${ri.toFixed(1)}"/></clipPath>`);
247
+ const disc = `<circle cx="${cx}" cy="${cy}" r="${ri.toFixed(1)}" fill="#ffffff" style="pointer-events:none"/>`;
248
+ const img = `<image href="${e(logo as string)}" x="${(cxN - ri).toFixed(1)}" y="${(cyN - ri).toFixed(1)}" width="${(2 * ri).toFixed(1)}" height="${(2 * ri).toFixed(1)}" preserveAspectRatio="xMidYMid meet" clip-path="url(#${clipId})" style="pointer-events:none"/>`;
249
+ const numberAbove = `<text x="${cx}" y="${(cyN - r - 3).toFixed(1)}" text-anchor="middle" font-size="10" font-weight="700" fill="${color}" style="pointer-events:none">${number}</text>`;
250
+ return `<g class="bubble" data-v="${e(p.vendorId)}">${circle}${disc}${img}${numberAbove}</g>`;
251
+ }
252
+
230
253
  // Numbers go inside when they fit, above the bubble when they don't.
231
254
  const fs = Math.max(10, Math.min(14, r * 0.9));
232
255
  const numberSvg =
233
256
  r >= 10 && !p.noScale
234
- ? `<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>`
235
- : `<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>`;
236
- 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>`;
257
+ ? `<text x="${cx}" y="${(cyN + 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>`
258
+ : `<text x="${cx}" y="${(cyN - r - 3).toFixed(1)}" text-anchor="middle" font-size="10" font-weight="700" fill="${color}" style="pointer-events:none">${number}</text>`;
259
+ return `<g class="bubble${p.noScale ? " no-scale" : ""}" data-v="${e(p.vendorId)}">${circle}${numberSvg}</g>`;
237
260
  })
238
261
  .join("");
239
262
 
@@ -257,6 +280,7 @@ function svgScatter(
257
280
  const yNeg = ay.signed ? yLabel(wrapPole(ay.negativePole, 26), H - PAD_BOTTOM, "start") : "";
258
281
 
259
282
  return `<svg viewBox="0 0 ${W} ${H}" role="img" aria-label="${e(ax.label)} vs ${e(ay.label)}">
283
+ ${clipDefs.length ? `<defs>${clipDefs.join("")}</defs>` : ""}
260
284
  <rect x="${PAD_X}" y="${PAD_TOP}" width="${W - 2 * PAD_X}" height="${H - PAD_TOP - PAD_BOTTOM}" class="plot"/>
261
285
  ${midX}${midY}
262
286
  ${xNeg}${xPos}
@@ -399,7 +423,7 @@ function axisSectionsHtml(
399
423
  <h2>Strategic map: ${e(axInfo.label)} &#215; ${e(ayInfo.label)}</h2>
400
424
  <figure class="map">
401
425
  <div class="map-row">
402
- ${svgScatter(points, axInfo, ayInfo, config.anchorVendor, colorByVendor, numberByVendor)}
426
+ ${svgScatter(points, axInfo, ayInfo, config.anchorVendor, colorByVendor, numberByVendor, logoByVendor)}
403
427
  <table class="legend"><thead><tr><th></th><th>vendor</th><th class="num">${legendMeasureHead}</th></tr></thead><tbody>${legendRows}</tbody></table>
404
428
  </div>
405
429
  <div class="map-tip" id="map-tip" hidden></div>