@leadbay/mcp 0.40.0 → 0.41.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.
@@ -1529,6 +1529,107 @@ delivered gets a diagnosis and a concrete next move, never a shrug. Close
1529
1529
  with NEXT STEPS from the tool description \u2014 and STOP; take no further action
1530
1530
  without my say-so.
1531
1531
  `;
1532
+ var leadbay_outreach_session = `
1533
+ Run an outreach session over my **{{arg:source_label}}**{{arg:campaign_paren}}.
1534
+
1535
+ GATE \u2014 DEFER TO TOOL RENDERING. When you call a Leadbay composite that ships its own RENDERING block (every composite in 0.9.0+ does), render the response using that block's recipe verbatim \u2014 score bars, glyph palette, column order, hide-list, link priorities, all of it. Do NOT substitute prose, a numbered list, or a different column structure even when an orchestrating prompt's body suggests alternate framing. Prompt-specific commentary (motivational nudges, summaries, next-action recommendations) belongs ABOVE or BELOW the canonical table, never in place of it.
1536
+
1537
+ If the prompt's body and the tool's RENDERING appear to conflict, the tool's RENDERING wins for the structural layout; the prompt's voice wins for the commentary that surrounds it.
1538
+
1539
+
1540
+ # PHASE 0 \u2014 PICK THE SOURCE
1541
+
1542
+ If the source was given, use it. Otherwise surface a \`single_select\` via your host's choice widget (\`ask_user_input_v0\` or \`AskUserQuestion\`):
1543
+
1544
+ > Which leads do you want to work?
1545
+ > - Follow-ups \u2014 leads already in my Monitor
1546
+ > - New leads \u2014 today's batch from my active lens
1547
+ > - A campaign \u2014 pick one
1548
+
1549
+ For the campaign branch, resolve the name I gave you or call \`leadbay_list_campaigns()\` and let me pick (cap at 4, sorted by \`updated_at\` desc, archived hidden). Capture the \`campaign_id\`.
1550
+
1551
+ **If I named a specific campaign up front**, this prompt is the wrong door \u2014 \`leadbay_work_campaign\` is the campaign-shaped session and already knows that flow. Say so in one line and hand over.
1552
+
1553
+ # PHASE 1 \u2014 ASSESS WHO IS ACTUALLY REACHABLE (the load-bearing phase)
1554
+
1555
+ Fetch one page from the chosen source:
1556
+
1557
+ - follow-ups \u2192 \`leadbay_pull_followups({count: 25})\`
1558
+ - discover \u2192 \`leadbay_pull_leads({count: 25})\`
1559
+ - campaign \u2192 \`leadbay_campaign_call_sheet({campaign_id})\`
1560
+
1561
+ **Then count how many rows are reachable \u2014 and the fields differ by source.** Reading the wrong ones reports an empty book while the sheet is full of dialable numbers:
1562
+
1563
+ - **follow-ups / discover** \u2014 a lead is reachable when \`has_phone\` is true, or \`phone_numbers\` holds a real value, or \`email\` does. **The API returns the literal string \`"null"\`** for a missing value in both \`phone_numbers\` and \`email\`; guard it, or the count claims a callable book that is not.
1564
+ - **campaign** \u2014 do NOT look for those fields; a call-sheet block has none of them. The sheet already did the counting: read \`summary.leads_with_phone\`, \`summary.leads_with_email\` and \`summary.leads_without_contacts\`, and its \`readiness\` booleans (\`ready_for_calling\`, \`ready_for_emailing\`, \`needs_enrichment\`). Per lead, the channels live in \`contacts[].phone_number\` / \`contacts[].email\`, with the switchboard in \`company_phone_numbers\`.
1565
+
1566
+ **\`contacts_count > 0\` is NOT reachability.** It counts known people, not people you can dial; a lead can show thousands of contacts and zero channels.
1567
+
1568
+ **One-line situation report**, always, before anything else:
1569
+
1570
+ \`\`\`
1571
+ \u{1F4CB} <N> leads \xB7 \u260E <with_phone> with a phone \xB7 \u2709 <with_email> with an email \xB7 \u{1F534} <no_channel> need enrichment
1572
+ \`\`\`
1573
+
1574
+ **Then branch:**
1575
+
1576
+ - **Nothing reachable at all** \u2014 do NOT build a board the rep cannot work. Say it plainly ("none of these 25 leads has a phone or email on file"), then offer, as a \`single_select\`: enrich the top leads' buyer titles (\`leadbay_enrich_titles\`), build the desk anyway so I can enrich row by row, or pick a different source.
1577
+ - **Some reachable** \u2014 go to Phase 2. Mention the unreachable count once so I know the desk will show enrich controls on those rows.
1578
+
1579
+ # PHASE 2 \u2014 BUILD THE LEAD DESK
1580
+
1581
+ Call \`leadbay_get_artifact_runtime\` and follow its **LEAD DESK** recipe. Build from the leads already in hand \u2014 do NOT re-call the source tool to populate the board.
1582
+
1583
+ The desk is one row per lead, carrying:
1584
+
1585
+ - the company, its sector / description and the COMPANY switchboard (labelled as the company's, never as the contact's direct line)
1586
+ - the contact, with email and phone \u2014 **lazy**, loaded when I open the row, because those come from \`research_lead_by_id\` and prefetching is one request per row
1587
+ - an enrich control on any contact missing a channel, which names the spend before buying
1588
+ - the CRM status (Wanted / Won / Lost / Unwanted), saving on change
1589
+ - this attempt's outcome + a note, which is required
1590
+ - like / dislike, and Qualify / Requalify
1591
+
1592
+ Set the artifact's \`mcp_tools\` to every tool the page calls, or its controls are inert.
1593
+
1594
+ End the turn with the standby line:
1595
+
1596
+ > The desk is up. Tell me what happened after each call or email \u2014 I'll record the outcome and the status.
1597
+
1598
+ # PHASE 3 \u2014 RECORD OUTCOMES, ONE AT A TIME
1599
+
1600
+ When I dictate something like *"Called Michel at Starmat, voicemail, trying Thursday"* or *"Talked to Christian, wants a quote, meeting booked Tuesday"*, parse:
1601
+
1602
+ 1. **Which lead** \u2014 by company or contact name, cross-referenced with the rows on the desk.
1603
+ 2. **The note** \u2014 my exact words. Don't paraphrase; the next rep reads this.
1604
+ 3. **The outcome** \u2014 ONE of:
1605
+ - \`STILL_CHASING\` \u2014 pursuing, no decision ("trying again", "they'll get back to me")
1606
+ - \`COULD_NOT_REACH_STILL_TRYING\` \u2014 voicemail, no answer, gatekeeper
1607
+ - \`INTEREST_VALIDATED_OR_MEETING_PLANED\` \u2014 meeting booked, quote requested, "send me more"
1608
+ - \`NOT_INTERESTED_LOST\` \u2014 declined, "not now", "not a fit"
1609
+
1610
+ Call \`leadbay_report_outreach({lead_id, note, epilogue_status, verification: {source: "user_confirmed", ref: <my exact words>}})\`.
1611
+
1612
+ **If I also reported a commercial outcome** \u2014 "we won it", "they're out" \u2014 that is a LEAD STATUS, a different axis. Fire \`leadbay_set_lead_status\` as well. Setting one never sets the other.
1613
+
1614
+ Confirm in ONE line: *"\u2705 Logged: <Company> \u2192 <outcome>. Next?"* Then wait. Don't ask "anything else?".
1615
+
1616
+ # PHASE 4 \u2014 STOP
1617
+
1618
+ When I say "done" / "that's it" / "wrapping up":
1619
+
1620
+ > Session complete \u2014 N logged: X meetings booked \xB7 Y still chasing \xB7 Z couldn't reach \xB7 W declined.
1621
+
1622
+ If any lead still has no channel, add one line: *"<N> leads still have nobody to call \u2014 want to enrich them for next time?"*
1623
+
1624
+ # Iron laws
1625
+
1626
+ - \`verification\` on \`leadbay_report_outreach\` is REQUIRED. For a call, \`{source: "user_confirmed", ref: <my verbatim words>}\`. Fabricating a gmail_message_id for a call is forbidden.
1627
+ - ONE attempt \u2192 ONE \`leadbay_report_outreach\`. Never batched at the end of the session.
1628
+ - Epilogue and lead status are different systems. When I report both in one breath, fire both.
1629
+ - Enrichment SPENDS QUOTA. Name the contact and the channels before buying, and never buy a channel already on file.
1630
+ - Never build the desk by hand. \`leadbay_get_artifact_runtime\` owns the skin and the wiring; a hand-built board logs nothing.
1631
+ - If an outcome I dictate doesn't map cleanly to one of the four epilogue values, ASK ONCE rather than guessing.
1632
+ `;
1532
1633
  var leadbay_plan_tour_in_city = `
1533
1634
  Plan a field sales tour for me in **{{arg:city}}**{{arg:date_paren}}.
1534
1635
 
@@ -2201,6 +2302,93 @@ Place a 2\u20133 sentence summary ABOVE the card with:
2201
2302
 
2202
2303
  The card itself handles the signal callouts (\`\u{1F4C8} business signals\`, \`\u{1F4A1} prospecting clues\`). Do NOT re-narrate signals in prose above the card \u2014 that's what the card sections are for. Be honest about uncertainty: if any field is missing from tool responses, say "not surfaced by qualification" rather than guessing.
2203
2304
  `;
2305
+ var leadbay_route_session = `
2306
+ Plan my route{{arg:city_in}}{{arg:day_paren}}.
2307
+
2308
+ GATE \u2014 DEFER TO TOOL RENDERING. When you call a Leadbay composite that ships its own RENDERING block (every composite in 0.9.0+ does), render the response using that block's recipe verbatim \u2014 score bars, glyph palette, column order, hide-list, link priorities, all of it. Do NOT substitute prose, a numbered list, or a different column structure even when an orchestrating prompt's body suggests alternate framing. Prompt-specific commentary (motivational nudges, summaries, next-action recommendations) belongs ABOVE or BELOW the canonical table, never in place of it.
2309
+
2310
+ If the prompt's body and the tool's RENDERING appear to conflict, the tool's RENDERING wins for the structural layout; the prompt's voice wins for the commentary that surrounds it.
2311
+
2312
+
2313
+ # PHASE 0 \u2014 WHERE
2314
+
2315
+ If I named a place, use it. Otherwise ask, once, as a short question \u2014 not a widget: a city is free text, not a choice between four.
2316
+
2317
+ > Which city or area are you going to?
2318
+
2319
+ **Never accept a country.** This workspace serves exactly one, so a country name is not a place to tour \u2014 it either fences the trip to a same-named commune or hands back the whole book as an itinerary. If I give you one, ask which city inside it.
2320
+
2321
+ # PHASE 1 \u2014 WHO IS THERE
2322
+
2323
+ Call \`leadbay_tour_plan({city})\`. It returns two halves and they are not the same thing:
2324
+
2325
+ - \`monitor_leads\` \u2014 leads I already know and have worked. These are the visits.
2326
+ - \`discover_leads\` \u2014 fresh leads in the same place, filtered client-side. These are the opportunistic stops.
2327
+ - \`discover_filter_note\` \u2014 says how many are actually IN the city versus within the radius, and which towns those are in. **Repeat that split.** Presenting a lead from the next town over as being in the city is how a rep ends up with a 40-minute detour they did not plan.
2328
+
2329
+ **Count what can actually go on a map.** A lead is placeable when \`location.pos\` holds real coordinates; \`[0, 0]\` is the API's missing-position sentinel, not a place. An imported book is mostly ungeocoded, so this number is usually well below the lead count \u2014 say both.
2330
+
2331
+ \`\`\`
2332
+ \u{1F5FA} <mappable> of <total> leads placed \xB7 \u{1F4CD} <in_city> in <city>, <nearby> within <radius>km \xB7 \u{1F534} <unplaced> with no coordinates
2333
+ \`\`\`
2334
+
2335
+ **If nothing is placeable**, do not build a map. Say so, list the leads, and offer to work them by phone with \`leadbay_outreach_session\` instead.
2336
+
2337
+ # PHASE 2 \u2014 BUILD THE ROUTE PLANNER
2338
+
2339
+ Call \`leadbay_get_artifact_runtime\` and follow its **ROUTE PLANNER** recipe. Build from the leads in hand \u2014 do NOT re-call \`tour_plan\` to populate it.
2340
+
2341
+ Half map, half panel. Clicking a marker opens that lead in the panel with set status, log outreach, and record a prospecting action; the pin repaints as I work, so the map answers "where have I been" at a glance.
2342
+
2343
+ Use the kit's geo helpers rather than hand-rolling them \u2014 each exists because the hand-rolled version is wrong in a way nothing surfaces:
2344
+
2345
+ - \`lb.leadPos\` \u2014 rejects \`[0,0]\` and out-of-range values
2346
+ - \`lb.orderByProximity\` \u2014 a sensible default order, not the API's
2347
+ - \`lb.distanceKm\` / \`lb.routeDistanceKm\` \u2014 great-circle, not flat
2348
+ - \`lb.routeUrl\` \u2014 a Google Maps link, with \`truncated\` counting anything past its 11-stop cap
2349
+
2350
+ **The leads with no coordinates go in the panel**, under their own heading, never dropped.
2351
+
2352
+ End the turn with the standby line:
2353
+
2354
+ > The route is up. Tell me how each visit went and I'll record it.
2355
+
2356
+ # PHASE 3 \u2014 RECORD VISITS AS THEY HAPPEN
2357
+
2358
+ When I say *"saw the manager at Starmat, wants a quote"* or *"Norba was closed"*, parse:
2359
+
2360
+ 1. **Which lead** \u2014 by company name, cross-referenced with the stops on the map.
2361
+ 2. **The note** \u2014 my exact words.
2362
+ 3. **The outcome** \u2014 ONE of:
2363
+ - \`STILL_CHASING\` \u2014 spoke to someone, no decision
2364
+ - \`COULD_NOT_REACH_STILL_TRYING\` \u2014 closed, nobody in, gatekeeper
2365
+ - \`INTEREST_VALIDATED_OR_MEETING_PLANED\` \u2014 quote asked for, meeting booked
2366
+ - \`NOT_INTERESTED_LOST\` \u2014 declined
2367
+
2368
+ Call \`leadbay_report_outreach({lead_id, note, epilogue_status, verification: {source: "user_confirmed", ref: <my exact words>}})\`.
2369
+
2370
+ **If I also report a commercial outcome** \u2014 "we won it", "they're out" \u2014 that is a LEAD STATUS, a different axis. Fire \`leadbay_set_lead_status\` too.
2371
+
2372
+ Confirm in ONE line: *"\u2705 <Company> \u2192 <outcome>. Next stop?"* Then wait.
2373
+
2374
+ # PHASE 4 \u2014 END OF THE DAY
2375
+
2376
+ When I say I'm done:
2377
+
2378
+ > Day complete \u2014 N visits: X quotes or meetings \xB7 Y still chasing \xB7 Z nobody in \xB7 W declined. Covered \u2248<km>km as the crow flies.
2379
+
2380
+ If any stop was never reached, name it and offer to carry it to the next trip.
2381
+
2382
+ # Iron laws
2383
+
2384
+ - A country is never a place to tour. Ask for the city.
2385
+ - \`verification\` on \`leadbay_report_outreach\` is REQUIRED \u2014 \`{source: "user_confirmed", ref: <my verbatim words>}\` for a visit.
2386
+ - ONE visit \u2192 ONE \`leadbay_report_outreach\`. Never batched at the end of the day.
2387
+ - Epilogue and lead status are different systems. When I report both, fire both.
2388
+ - Never drop a lead for having no coordinates; list it.
2389
+ - Straight-line distance is not drive distance. Say "as the crow flies".
2390
+ - Never build the map by hand \u2014 \`leadbay_get_artifact_runtime\` owns the skin, the geo helpers and the wiring.
2391
+ `;
2204
2392
  var leadbay_setup_team_prospecting = `
2205
2393
  Set up manager-led prospecting for me: turn the audience into a lens, validate candidates, then persist as named campaigns.
2206
2394
 
@@ -3007,6 +3195,7 @@ Trigger when the user DESCRIBES who they want: "get me N companies that
3007
3195
  profile, and "today's leads", are the daily lens batch \u2014 leadbay_pull_leads.
3008
3196
  "Qualify these companies I have" is leadbay_qualify_leads.
3009
3197
  `, "arguments": [{ "name": "need", "description": "What the user is looking for, in their own words (e.g. '10 gyms around Dallas that would buy modular flooring, with phone numbers'). Optional \u2014 the session starts by asking when absent.", "required": false }], "expected_calls": ["leadbay_find_new_leads", "leadbay_lead_job_status", "leadbay_qualify_leads"], "failure_modes": ["Passes the user's raw sentence as `query` instead of crafting an example_lead description (vendor-vocabulary trap \u2014 measured 0 delivered from a raw query vs on-profile results from a crafted example)", "Invents a distinctive brand name in example_lead.name (pulls matching toward name-lookalikes)", 'Puts event language ("hiring", "expanding", "just raised") into the seed description', "Launches qualify:true or channels without a dry_run quote and the user's explicit go-ahead", "Retries a failed/timed-out submit with a NEW request_id (double launch) \u2014 the same ask must reuse the same request_id", 'Reports "no results" without narrating the funnel + scope_notes and proposing a concrete fix', "Renders delivered leads as freeform prose instead of the canonical lead-delivery table", "Blends two distinct buyer archetypes into one seed description instead of running one search per archetype", "Passes a country name in filters.locations (silently matches a same-named town \u2014 whole-country intent means OMITTING locations) or a nested employees object instead of the flat employees_min/employees_max", "Renders rows that visibly violate the user's exclusions, or presents a best-fit-under-30 table as an answer instead of flagging weak matches"] },
3198
+ leadbay_outreach_session: { "name": "leadbay_outreach_session", "short_description": 'Run an outreach session over ANY set of leads: follow-ups, a Discover\nlens, or a campaign. Assess who is actually reachable, build the lead\ndesk, then record each outcome as you dictate it. Trigger on "relance\nplan", "campagne de relance", "cold calling session", "prospection\nsession", "outreach session", "let\'s contact leads", "who do I call\ntoday". For a campaign the user NAMES, prefer `leadbay_work_campaign`.\n', "arguments": [{ "name": "source", "description": "Optional: where the leads come from \u2014 'followups' (the Monitor, default), 'discover' (the active lens), or 'campaign'. Omit and the session asks.", "required": false }, { "name": "campaign", "description": "Optional: campaign name (fuzzy-matched against your own campaigns) or UUID, when source is 'campaign'. Omit to list and pick.", "required": false }], "expected_calls": ["leadbay_pull_followups", "leadbay_get_artifact_runtime", "leadbay_enrich_contacts", "leadbay_report_outreach", "leadbay_set_lead_status"], "failure_modes": ["Builds the desk before checking reachability \u2014 a board of leads with no phone or email is a page the rep cannot work. Assess FIRST, and when nothing is callable say so and offer enrichment instead.", "Renders a markdown table instead of the lead desk artifact \u2014 a table cannot log outreach, set status, or buy a missing channel. The desk is the deliverable.", "Hand-writes the artifact instead of calling `leadbay_get_artifact_runtime` \u2014 a hand-built board misses the product skin and wires its buttons to nothing, so the rep's work never reaches Leadbay.", "Prefetches every row's contacts to fill the table \u2014 that is one `research_lead_by_id` per row for cells the rep may never read. Contacts are LAZY; the desk loads them per row on the rep's gesture.", "Records outreach WITHOUT verification \u2014 `verification.source/ref` is REQUIRED. For a call, pass `{source: 'user_confirmed', ref: <the user's exact words>}`. Fabricating a gmail_message_id for a call is forbidden.", "Records the epilogue but not the CRM status when the user reports both \u2014 'she's interested, meeting booked' is an epilogue AND a status. Setting one never sets the other.", "Batches the outreach logs at the end of the session \u2014 one attempt is one `leadbay_report_outreach`, recorded as the rep dictates it, or a crash loses the lot.", "Enriches a contact without saying it spends quota \u2014 every email and phone reveal is a purchase. Name the contact and the channels before buying.", "Treats `contacts_count > 0` as reachable \u2014 it counts known PEOPLE, not people you can dial. A lead can carry thousands of contacts and no channel at all.", "Counts reachability on a CAMPAIGN with the follow-ups fields \u2014 a call-sheet block has no `has_phone`, `phone_numbers` or `email`, so all three read undefined and the session reports 'nobody is callable' over a sheet full of numbers. The sheet ships `summary.leads_with_phone` / `leads_with_email` and `readiness`; use those, and `contacts[].phone_number` per lead."] },
3010
3199
  leadbay_plan_tour_in_city: { "name": "leadbay_plan_tour_in_city", "short_description": 'Use whenever the user names a city they\'ll be in and asks who to see\n\u2014 "I\'m in SF next Tuesday, who\'s worth meeting?", "I\'m going to Berlin\n\u2014 who should I visit?", "plan my <city> tour". Any in-person/visit\nintent tied to a place routes here, NOT to `leadbay_pull_leads`. It\nsurfaces follow-ups + fresh Discover leads in the city via\n`leadbay_tour_plan`, ALWAYS offers to plot them on a map (rendering it\non yes), then offers outreach drafts + campaign persistence.\n', "arguments": [{ "name": "city", "description": "City or region the user is visiting (e.g. 'Limoges', 'Bay Area'). Used as the geo filter for both Monitor and Discover lookups. A country is not a city: this workspace already covers exactly one country, and a country name here silently fences the tour to a same-named village. Do NOT omit the argument to recover \u2014 a city-less tour returns arbitrary leads from across the whole workspace, which is not an itinerary. Ask which city or region the visit is to.", "required": true }, { "name": "date", "description": "When the visit is (e.g. 'May 24', 'next Thursday'). Surfaced in the outreach drafts as 'I'll be in <city> on <date>'.", "required": false }], "expected_calls": ["leadbay_tour_plan", "leadbay_research_lead_by_id", "leadbay_prepare_outreach", "leadbay_create_campaign"], "failure_modes": ["Calls leadbay_followups_map (Monitor-only) instead of leadbay_tour_plan \u2014 loses the Discover (fresh-lead) half that the user explicitly asked for", "Calls leadbay_pull_leads then drops the geo filter \u2014 returns the lens-wide wishlist instead of city-relevant fresh leads", 'Skips the campaign-persist step ("would you like to save these as a tour?") \u2014 leaves the rep with a one-shot map but no follow-up artifact', "Creates a campaign WITHOUT asking the user first \u2014 the persist step is high-intent; offer it, don't assume", "Fabricates lead_ids when seeding the campaign instead of using the ids returned by tour_plan"] },
3011
3200
  leadbay_prospecting_overview: { "name": "leadbay_prospecting_overview", "short_description": `Orientation for working with Leadbay from any host \u2014 discovery vs.
3012
3201
  follow-up, the outreach loop, outcome recording, imports, pushback /
@@ -3018,6 +3207,7 @@ should I follow up on" to "I'll send via lemlist".
3018
3207
  leadbay_qualify_top_n: { "name": "leadbay_qualify_top_n", "short_description": "Bulk-qualify the top N un-qualified leads in the active lens. Uses\nleadbay_bulk_qualify_leads with a sensible default budget.\n", "arguments": [{ "name": "count", "description": "How many leads to qualify (default 10, max 25). Higher counts may take 5+ minutes.", "required": false }], "expected_calls": ["leadbay_bulk_qualify_leads", "leadbay_qualify_status", "leadbay_pull_leads", "leadbay_research_lead_by_id"], "failure_modes": ["Picks a count larger than the user asked for (or larger than the max 25)", "Glosses over still-running leads in the summary instead of naming them", "Recommends a lead from the existing qualified pool instead of one from this batch's actual results", 'Replaces the canonical pull_leads table with prose when rendering the newly-qualified batch (the per-tool RENDERING block is the structural contract; "standouts" commentary sits above it)', "Expands the qualify-status sentence into a card or table instead of the one-line status-inline render"] },
3019
3208
  leadbay_refine_audience: { "name": "leadbay_refine_audience", "short_description": "Refine the kind of leads Leadbay surfaces beyond firmographics, with a\nfree-text instruction. Handles the clarification round-trip if the new\nprompt is ambiguous.\n", "arguments": [{ "name": "instruction", "description": "The refinement (e.g. 'focus on hospitals running their own IT'). Set to plain English.", "required": true }], "expected_calls": ["leadbay_refine_lead_targeting", "leadbay_account_status"], "failure_modes": ["Calls leadbay_answer_clarification on the user's behalf instead of surfacing the clarification verbatim", "Glosses over the clarification options instead of presenting them as offered", "Promises immediate effect when status='applied' actually triggers an async intelligence recompute"] },
3020
3209
  leadbay_research_a_domain: { "name": "leadbay_research_a_domain", "short_description": "Resolve a company by name or domain across the user's visible Discover,\nMonitor, and Activate corpus, then return everything Leadbay knows about it.\n", "arguments": [{ "name": "domain", "description": "Company name or domain (for example 'Acme Corporation' or 'acme.com'). The legacy argument key remains `domain` for client compatibility.", "required": true }], "expected_calls": ["leadbay_research_lead_by_name_fuzzy"], "failure_modes": ["Fabricates qualification answers not present in any tool response", "Calls leadbay_import_and_qualify before searching the existing visible corpus", "Treats the active lens as the entire search universe when the user did not request a lens scope", "Imports a missing company without the user's explicit permission", "Renders the research result as a freeform narrative instead of the canonical research-company-card layout (the card with header score bar, pill row, signal sections, contacts table is the structural contract; commentary belongs ABOVE or BELOW it)"] },
3210
+ leadbay_route_session: { "name": "leadbay_route_session", "short_description": 'Plan and work a day on the road: pick the place, see the leads on a map,\ndrive them in a sensible order and record each visit as it happens.\nTrigger on "plan my route", "tourn\xE9e", "road trip", "I\'m driving to\n<city>", "who can I visit in <city>", "plan my day in the field",\n"prospection terrain". For working leads by PHONE from a desk, prefer\n`leadbay_outreach_session`.\n', "arguments": [{ "name": "city", "description": "Where the rep is going \u2014 a city or region (e.g. 'Lyon', 'Hauts-de-France'). Omit and the session asks. NEVER a country: this workspace serves one country, so a country name is not a place to tour.", "required": false }, { "name": "day", "description": "Optional: which day the trip is, in the rep's own words ('Thursday', 'next week'). Used only to frame the plan; nothing is scheduled.", "required": false }], "expected_calls": ["leadbay_tour_plan", "leadbay_get_artifact_runtime", "leadbay_report_outreach", "leadbay_set_lead_status"], "failure_modes": ["Renders a markdown list of addresses instead of the route planner \u2014 a list cannot show which stops are near each other, and that is the whole question a trip asks.", "Drops the leads with no coordinates \u2014 an imported book is mostly ungeocoded, so a map of 12 out of 40 tells the rep their book is small. They belong in the panel beside the map under their own heading.", "Treats `location.pos` of `[0, 0]` as a real place \u2014 that is the API's missing-position sentinel, and the pin lands in the Gulf of Guinea. `lb.leadPos` rejects it.", "Orders the stops by score, or leaves the API's order \u2014 the rep is driving, so the order must be geographic. `lb.orderByProximity` is the default; they re-order by hand from there.", "Measures distance with flat geometry \u2014 at these latitudes a degree of longitude is ~73km against ~111km for latitude, so an east-west leg reads as half its length and the route comes out wrong.", "Hands over a Google Maps link with more than 11 stops and says nothing \u2014 everything past the cap is silently dropped, and the rep drives a day that ends early. `lb.routeUrl` returns `truncated`; say the number.", "Presents the straight-line total as a drive estimate \u2014 it is 'as the crow flies' and always shorter than the road.", "Passes a COUNTRY as the city \u2014 this workspace serves exactly one country, so that fences the tour to a same-named commune or returns the whole book as an itinerary.", "Records a visit WITHOUT verification \u2014 `verification.source/ref` is REQUIRED. For a visit, pass `{source: 'user_confirmed', ref: <the rep's exact words>}`.", "Records the epilogue but not the CRM status when the rep reports both \u2014 'met the owner, they're in' is an epilogue AND a status. Setting one never sets the other."] },
3021
3211
  leadbay_setup_team_prospecting: { "name": "leadbay_setup_team_prospecting", "short_description": "Manager-led prospecting setup: conversationally turn a natural-language\naudience ask into a Leadbay lens, validate the candidate leads, and\npersist them as one or more named campaigns the rep(s) can work\nthrough. Closes #3630 US3 end-to-end (within the current\ncreator-scoped campaign visibility model).\n", "arguments": [{ "name": "audience", "description": "Natural-language audience description (e.g. 'plumbing companies with 10-50 employees in Seine-Maritime'). The lens-creation step (`leadbay_refine_lead_targeting` \u2192 `leadbay_create_lens`) interprets it. A country name is not a scope here \u2014 this workspace already covers exactly one country, so drop it and keep the rest of the description; a DIFFERENT country cannot be targeted at all.", "required": true }, { "name": "rep_split", "description": "Optional: how to split the validated leads into per-rep campaigns. Free text \u2014 e.g. 'split by city' or 'one campaign per rep: John gets Tulsa, Sarah gets OKC'. Splitting by country is not a split \u2014 the workspace is single-country.", "required": false }], "expected_calls": ["leadbay_refine_lead_targeting", "leadbay_create_lens", "leadbay_promote_lens", "leadbay_pull_leads", "leadbay_research_lead_by_id", "leadbay_create_campaign", "leadbay_add_leads_to_campaign"], "failure_modes": ["Skips the validation step \u2014 creates a campaign of unvetted leads from a freshly-created lens without giving the manager a chance to drop weak fits", "Creates ONE campaign for all reps without asking about the split \u2014 the user explicitly mentioned per-rep distribution and the prompt should honor it", "Pretends the backend supports cross-user assignment \u2014 campaigns are owned by the caller (creator-scoped). Surface this honestly instead of fabricating an assignment model", "Asks ALL clarifying questions inline before tool calls \u2014 instead, run the lens refinement loop with `leadbay_refine_lead_targeting` which handles the clarification protocol natively"] },
3022
3212
  leadbay_sync_outreach: { "name": "leadbay_sync_outreach", "short_description": `Keep Leadbay's record of who was contacted current: read my mailbox and
3023
3213
  calendar, log every email and meeting with a Leadbay lead on the person it
@@ -3038,11 +3228,13 @@ var PROMPT_CATALOG_BULLETS = {
3038
3228
  leadbay_import_file: `- \`leadbay_import_file\` (optional args: file, instruction): Import a user-supplied CSV/file into Leadbay through five phases with evidence gates \u2014 scan, derive, resolve identities, preserve & commit, then optionally qualify and report. The job is to maximize how many rows the Leadbay system actually ingests and matches.`,
3039
3229
  leadbay_log_outreach: `- \`leadbay_log_outreach\` (required args: lead_id, summary): Log outreach (an email I sent, a call I made, a meeting I had) on a specific lead. Captures verification so the SDR pipeline trusts the entry.`,
3040
3230
  leadbay_new_leads: `- \`leadbay_new_leads\` (optional args: need): Guided net-new lead delivery \u2014 turn a described need ("gyms around Dallas that would buy our flooring") into ICP-perfect NEW companies with qualification evidence and the right contact, via leadbay_find_new_leads. Trigger when the user DESCRIBES who they want: "get me N companies that <profile>", "we're entering <market>". A bare "find me new leads" with no profile, and "today's leads", are the daily lens batch \u2014 leadbay_pull_leads. "Qualify these companies I have" is leadbay_qualify_leads.`,
3231
+ leadbay_outreach_session: `- \`leadbay_outreach_session\` (optional args: source, campaign): Run an outreach session over ANY set of leads: follow-ups, a Discover lens, or a campaign. Assess who is actually reachable, build the lead desk, then record each outcome as you dictate it. Trigger on "relance plan", "campagne de relance", "cold calling session", "prospection session", "outreach session", "let's contact leads", "who do I call today". For a campaign the user NAMES, prefer \`leadbay_work_campaign\`.`,
3041
3232
  leadbay_plan_tour_in_city: `- \`leadbay_plan_tour_in_city\` (required args: city; optional args: date): Use whenever the user names a city they'll be in and asks who to see \u2014 "I'm in SF next Tuesday, who's worth meeting?", "I'm going to Berlin \u2014 who should I visit?", "plan my <city> tour". Any in-person/visit intent tied to a place routes here, NOT to \`leadbay_pull_leads\`. It surfaces follow-ups + fresh Discover leads in the city via \`leadbay_tour_plan\`, ALWAYS offers to plot them on a map (rendering it on yes), then offers outreach drafts + campaign persistence.`,
3042
3233
  leadbay_prospecting_overview: `- \`leadbay_prospecting_overview\`: Orientation for working with Leadbay from any host \u2014 discovery vs. follow-up, the outreach loop, outcome recording, imports, pushback / snooze, and the connected-outreach-tool registry. Trigger when the conversation involves Leadbay leads, prospecting, pipeline, follow-up, outreach, or lens / ICP \u2014 anything from "show me my leads" to "what should I follow up on" to "I'll send via lemlist".`,
3043
3234
  leadbay_qualify_top_n: `- \`leadbay_qualify_top_n\` (optional args: count): Bulk-qualify the top N un-qualified leads in the active lens. Uses leadbay_bulk_qualify_leads with a sensible default budget.`,
3044
3235
  leadbay_refine_audience: `- \`leadbay_refine_audience\` (required args: instruction): Refine the kind of leads Leadbay surfaces beyond firmographics, with a free-text instruction. Handles the clarification round-trip if the new prompt is ambiguous.`,
3045
3236
  leadbay_research_a_domain: `- \`leadbay_research_a_domain\` (required args: domain): Resolve a company by name or domain across the user's visible Discover, Monitor, and Activate corpus, then return everything Leadbay knows about it.`,
3237
+ leadbay_route_session: `- \`leadbay_route_session\` (optional args: city, day): Plan and work a day on the road: pick the place, see the leads on a map, drive them in a sensible order and record each visit as it happens. Trigger on "plan my route", "tourn\xE9e", "road trip", "I'm driving to <city>", "who can I visit in <city>", "plan my day in the field", "prospection terrain". For working leads by PHONE from a desk, prefer \`leadbay_outreach_session\`.`,
3046
3238
  leadbay_setup_team_prospecting: `- \`leadbay_setup_team_prospecting\` (required args: audience; optional args: rep_split): Manager-led prospecting setup: conversationally turn a natural-language audience ask into a Leadbay lens, validate the candidate leads, and persist them as one or more named campaigns the rep(s) can work through. Closes #3630 US3 end-to-end (within the current creator-scoped campaign visibility model).`,
3047
3239
  leadbay_sync_outreach: `- \`leadbay_sync_outreach\` (optional args: lookback_days): Keep Leadbay's record of who was contacted current: read my mailbox and calendar, log every email and meeting with a Leadbay lead on the person it involved, then schedule the same sync to run every day. Trigger on "log my emails in Leadbay", "sync my outreach", "keep Leadbay up to date with my emails".`,
3048
3240
  leadbay_top_accounts_to_activate: `- \`leadbay_top_accounts_to_activate\` (optional args: count, territory): Build a ranked account-conquest plan from Leadbay data \u2014 the accounts worth activating, each with a motif, a pitch and a checklist, ranked by the strongest Leadbay signal. Every figure carries its source, and anything Leadbay can't measure is shown as OMITTED rather than estimated. Uses \`leadbay_bulk_qualify_leads\` and \`leadbay_enrich_titles\`. Trigger on "top 50 accounts to activate", "who should we go after".`,
@@ -3213,6 +3405,38 @@ var CATALOG = [
3213
3405
  )
3214
3406
  ]
3215
3407
  },
3408
+ {
3409
+ name: "leadbay_outreach_session",
3410
+ description: PROMPT_META.leadbay_outreach_session.short_description,
3411
+ arguments: promptArguments("leadbay_outreach_session"),
3412
+ render: (args) => {
3413
+ const source = (args.source ?? "").toLowerCase();
3414
+ const label = source === "discover" ? "new leads" : source === "campaign" ? "campaign" : source === "followups" ? "follow-ups" : "<pick a source>";
3415
+ return [
3416
+ userMessage(
3417
+ substitutePlaceholders(leadbay_outreach_session, {
3418
+ source_label: label,
3419
+ campaign_paren: source === "campaign" && args.campaign ? ` \u2014 ${args.campaign}` : ""
3420
+ })
3421
+ )
3422
+ ];
3423
+ }
3424
+ },
3425
+ {
3426
+ name: "leadbay_route_session",
3427
+ description: PROMPT_META.leadbay_route_session.short_description,
3428
+ arguments: promptArguments("leadbay_route_session"),
3429
+ render: (args) => [
3430
+ userMessage(
3431
+ substitutePlaceholders(leadbay_route_session, {
3432
+ // " in Lyon" / "" — the opening line reads as a sentence either way,
3433
+ // and Phase 0 asks when the place is missing.
3434
+ city_in: args.city ? ` in ${args.city}` : "",
3435
+ day_paren: args.day ? ` (${args.day})` : ""
3436
+ })
3437
+ )
3438
+ ]
3439
+ },
3216
3440
  {
3217
3441
  name: "leadbay_qualify_top_n",
3218
3442
  description: PROMPT_META.leadbay_qualify_top_n.short_description,
@@ -4482,6 +4706,7 @@ var COMPOSITE_FILE_TOOL_NAMES = /* @__PURE__ */ new Set([
4482
4706
  "leadbay_resolve_import_rows",
4483
4707
  "leadbay_scan_portfolio_signals",
4484
4708
  "leadbay_list_lens_seed_candidates",
4709
+ "leadbay_set_prospecting_action",
4485
4710
  "leadbay_set_qualification_questions",
4486
4711
  "leadbay_team_activity",
4487
4712
  "leadbay_update_custom_field",
@@ -6458,7 +6683,7 @@ Get the headless runtime + usage guide to BUILD an interactive HTML artifact who
6458
6683
 
6459
6684
  ## WHEN TO USE
6460
6685
 
6461
- Trigger phrases: "build me a dashboard", "build a call sheet", "interactive artifact", "make a page with buttons", "build an artifact to work my leads", "interactive lead triage board", "a page with buttons that log my calls".
6686
+ Trigger phrases: "build me a dashboard", "build a call sheet", "interactive artifact", "make a page with buttons", "build an artifact to work my leads", "interactive lead triage board", "a page with buttons that log my calls", "relance board", "campagne de relance", "board de relance", "follow-up table", "a table with emails and phone numbers to call my leads", "a table where I can set status and log what happened".
6462
6687
 
6463
6688
  Do NOT use for: "show me today's leads" \u2192 \`leadbay_pull_leads\`; "leads I should follow up with" \u2192 \`leadbay_pull_followups\`; "log that I emailed" \u2192 \`leadbay_report_outreach\`.
6464
6689
 
@@ -6468,6 +6693,7 @@ Examples that SHOULD invoke this tool:
6468
6693
  - "Build me an interactive call sheet for these leads."
6469
6694
  - "Make an artifact with buttons to log call outcomes per lead."
6470
6695
  - "I want a clickable lead-triage board I can work down."
6696
+ - "Build me a relance table with each lead's email and phone, where I can set the status and log what happened."
6471
6697
 
6472
6698
  Examples that should NOT invoke this tool (sound similar, route elsewhere):
6473
6699
  - "Show me today's leads."
@@ -6493,9 +6719,9 @@ Returns \`{ runtime, usage_guide, version }\`:
6493
6719
 
6494
6720
  The model \u2014 two layers. Primitives: \`lb.field\` (value + API-populated options), \`lb.action\` (write/submit), \`lb.resource\` (load-on-click / poll-until-done / \`.refresh()\`), \`lb.list\` (paginated rows); each exposes \`.loading/.error/.subscribe\`, and you bind them to YOUR native elements with \`lb.bindSelect/bindValue/bindAction\` (no style imposed). Domain components (pre-wired, footguns baked in): \`lb.campaigns\`, \`lb.outreach\`, \`lb.note\`, \`lb.like/dislike\`, \`lb.leadHistory\`, \`lb.leadProfile\`, \`lb.callList\`, \`lb.enrichment\` (launch + poll), \`lb.teamActivity\` (manager leaderboard + trend). TanStack-Query's headless-view-model separation, vanilla (cowork is inline-only \u2014 no React).
6495
6721
 
6496
- Canonical uses: a cold-call sheet (\`lb.callList\` + per-row \`lb.outreach\`/\`lb.leadHistory\`); a manager dashboard (\`lb.teamActivity\` \u2192 leaderboard table + Chart.js trend); a live enrichment view (\`lb.enrichment\` \u2192 progress + refresh). Live auto-poll is host-dependent \u2014 always wire a Refresh.
6722
+ Canonical uses: a cold-call sheet (\`lb.callList\` + per-row \`lb.note\`, prospecting toggles and \`lb.leadHistory\`); a manager dashboard (\`lb.teamActivity\` \u2192 leaderboard table + Chart.js trend); a live enrichment view (\`lb.enrichment\` \u2192 progress + refresh). Live auto-poll is host-dependent \u2014 always wire a Refresh.
6497
6723
 
6498
- Write-call footguns (in the guide, repeated because they bite): for \`leadbay_report_outreach\` (status/disposition) the \`args\` MUST include \`verification:{source:"user_confirmed",ref:"\u2026"}\` AND \`_triggered_by:"<the user's request>"\`, or the call is rejected. \`leadbay_add_leads_to_campaign\` needs \`_triggered_by\` too. \`leadbay_add_note\`/\`leadbay_like_lead\`/\`leadbay_dislike_lead\` need only their own args. Snoozing (pushback) is advanced-gated \u2014 not callable from a default artifact. Org CRM status IS available: \`lb.leadStatus()\` gives the Wanted/Won/Lost/Unwanted picker field and \`lb.setStatus()\` the write (\`leadbay_set_lead_status\`), with the partial-write check baked in. Keep it distinct from \`report_outreach\`'s \`epilogue_status\`, which records how one outreach ATTEMPT went.
6724
+ Write-call footguns (in the guide, repeated because they bite): a page's buttons NEVER call \`leadbay_report_outreach\` (\`lb.outreach\`) \u2014 it asks a human to confirm every \`user_confirmed\` call, a page cannot show that prompt, and the button hangs 60s before writing anyway. Write the note with \`lb.note\` (\`leadbay_add_note\`) and the prospecting action with \`leadbay_set_prospecting_action\` (\`{lead_id, action, selected, _triggered_by}\`), toggles over the lead's \`epilogue_today_statuses\` as in the web app. \`leadbay_add_leads_to_campaign\` needs \`_triggered_by\` too. \`leadbay_add_note\`/\`leadbay_like_lead\`/\`leadbay_dislike_lead\` need only their own args. Snoozing (pushback) is advanced-gated \u2014 not callable from a default artifact. Org CRM status IS available: \`lb.leadStatus()\` gives the Wanted/Won/Lost/Unwanted picker field and \`lb.setStatus()\` the write (\`leadbay_set_lead_status\`), with the partial-write check baked in. Keep it distinct from the prospecting action, which records how one outreach ATTEMPT went.
6499
6725
 
6500
6726
  WHEN TO USE: the user asks for a clickable / interactive artifact, dashboard, or call sheet that DOES things (not just displays data).
6501
6727
 
@@ -8620,7 +8846,7 @@ User picks \u2192 call the matching \`Calls\` tool. Constraints: 2\u20134 mutual
8620
8846
  | Observation | Suggest | Calls |
8621
8847
  |---|---|---|
8622
8848
  | Always (top of menu) | "Prep outreach for [top row's contact]" | leadbay_prepare_outreach(leadId) |
8623
- | \u2265 1 lead returned | "Build an interactive call board" | leadbay_get_artifact_runtime \u2192 its CANONICAL board recipe, data in hand |
8849
+ | \u2265 1 lead returned | "Build an interactive board to contact these leads" | leadbay_get_artifact_runtime \u2192 its LEAD DESK recipe, data in hand |
8624
8850
  | "how well do we cover sector X / city Y" | "Build a coverage board" | leadbay_get_artifact_runtime \u2192 its COVERAGE recipe (\`lb.portfolioSectors\` + \`lb.segmentCount\`) |
8625
8851
  | User named a city / sector / timeframe | "Refilter by [their phrase]" | leadbay_pull_followups(set_filter: { criteria: [...] }) |
8626
8852
  | \`pagination.has_more == true\` | "Pull the next page" | leadbay_pull_followups(page = current + 1) |
@@ -10400,6 +10626,44 @@ follow-up disposition), expressing taste rather than an outcome (\`leadbay_like_
10400
10626
 
10401
10627
  This tool MUTATES state. The caller (agent or human-in-the-loop) is responsible for confirming intent before invocation; the MCP server does not soft-prompt for confirmation. See \`annotations.destructiveHint\`.
10402
10628
  `;
10629
+ var leadbay_set_prospecting_action = `## WHAT IT DOES
10630
+
10631
+ Turns one of a lead's prospecting actions for today on or off (Still chasing, Couldn't reach, Meeting planned, Not interested), exactly as the web app's Prospection cell does. Writes no note.
10632
+
10633
+ ## WHEN TO USE
10634
+
10635
+ Trigger phrases: "mark it as still chasing", "set the prospecting action", "untick meeting planned", "remove the couldn't reach", "clear the prospecting action", "that was a mistake, unset it".
10636
+
10637
+ Do NOT use for: "I called them / I emailed them / we met" \u2192 \`leadbay_report_outreach\`; "mark it as won / lost" \u2192 \`leadbay_set_lead_status\`.
10638
+
10639
+ Use this when: the user sets or clears the action flag itself, with no outreach to record; when an outreach happened, log it with leadbay_report_outreach instead
10640
+
10641
+ Examples that SHOULD invoke this tool:
10642
+ - "Untick Meeting planned on Solimac, I clicked it by mistake."
10643
+ - "Set Bachelard to Still chasing."
10644
+ - "Clear the Couldn't reach on the Courth\xE9zon lead."
10645
+
10646
+ Examples that should NOT invoke this tool (sound similar, route elsewhere):
10647
+ - "I just called Solimac, they want a demo \u2014 log it."
10648
+ - "Mark Bachelard as won."
10649
+ - "Draft a follow-up email to Magali."
10650
+
10651
+ ## RENDER (quick)
10652
+
10653
+ One line: \u2705 "<Action>" on / off for <Company>. No table. When turning an
10654
+ action ON after a real call or visit, suggest logging it with
10655
+ leadbay_report_outreach so the note carries what happened.
10656
+
10657
+ ---
10658
+
10659
+ Sets or clears one prospecting action for **today** on one lead. \`selected: true\` wraps \`POST /leads/epilogue\`; \`selected: false\` wraps \`DELETE /leads/{id}/epilogue?type=\`. Both return 204; the tool echoes \`{lead_id, action, selected}\`.
10660
+
10661
+ **What "selected" means.** A lead's actions for today live in \`epilogue_today_statuses\`, and several can be on at once \u2014 the web app shows them as a multi-select. Turning one on appends it there and moves \`epilogue_status\`; turning one off removes that type from today's list and leaves \`epilogue_status\` alone. So read the current state from \`epilogue_today_statuses\`, never from \`epilogue_status\`, which is only the last value ever set.
10662
+
10663
+ **Not a substitute for logging outreach.** This writes no note and takes no verification, because it records a flag the user controls, not a claim that something happened. When the user tells you a call, email, message or meeting took place, use \`leadbay_report_outreach\` \u2014 it writes the note, sets the action, and carries the proof.
10664
+
10665
+ **Do not turn an action on twice.** Each \`selected: true\` appends another entry to today's list. Check \`epilogue_today_statuses\` first.
10666
+ `;
10403
10667
  var leadbay_set_pushback = `Snooze (pushback) one or more leads for 3, 6, or 12 months. The leads remain in the user's pipeline but are excluded from \`leadbay_pull_followups\` until the pushback window expires. Use this when the user says "not now", "next quarter", "follow up in 3 months", "6 months out", "next year", or any equivalent deferral.
10404
10668
 
10405
10669
  Accepts short labels (\`3\`, \`6\`, \`12\` \u2014 months) or the wire-format enum (\`PUSHBACK_3\`, \`PUSHBACK_6\`, \`PUSHBACK_12\`). Bulk-native: pass up to 1000 lead UUIDs per call.
@@ -18353,7 +18617,7 @@ function buildPullLeadsNextSteps(args) {
18353
18617
  const options = [];
18354
18618
  options.push({
18355
18619
  label: "Triage board",
18356
- description: "Build an interactive lead triage board to sort and filter this batch.",
18620
+ description: "Build an interactive lead triage board to sort and filter this batch \u2014 call leadbay_get_artifact_runtime and follow its canonical triage-board recipe, building from the leads in hand (do NOT re-call pull_leads).",
18357
18621
  kind: "build_artifact"
18358
18622
  });
18359
18623
  options.push({
@@ -18776,13 +19040,27 @@ function mergeLocationIds(filter, ids) {
18776
19040
  }
18777
19041
  return { criteria };
18778
19042
  }
18779
- function buildFollowupNextSteps(leadCount2, hasMore, nextPage, hasActiveFilter) {
19043
+ function isMappable(lead) {
19044
+ const p = lead?.location?.pos;
19045
+ if (!Array.isArray(p) || p.length < 2)
19046
+ return false;
19047
+ const [lat, lng] = p;
19048
+ if (typeof lat !== "number" || typeof lng !== "number")
19049
+ return false;
19050
+ if (!Number.isFinite(lat) || !Number.isFinite(lng))
19051
+ return false;
19052
+ if (lat === 0 && lng === 0)
19053
+ return false;
19054
+ return lat >= -90 && lat <= 90 && lng >= -180 && lng <= 180;
19055
+ }
19056
+ var GEO_DENSE_RATIO = 0.6;
19057
+ function buildFollowupNextSteps(leadCount2, hasMore, nextPage, hasActiveFilter, mappableCount) {
18780
19058
  if (leadCount2 === 0)
18781
19059
  return null;
18782
19060
  const options = [];
18783
19061
  options.push({
18784
- label: "Call board",
18785
- description: "Build an interactive call board to work these leads and log outreach.",
19062
+ label: "Contact and outreach",
19063
+ description: "Build an interactive board to contact these leads: contacts with their email and phone, the enrich that buys a missing one, CRM status, outreach logging, taste and requalify \u2014 one row per lead. Call leadbay_get_artifact_runtime and follow its LEAD DESK recipe, building from the leads in hand (do NOT re-call pull_followups).",
18786
19064
  kind: "build_artifact"
18787
19065
  });
18788
19066
  options.push({
@@ -18790,9 +19068,15 @@ function buildFollowupNextSteps(leadCount2, hasMore, nextPage, hasActiveFilter)
18790
19068
  description: "Prepare a call opener and email for the top lead.",
18791
19069
  kind: "enrich_top_leads"
18792
19070
  });
18793
- options.push({
19071
+ const coverageRecipe = " Call leadbay_get_artifact_runtime and follow its segment-coverage recipe \u2014 `lb.portfolioSectors` for the sector list (never hardcode one) and `lb.segmentCount` for each figure (it verifies the echoed filter).";
19072
+ const geoDense = typeof mappableCount === "number" && leadCount2 > 0 && mappableCount / leadCount2 >= GEO_DENSE_RATIO;
19073
+ options.push(geoDense ? {
19074
+ label: "Route planner",
19075
+ description: `${mappableCount} of these ${leadCount2} leads have coordinates \u2014 build an interactive route planner: the map on one half, the lead list on the other, clicking a marker opens that lead to set status, log outreach and record a prospecting action, with the pin repainting as you work. Call leadbay_get_artifact_runtime and follow its ROUTE PLANNER recipe, building from the leads in hand (do NOT re-call pull_followups). List the leads WITHOUT coordinates beside the map rather than dropping them.`,
19076
+ kind: "build_artifact"
19077
+ } : {
18794
19078
  label: "Coverage board",
18795
- description: hasActiveFilter ? "Build a coverage board measuring this filtered slice against the whole book \u2014 the filter is server-stored, so measure unfiltered for the denominator." : "Build a coverage board measuring how much of the portfolio sits in each sector or city.",
19079
+ description: (hasActiveFilter ? "Build a coverage board measuring this filtered slice against the whole book \u2014 the filter is server-stored, so measure unfiltered for the denominator." : "Build a coverage board measuring how much of the portfolio sits in each sector or city.") + coverageRecipe,
18796
19080
  kind: "build_artifact"
18797
19081
  });
18798
19082
  if (hasMore && nextPage != null) {
@@ -19094,7 +19378,15 @@ var pullFollowups = {
19094
19378
  total_excluded_by_pushback: excluded,
19095
19379
  ...emails.some(Boolean) ? { reply_check: REPLY_CHECK } : {},
19096
19380
  ...outreachSync ? { outreach_sync: outreachSync } : {},
19097
- next_steps: buildFollowupNextSteps(leads.length, moreToCome, moreToCome ? currentPage + 1 : null, Array.isArray(filterCriteria) && filterCriteria.length > 0),
19381
+ next_steps: buildFollowupNextSteps(
19382
+ leads.length,
19383
+ moreToCome,
19384
+ moreToCome ? currentPage + 1 : null,
19385
+ Array.isArray(filterCriteria) && filterCriteria.length > 0,
19386
+ // How many of these can go on a map. A geo-dense page is offered the
19387
+ // route planner in place of the sector board.
19388
+ leads.filter(isMappable).length
19389
+ ),
19098
19390
  _meta: {
19099
19391
  region: client.region,
19100
19392
  latency_ms: client.lastMeta?.latency_ms ?? null
@@ -19125,6 +19417,32 @@ var followupsMap = {
19125
19417
  };
19126
19418
 
19127
19419
  // ../core/dist/composite/tour-plan.js
19420
+ function buildTourNextSteps(monitorLeads, discoverLeads2, city) {
19421
+ const all = [...monitorLeads, ...discoverLeads2];
19422
+ if (all.length === 0)
19423
+ return null;
19424
+ const mappable = all.filter(isMappable).length;
19425
+ const where = city ? ` in ${city}` : "";
19426
+ const options = [];
19427
+ options.push({
19428
+ label: "Route planner",
19429
+ description: `Build an interactive route planner for these ${all.length} leads${where}: the map on one half, the lead list on the other, clicking a marker opens that lead to set status, log outreach and record a prospecting action, with the pin repainting as you work. Call leadbay_get_artifact_runtime and follow its ROUTE PLANNER recipe, building from the leads in hand (do NOT re-call tour_plan).` + (mappable < all.length ? ` ${all.length - mappable} have no coordinates \u2014 list them beside the map rather than dropping them.` : ""),
19430
+ kind: "build_artifact"
19431
+ });
19432
+ options.push({
19433
+ label: "Prep outreach",
19434
+ description: "Prepare a call opener and email for the top lead on the tour, so the first visit has something to open with.",
19435
+ kind: "enrich_top_leads"
19436
+ });
19437
+ if (monitorLeads.length > 0) {
19438
+ options.push({
19439
+ label: "Contact and outreach",
19440
+ description: "Build the lead desk instead \u2014 one row per lead with contacts, status and outreach logging, for working these by phone rather than in person. Call leadbay_get_artifact_runtime and follow its LEAD DESK recipe.",
19441
+ kind: "build_artifact"
19442
+ });
19443
+ }
19444
+ return { question: "What do you want to do next?", options: options.slice(0, 4) };
19445
+ }
19128
19446
  var DEFAULT_FOLLOWUPS_COUNT = 6;
19129
19447
  var DEFAULT_DISCOVER_COUNT = 6;
19130
19448
  var DISCOVER_OVER_PULL = 30;
@@ -19321,6 +19639,25 @@ var tourPlan = {
19321
19639
  type: "string",
19322
19640
  description: "Human-readable summary of the client-side geo filter applied to Discover leads. Says how many stops are in the named town, how many are within `radius_km` of it, and which towns those are in \u2014 repeat that split to the user rather than presenting every stop as being in the city they named."
19323
19641
  },
19642
+ next_steps: {
19643
+ type: ["object", "null"],
19644
+ description: "Deterministic follow-on offers, the route planner FIRST. Map `options[]` into the host's next-step widget VERBATIM and in order \u2014 do not reword, reorder or drop them. null when the tour found no leads. Each option: {label (\u22645 words), description (the full sentence), kind}.",
19645
+ properties: {
19646
+ question: { type: "string" },
19647
+ options: {
19648
+ type: "array",
19649
+ items: {
19650
+ type: "object",
19651
+ properties: {
19652
+ label: { type: "string" },
19653
+ description: { type: "string" },
19654
+ kind: { type: "string" }
19655
+ },
19656
+ required: ["label", "description", "kind"]
19657
+ }
19658
+ }
19659
+ }
19660
+ },
19324
19661
  map_locations: {
19325
19662
  type: "array",
19326
19663
  description: "Pre-shaped entries for `places_map_display_v0` \u2014 pass each one verbatim ({name, address, latitude, longitude, notes}); the mode badge (\u2605 Customer / \u2605 Qualified / \u2726 New) is already in `notes`. Do NOT reshape or re-derive from `location.pos`. One entry per lead with valid coordinates; coordinate-less leads are omitted and counted in `map_summary`.",
@@ -19483,6 +19820,7 @@ var tourPlan = {
19483
19820
  discover_leads: discoverLeads2,
19484
19821
  discover_filter_note: filterNote,
19485
19822
  ...buildMap(monitorLeads, discoverLeads2),
19823
+ next_steps: buildTourNextSteps(monitorLeads, discoverLeads2, params.city ?? null),
19486
19824
  _meta: {
19487
19825
  region: client.region,
19488
19826
  latency_ms: client.lastMeta?.latency_ms ?? null
@@ -19789,6 +20127,44 @@ var campaignProgression = {
19789
20127
  };
19790
20128
 
19791
20129
  // ../core/dist/composite/campaign-call-sheet.js
20130
+ function buildCallSheetNextSteps(args) {
20131
+ const { leadCount: leadCount2, campaignId, leadsWithoutContacts, hasMore, nextPage } = args;
20132
+ if (leadCount2 <= 0)
20133
+ return null;
20134
+ const options = [];
20135
+ const mostlyEmpty = leadsWithoutContacts * 2 > leadCount2;
20136
+ options.push({
20137
+ label: "Contact and outreach",
20138
+ description: `Build an interactive board to contact this campaign: contacts with their email and phone, the enrich that buys a missing one, CRM status, outreach logging, taste and requalify \u2014 one row per lead. Call leadbay_get_artifact_runtime and follow its LEAD DESK recipe with source \`campaign\` and campaignId \`${campaignId}\`, building from the leads in hand (do NOT re-call the call sheet).` + (mostlyEmpty ? " Most rows here have no contact yet, so lead with the enrich control." : ""),
20139
+ kind: "build_artifact"
20140
+ });
20141
+ if (mostlyEmpty) {
20142
+ options.push({
20143
+ label: "Enrich the campaign",
20144
+ description: `${leadsWithoutContacts} of ${leadCount2} leads on this page have no contact to call. Order contact enrichment for the buyer titles before working the sheet.`,
20145
+ kind: "enrich_top_leads"
20146
+ });
20147
+ } else {
20148
+ options.push({
20149
+ label: "Prep outreach",
20150
+ description: "Prepare a call opener and email for the top lead on the sheet.",
20151
+ kind: "enrich_top_leads"
20152
+ });
20153
+ }
20154
+ options.push({
20155
+ label: "Campaign progress",
20156
+ description: "Show how far this campaign has been worked \u2014 contacted, replied, still open.",
20157
+ kind: "qualify_deeper"
20158
+ });
20159
+ if (hasMore && nextPage != null) {
20160
+ options.push({
20161
+ label: "Next page",
20162
+ description: `Pull page ${nextPage + 1} of the call sheet.`,
20163
+ kind: "pull_next_page"
20164
+ });
20165
+ }
20166
+ return { question: "What do you want to do next?", options: options.slice(0, 4) };
20167
+ }
19792
20168
  function hasOutreachSignal2(progress) {
19793
20169
  if (!progress)
19794
20170
  return false;
@@ -19883,6 +20259,25 @@ var campaignCallSheet = {
19883
20259
  total: { type: "number" }
19884
20260
  }
19885
20261
  },
20262
+ next_steps: {
20263
+ type: ["object", "null"],
20264
+ description: "Deterministic follow-on offers, artifact option FIRST. Map `options[]` into the host's next-step widget VERBATIM and in order \u2014 do not reword, reorder or drop them. null when the sheet is empty. Each option: {label (\u22645 words), description (the full sentence), kind}.",
20265
+ properties: {
20266
+ question: { type: "string" },
20267
+ options: {
20268
+ type: "array",
20269
+ items: {
20270
+ type: "object",
20271
+ properties: {
20272
+ label: { type: "string" },
20273
+ description: { type: "string" },
20274
+ kind: { type: "string" }
20275
+ },
20276
+ required: ["label", "description", "kind"]
20277
+ }
20278
+ }
20279
+ }
20280
+ },
19886
20281
  _meta: {
19887
20282
  type: "object",
19888
20283
  properties: {
@@ -20005,10 +20400,18 @@ var campaignCallSheet = {
20005
20400
  needs_enrichment: noContactRatio >= 0.3 || phoneRatio < 0.4 && emailRatio < 0.4,
20006
20401
  travel_friendly: leadsWithCoords >= 5 && coordRatio >= 0.6
20007
20402
  };
20403
+ const nextSteps = buildCallSheetNextSteps({
20404
+ leadCount: blocks.length,
20405
+ campaignId: params.campaign_id,
20406
+ leadsWithoutContacts,
20407
+ hasMore: typeof leadsRes.pagination?.pages === "number" && typeof leadsRes.pagination?.page === "number" && leadsRes.pagination.page + 1 < leadsRes.pagination.pages,
20408
+ nextPage: typeof leadsRes.pagination?.page === "number" ? leadsRes.pagination.page + 1 : null
20409
+ });
20008
20410
  return {
20009
20411
  campaign_id: params.campaign_id,
20010
20412
  leads: blocks,
20011
20413
  map_locations: mapLocations,
20414
+ next_steps: nextSteps,
20012
20415
  summary: {
20013
20416
  total_leads: blocks.length,
20014
20417
  total_contacts: totalContacts,
@@ -26671,7 +27074,7 @@ function buildFindNewLeadsNextSteps(delivered, stillRunning, stopReason, truncat
26671
27074
  if (delivered > 0) {
26672
27075
  options.push({
26673
27076
  label: "Triage what landed",
26674
- description: "Build an interactive lead triage board from the leads delivered so far.",
27077
+ description: "Build an interactive lead triage board from the leads delivered so far \u2014 call leadbay_get_artifact_runtime and follow its canonical triage-board recipe.",
26675
27078
  kind: "build_artifact"
26676
27079
  });
26677
27080
  }
@@ -26687,7 +27090,7 @@ function buildFindNewLeadsNextSteps(delivered, stillRunning, stopReason, truncat
26687
27090
  }
26688
27091
  options.push({
26689
27092
  label: "Triage board",
26690
- description: "Build an interactive lead triage board to sort and filter this batch.",
27093
+ description: "Build an interactive lead triage board to sort and filter this batch \u2014 call leadbay_get_artifact_runtime and follow its canonical triage-board recipe, building from the leads in hand (do NOT re-run the search).",
26691
27094
  kind: "build_artifact"
26692
27095
  });
26693
27096
  if (stopReason === "max_cost") {
@@ -28718,6 +29121,73 @@ var reportOutreach = {
28718
29121
  }
28719
29122
  };
28720
29123
 
29124
+ // ../core/dist/composite/set-prospecting-action.js
29125
+ var setProspectingAction = {
29126
+ name: "leadbay_set_prospecting_action",
29127
+ annotations: {
29128
+ title: "Set or clear a prospecting action",
29129
+ readOnlyHint: false,
29130
+ // A per-day status flag the rep toggles, like the web app's cell: turning
29131
+ // it off is the undo of turning it on, not the loss of a record.
29132
+ destructiveHint: false,
29133
+ // Turning an action on twice appends it to today's list twice.
29134
+ idempotentHint: false,
29135
+ openWorldHint: false
29136
+ },
29137
+ description: leadbay_set_prospecting_action,
29138
+ optional: true,
29139
+ write: true,
29140
+ inputSchema: {
29141
+ type: "object",
29142
+ properties: {
29143
+ lead_id: { type: "string", description: "Lead UUID." },
29144
+ action: {
29145
+ type: "string",
29146
+ enum: [
29147
+ "STILL_CHASING",
29148
+ "COULD_NOT_REACH_STILL_TRYING",
29149
+ "INTEREST_VALIDATED_OR_MEETING_PLANED",
29150
+ "NOT_INTERESTED_LOST"
29151
+ ],
29152
+ description: "The prospecting action. The EPILOGUE_-prefixed form is accepted too."
29153
+ },
29154
+ selected: {
29155
+ type: "boolean",
29156
+ description: "true to turn the action on for today, false to turn it off."
29157
+ }
29158
+ },
29159
+ required: ["lead_id", "action", "selected"],
29160
+ additionalProperties: false
29161
+ },
29162
+ outputSchema: {
29163
+ type: "object",
29164
+ properties: {
29165
+ lead_id: { type: "string" },
29166
+ action: { type: "string", description: "The short form, e.g. STILL_CHASING." },
29167
+ selected: { type: "boolean", description: "The state the action is now in." }
29168
+ },
29169
+ required: ["lead_id", "action", "selected"]
29170
+ },
29171
+ execute: async (client, params) => {
29172
+ if (!params.lead_id) {
29173
+ throw client.makeError("INVALID_PARAMS", "lead_id is required", "Pass the lead's UUID.");
29174
+ }
29175
+ const wire = EPILOGUE_LABEL_MAP[params.action];
29176
+ if (!wire) {
29177
+ throw client.makeError("INVALID_PARAMS", `Unknown prospecting action: ${params.action}`, "Use STILL_CHASING, COULD_NOT_REACH_STILL_TRYING, INTEREST_VALIDATED_OR_MEETING_PLANED or NOT_INTERESTED_LOST.");
29178
+ }
29179
+ if (typeof params.selected !== "boolean") {
29180
+ throw client.makeError("INVALID_PARAMS", "selected must be true or false", "true turns the action on, false turns it off.");
29181
+ }
29182
+ if (params.selected) {
29183
+ await client.requestVoid("POST", "/leads/epilogue", { lead_ids: [params.lead_id], status: wire });
29184
+ } else {
29185
+ await client.requestVoid("DELETE", `/leads/${encodeURIComponent(params.lead_id)}/epilogue?type=${encodeURIComponent(wire)}`);
29186
+ }
29187
+ return { lead_id: params.lead_id, action: wire.replace(/^EPILOGUE_/, ""), selected: params.selected };
29188
+ }
29189
+ };
29190
+
28721
29191
  // ../core/dist/composite/report-friction.js
28722
29192
  var VALID_CATEGORIES = /* @__PURE__ */ new Set([
28723
29193
  "silent_failure",
@@ -29060,8 +29530,8 @@ var sendFeedback = {
29060
29530
 
29061
29531
  // ../core/dist/artifact-runtime.generated.js
29062
29532
  var ARTIFACT_KIT_VERSION = "0.6.0";
29063
- var ARTIFACT_RUNTIME = '"use strict";(()=>{var Z=Object.defineProperty;var Y=(e,t,r)=>t in e?Z(e,t,{enumerable:!0,configurable:!0,writable:!0,value:r}):e[t]=r;var i=(e,t,r)=>Y(e,typeof t!="symbol"?t+"":t,r);var U=`\n:root{\n--color-black:#191919;--color-white:#fff;\n--color-gray-1:#f9f9f9;--color-gray-2:#f0f0f0;--color-gray-3:#e0e0e0;--color-gray-4:#cecece;\n--color-gray-5:#c4c4c4;--color-gray-6:#8d8d8d;--color-gray-7:#787878;--color-gray-8:#646464;\n--color-gray-9:#202020;\n--color-linkedin:#0a66c2;\n--color-blue-background:oklch(0.947 0.029 251);--color-blue-foreground:oklch(0.564 0.181 251);\n--color-green-background:oklch(0.947 0.029 141);--color-green-foreground:oklch(0.564 0.181 141);\n--color-red-background:oklch(0.947 0.029 26);--color-red-foreground:oklch(0.564 0.191 26);\n--color-gold-background:oklch(0.972 0.049 91);--color-gold-foreground:oklch(0.667 0.177 91);\n--color-cherry-background:oklch(0.947 0.029 15);--color-cherry-foreground:oklch(0.44 0.146 15);\n/* Purple carries the product\'s "AI" affordance (Qualify / Requalify). The\n border is the foreground at 65% transparency, exactly as the style package\n derives every *-border token. */\n--color-purple-background:oklch(0.947 0.029 288);--color-purple-foreground:oklch(0.464 0.181 288);\n--color-purple-border:color-mix(in oklch,var(--color-purple-foreground),transparent 65%);\n/* Teal carries the qualifier\'s intent tags, as the product\'s taste-profile\n tags do. Border derived at 65% transparency, like every *-border token. */\n--color-teal-background:oklch(0.977 0.019 180);--color-teal-foreground:oklch(0.574 0.182 180);\n--color-teal-border:color-mix(in oklch,var(--color-teal-foreground),transparent 65%);\n--color-red-like:var(--color-cherry-foreground);\n--lb-font:"Nikkei Maru",system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;\n/* Concentric: outer = inner + padding (0.625 + 0.875 = 1.5rem). The card\'s\n padding is what closes this equation, so the two move together \\u2014 see\n .lb-card, which pads 0.875rem for exactly this reason. */\n--lb-radius:1.5rem;--lb-radius-sm:0.625rem;--lb-gap:0.75rem;\n--lb-surface:var(--color-gray-1);--lb-border:var(--color-gray-3);\n--lb-fg:var(--color-black);--lb-muted:var(--color-gray-8);--lb-field:var(--color-white);\n/* Chips and shadows need their own aliases: a chip painted with a raw ramp\n value cannot theme, and a shadow tinted with --color-gray-9 is invisible in\n dark because gray-9 IS the dark surface. */\n--lb-chip-bg:var(--color-gray-2);\n--lb-control-border:var(--color-gray-6);\n--lb-shadow:0 0.5rem 1.5rem oklch(0 0 0/.06),0 0.125rem 0.5rem oklch(0 0 0/.04);\ncolor-scheme:light;\n/* macOS renders text heavier than intended. This belongs once on the root,\n never per component \\u2014 an artifact that injects this sheet gets it for free\n instead of each page re-declaring it on its own body. */\n-webkit-font-smoothing:antialiased;\n-moz-osx-font-smoothing:grayscale;\n}\n:root[data-theme=dark],:root[data-lb-theme=dark]{\n--lb-surface:var(--color-gray-9);--lb-border:var(--color-gray-8);\n--lb-fg:var(--color-white);--lb-muted:var(--color-gray-3);\n/* --lb-field must NOT equal --lb-surface: a control needs its own ground or it\n reads as text on the card (was 1.00:1). */\n--lb-field:#2c2c2c;\n--lb-chip-bg:#3b3b3b;\n--lb-control-border:var(--color-gray-6);\n--lb-shadow:0 0.5rem 1.5rem oklch(0 0 0/.5),0 0.125rem 0.5rem oklch(0 0 0/.35);\ncolor-scheme:dark;\n--color-blue-background:oklch(0.30 0.055 251);--color-blue-foreground:oklch(0.80 0.11 251);\n--color-green-background:oklch(0.30 0.055 141);--color-green-foreground:oklch(0.80 0.13 141);\n--color-red-background:oklch(0.30 0.060 26);--color-red-foreground:oklch(0.80 0.11 26);\n--color-gold-background:oklch(0.30 0.055 91);--color-gold-foreground:oklch(0.82 0.13 91);\n--color-cherry-background:oklch(0.30 0.055 15);--color-cherry-foreground:oklch(0.82 0.10 15);\n--color-purple-background:oklch(0.30 0.055 288);--color-purple-foreground:oklch(0.82 0.11 288);\n--color-teal-background:oklch(0.30 0.055 180);--color-teal-foreground:oklch(0.82 0.11 180);\n}\n@media(prefers-color-scheme:dark){:root:not([data-theme=light]):not([data-lb-theme=light]){\n--lb-surface:var(--color-gray-9);--lb-border:var(--color-gray-8);\n--lb-fg:var(--color-white);--lb-muted:var(--color-gray-3);\n/* --lb-field must NOT equal --lb-surface: a control needs its own ground or it\n reads as text on the card (was 1.00:1). */\n--lb-field:#2c2c2c;\n--lb-chip-bg:#3b3b3b;\n--lb-control-border:var(--color-gray-6);\n--lb-shadow:0 0.5rem 1.5rem oklch(0 0 0/.5),0 0.125rem 0.5rem oklch(0 0 0/.35);\ncolor-scheme:dark;\n--color-blue-background:oklch(0.30 0.055 251);--color-blue-foreground:oklch(0.80 0.11 251);\n--color-green-background:oklch(0.30 0.055 141);--color-green-foreground:oklch(0.80 0.13 141);\n--color-red-background:oklch(0.30 0.060 26);--color-red-foreground:oklch(0.80 0.11 26);\n--color-gold-background:oklch(0.30 0.055 91);--color-gold-foreground:oklch(0.82 0.13 91);\n--color-cherry-background:oklch(0.30 0.055 15);--color-cherry-foreground:oklch(0.82 0.10 15);\n--color-purple-background:oklch(0.30 0.055 288);--color-purple-foreground:oklch(0.82 0.11 288);\n--color-teal-background:oklch(0.30 0.055 180);--color-teal-foreground:oklch(0.82 0.11 180);\n}}\n/* 0.875rem padding is not arbitrary: it closes the concentric equation with\n --lb-radius (1.5rem) and --lb-radius-sm (0.625rem). Changing it without\n moving --lb-radius leaves a control\'s arc running wider than the card\'s in\n the corner. */\n.lb-card{display:grid;gap:var(--lb-gap);padding:0.875rem;\nbackground-color:var(--lb-surface);border:1px solid var(--lb-border);\nborder-radius:var(--lb-radius);corner-shape:squircle;color:var(--lb-fg);\nfont-family:var(--lb-font);\nbox-shadow:var(--lb-shadow)}\n/* \\u2500\\u2500 Card layout \\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\n A lead card is a stack of SECTIONS, each a titled block (Fit, Intent tags,\n Data, Status, Outreach). One wrapper owns the rhythm \\u2014 16px between\n sections, 8px between the items inside one \\u2014 so a section can be added,\n removed or reordered without touching spacing, and no margin can collapse.\n Every artifact that builds cards gets the same geometry from here rather\n than hand-rolling it. */\n.lb-sections{display:flex;flex-direction:column;gap:1rem}\n.lb-section{display:grid;gap:0.5rem}\n/* A section title: black, uppercase, tracked. It names the block below it, so\n it must sit at SECTION level \\u2014 nested inside a row it stops being a peer of\n the other titles and the card loses its scan order. */\n.lb-sec-title{font-size:0.75rem;font-weight:700;letter-spacing:.08em;\ntext-transform:uppercase;color:var(--lb-fg);line-height:1.4;\ntext-wrap:balance}\n/* Header row: leading control (a bulk checkbox), the title, then trailing\n controls (taste) pushed to the far edge. */\n.lb-card-top{display:flex;align-items:center;gap:0.625rem;flex-wrap:wrap}\n.lb-card-top>.lb-group{flex:0 0 auto;margin-inline-start:auto}\n.lb-card-top>input[type=checkbox]{flex:0 0 auto;width:1rem;height:1rem;margin:0}\n/* The company name. Bold and underlined so it reads as the card\'s subject and\n as a link, without competing with the uppercase section titles. */\n/* text-wrap:balance keeps a two-line company name from breaking one-word-over;\n the underline takes its position and thickness from the font\'s own metrics\n rather than wherever the browser puts them, and skip-ink clears descenders. */\n.lb-lead-title{flex:1 1 9rem;min-width:0;font-size:0.9375rem;font-weight:700;\nline-height:1.3;color:var(--lb-fg);text-wrap:balance;text-decoration:underline;\ntext-underline-position:from-font;text-decoration-thickness:from-font;\ntext-underline-offset:2px;text-decoration-skip-ink:auto;overflow-wrap:break-word}\n.lb-lead-title a{color:inherit;text-decoration:inherit}\n/* Controls in a section stack, each spanning it, so every control on the card\n shares one leading edge and one width. Three different widths across a row\n read as three unrelated things. */\n.lb-section>.lb-select,.lb-section>.lb-input,\n.lb-stack>.lb-select,.lb-stack>.lb-input,.lb-stack>.lb-btn{width:100%;min-width:0}\n/* Footer: a leading action, a spacer, a trailing escape hatch. */\n.lb-card-foot{display:flex;align-items:center;gap:0.625rem;\npadding-block-start:0.875rem;border-block-start:1px solid var(--lb-border)}\n.lb-card-foot .lb-spacer{flex:1 1 auto}\n/* \\u2500\\u2500 Toolbar \\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\n The bar above a deck of cards: a tally, the controls that narrow what is\n shown, and the controls that act on a selection. Those are three different\n jobs, so they are three groups \\u2014 0.5rem within one, 1.5rem between \\u2014 rather\n than one flat row where "apply to selected" reads as another filter.\n Concentric: it pads 0.75rem around controls that round at 0.625rem, so it\n rounds at 1.375rem. */\n.lb-toolbar{display:flex;flex-wrap:wrap;align-items:flex-end;gap:0.75rem 1.5rem;\npadding:0.75rem 1rem;background-color:var(--lb-surface);\nborder:1px solid var(--lb-border);border-radius:1.375rem;corner-shape:squircle;\nfont-family:var(--lb-font);color:var(--lb-fg)}\n.lb-toolbar .lb-group{gap:0.5rem}\n/* A labelled control stacks: caption above, control below, as a card section\n does. One label pattern across the whole artifact. */\n.lb-field{display:inline-flex;flex-direction:column;align-items:flex-start;\ngap:0.25rem;font-size:0.8125rem;line-height:1.4;color:var(--lb-muted);\nwhite-space:nowrap}\n/* A checkbox belongs BESIDE its words \\u2014 under a caption it reads as orphaned. */\n.lb-field-inline{flex-direction:row;align-items:center;gap:0.5rem}\n/* A control\'s caption is not body copy: it names the control, so it takes the\n same treatment as a card\'s section title. */\n.lb-field-label{font-size:0.75rem;font-weight:700;letter-spacing:.08em;\ntext-transform:uppercase;color:var(--lb-fg)}\n/* A labelled field sizes to its own content, so width:100% on the control\n inside only fills whatever the field already became. Two fields side by side\n still come out unequal \\u2014 they have no shared reference to be equal against.\n Give the FIELD the basis: flex:1 1 9rem makes every labelled field claim the\n same share of the row, so a select and a text input beside it match. */\n.lb-field{flex:1 1 9rem;min-width:9rem;max-width:16rem}\n.lb-field>.lb-select,.lb-field>.lb-input{width:100%;min-width:0}\n/* An inline field holds a checkbox, not a sized control: natural width, no floor. */\n.lb-field-inline{flex:0 0 auto;min-width:0;max-width:none}\n.lb-field-inline>*{min-width:0;width:auto;flex:0 0 auto}\n/* Readouts sit on the control baseline, not the caption\'s. */\n.lb-toolbar .lb-tally,.lb-toolbar .lb-status{align-self:center}\n.lb-tally{font-size:0.8125rem;line-height:1.4;color:var(--lb-muted);\nfont-variant-numeric:tabular-nums;white-space:nowrap}\n/* Connection state, never carried by colour alone: live is a filled dot, dead\n a hollow ring, so the two differ in shape as well as hue \\u2014 a red and a green\n circle are the same circle to a colour-blind reader, and identical under\n forced colours. */\n.lb-status{display:inline-flex;align-items:center;gap:0.5rem;\nfont-size:0.75rem;line-height:1.4;color:var(--lb-muted);white-space:nowrap}\n.lb-status-dot{width:0.5rem;height:0.5rem;border-radius:50%;flex:none;\nbackground-color:var(--color-gray-5);border:2px solid transparent}\n.lb-status[data-live=yes] .lb-status-dot{background-color:var(--color-green-foreground)}\n.lb-status[data-live=no] .lb-status-dot{background-color:transparent;\nborder-color:var(--color-red-foreground)}\n/* \\u2500\\u2500 Pager \\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\n Below a deck, never above it: the rep reads the rows, then decides whether\n to go on. The range ("21\\u201340 of 60") is the honest label \\u2014 "page 2" alone\n does not say how much is left, and a rep who cannot see the end cannot tell\n a short lens from a long one. Tabular, because the numbers change. */\n.lb-pager{display:flex;align-items:center;gap:0.75rem;flex-wrap:wrap}\n.lb-pager .lb-spacer{flex:1 1 auto}\n.lb-pager-range{font-size:0.8125rem;line-height:1.4;color:var(--lb-muted);\nfont-variant-numeric:tabular-nums;white-space:nowrap}\n/* \\u2500\\u2500 Metrics + chart \\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\n A manager dashboard reports before it lists: a row of figures, then the\n trend behind them, then the per-rep table. Tiles are for the few numbers\n that ARE the point \\u2014 a tile per field turns a dashboard into a wall. */\n.lb-tiles{display:grid;gap:0.75rem;\ngrid-template-columns:repeat(auto-fit,minmax(min(9rem,100%),1fr))}\n.lb-tile{display:grid;gap:0.25rem;padding:0.75rem 0.875rem;\nbackground-color:var(--lb-field);border:1px solid var(--lb-border);\nborder-radius:1.375rem;corner-shape:squircle}\n.lb-tile-label{font-size:0.75rem;font-weight:700;letter-spacing:.08em;\ntext-transform:uppercase;color:var(--lb-muted);line-height:1.4}\n.lb-tile-value{font-size:1.5rem;font-weight:700;line-height:1.15;\ncolor:var(--lb-fg);font-variant-numeric:tabular-nums}\n/* The chart draws from the theme tokens, so it reads in light and dark alike;\n a hardcoded stroke disappears on one of the two. Inline SVG rather than a\n CDN library: a sparse weekly series does not earn 200KB, and an artifact\n that silently fails to load its chart shows nothing at all. */\n.lb-chart{display:block;width:100%;height:auto}\n.lb-chart .lb-chart-grid{stroke:var(--lb-border);stroke-width:1}\n.lb-chart .lb-chart-line{fill:none;stroke:var(--color-blue-foreground);stroke-width:2;\nstroke-linejoin:round;stroke-linecap:round}\n.lb-chart .lb-chart-area{fill:var(--color-blue-background);opacity:.55}\n.lb-chart .lb-chart-dot{fill:var(--color-blue-foreground)}\n/* Axis labels are UI text and take the same 12px floor as everything else \\u2014\n a chart is not a licence to shrink type below what is readable. */\n.lb-chart text{fill:var(--lb-muted);font-family:var(--lb-font);font-size:0.75rem}\n/* An empty series is a real answer, not a failure: say so in place of the\n chart rather than drawing empty axes that look broken. */\n.lb-empty{display:grid;place-items:center;gap:0.25rem;padding:1.5rem 1rem;\nbackground-color:var(--lb-field);border:1px dashed var(--lb-border);\nborder-radius:1.375rem;corner-shape:squircle;text-align:center}\n.lb-empty-title{font-size:0.8125rem;font-weight:600;color:var(--lb-fg);line-height:1.4}\n.lb-empty-hint{font-size:0.78125rem;color:var(--lb-muted);line-height:1.45;max-width:44ch}\n/* A page flip replaces the whole deck, so the rows must read as pending or the\n rep cannot tell a slow page from a finished one. Dim and lock the deck\n rather than blanking it: keeping the old rows on screen preserves the\n scroll position and the sense of place. */\n.lb-deck[data-lb-state=loading]{opacity:.55;pointer-events:none}\n/* The head must be able to wrap. Both children default to flex:0 1 auto, so\n without this they shrink together and a long company name is crushed to three\n lines while the chips keep full width. flex-basis:11rem is a content breakpoint:\n below ~176px for the title, the chips drop to their own line instead. */\n.lb-card-head{display:flex;flex-wrap:wrap;justify-content:space-between;\nalign-items:baseline;gap:var(--lb-gap)}\n/* An email or URL has no break opportunity \\u2014 browsers do not break at "@" or\n ".", so without this a company address escapes the card and the chat host\n clips it with no way to recover the tail. */\n.lb-title{font-size:0.875rem;font-weight:600;line-height:1.3;color:var(--lb-fg);\noverflow-wrap:break-word;min-width:0;flex:1 1 11rem}\n/* pretty stops a lone word landing on the last line of a why-it-fits sentence\n or a fact \\u2014 the orphan that makes a card look unfinished. */\n.lb-sub{font-size:0.8125rem;line-height:1.45;color:var(--lb-muted);\ntext-wrap:pretty;overflow-wrap:break-word;max-width:68ch}\n/* An empty fact line is a 30px void that reads as a rendering bug. */\n.lb-sub:empty{display:none}\n/* The tightest group on the card: consecutive data lines (the contact, then\n the company switchboard) are one block, so they sit at HALF the section\'s\n own 8px. That 2x ratio is what makes them read as a unit rather than as\n separate facts \\u2014 the same rule .lb-section applies one level up against\n .lb-sections\' 16px. The scale halves at each level: 16 \\u2192 8 \\u2192 4. */\n.lb-facts{display:grid;gap:0.25rem}\n/* The contract\'s canonical skeleton already emits data-why; the sheet just did\n not honour it, so consumers hand-patched an inline font-style. */\n.lb-sub[data-why]{font-style:italic;color:var(--lb-fg)}\n/* Icon column: the glyph gets its own cell so a wrapping line hangs on one\n shared text edge instead of running back under the emoji. */\n.lb-fact{display:grid;grid-template-columns:1.15rem 1fr;gap:0.35rem;align-items:baseline}\n.lb-row{display:flex;align-items:center;gap:var(--lb-gap);flex-wrap:wrap}\n/* row-gap matters once the row wraps: without it the wrapped line sits flush\n against the one above. */\n.lb-row{row-gap:0.5rem}\n/* 8px, matching .lb-section \\u2014 a stack inside a section must not space its\n controls wider than the section spaces its own children. */\n.lb-stack{display:grid;gap:0.5rem}\n/* A select is border-box by UA default; a text input is content-box. Given the\n same width, padding and border, the input therefore renders ~2rem wider \\u2014 the\n padding lands outside its box and inside the select\'s. Neither is wrong on its\n own; they just cannot be sized together until they agree. Every control the\n kit styles is border-box, so a declared width is the width that shows. */\n.lb-btn,.lb-select,.lb-input{box-sizing:border-box}\n.lb-select,.lb-input{font:inherit;font-family:var(--lb-font);font-size:0.8125rem;color:var(--lb-fg);\nbackground-color:var(--lb-field);border:1px solid var(--lb-control-border);\nborder-radius:var(--lb-radius-sm);corner-shape:squircle;padding:0.4rem 0.55rem;min-height:2.125rem}\n/* Geometry, states and motion ported from the product\'s Button component\n (packages/ui/components/Button) so an artifact button and an app button are\n the same object. MUI spacing is 8px/unit, so its size=medium (py .875,\n px .875, borderRadius .875) is 7px/7px/7px and large (py 1.75, px 2,\n borderRadius 1.25) is 14px/16px/10px. A control sitting in a row beside a\n select reads as medium, so that is the default here; .lb-btn-lg opts into\n large. The app\'s default variant is secondary \\u2014 white ground, gray-5\n border, black text \\u2014 which is what an unadorned .lb-btn already was. */\n.lb-btn{font:inherit;font-family:var(--lb-font);font-size:0.8125rem;font-weight:600;\ncolor:var(--color-black);background-color:var(--color-white);\nborder:1px solid var(--color-gray-5);\nborder-radius:0.4375rem;corner-shape:squircle;padding:0.4375rem 0.4375rem;min-height:2.125rem;\ncursor:pointer;user-select:none;-webkit-user-select:none;text-wrap:nowrap;\nfont-variant-numeric:tabular-nums;\ntransition:background-color .16s ease,border .16s ease,color .16s ease,\nopacity .16s ease,transform .16s cubic-bezier(0.23,1,0.32,1)}\n/* The app hovers the GROUND to gray-2, not the border. */\n@media (hover:hover){\n.lb-btn:hover:not([disabled]):not([data-lb-state=loading]){\nbackground-color:var(--color-gray-2)}}\n/* Front-loaded press, so the tap leads the colour crossfade. */\n.lb-btn:active:not([disabled]):not([data-lb-state=loading]){transform:scale(0.97)}\n.lb-btn[disabled]{opacity:.4;cursor:default}\n/* A text button with a label + icon: the app lays the content out as a flex row\n with a 6px gap (its content style uses gap 0.75). */\n.lb-btn{display:inline-flex;align-items:center;justify-content:center;gap:0.375rem}\n.lb-btn>svg{display:block;flex-shrink:0;width:1.05rem;height:1.05rem}\n/* Opt-in large, for a button that stands alone rather than in a control row. */\n.lb-btn-lg{border-radius:0.625rem;padding:0.875rem 1rem}\n/* A SUBMIT is not a toggle. Like/Dislike are independent switches; "Set status"\n commits the value sitting in the select beside it. Rendered identically, the\n row reads as five peers and the rep cannot see which control ends the task.\n The accent border + tinted ground marks it as the commit without making it a\n loud filled button \\u2014 the state styles above still win when they apply. */\n/* INVERTED, not recoloured. A normal button is --lb-fg on --lb-field; the\n submit swaps them. That introduces no new hue, and it themes for free: light\n gives near-black on white, dark gives white on near-black \\u2014 the polarity flip\n reads as "this one commits" in both, without competing with the semantic\n state colours (success green / error red) that still override it. */\n/* This is the app\'s primary variant verbatim: black ground, white text, and a\n hover that mixes 32% gray-8 into the black rather than lightening opacity. */\n.lb-btn-submit{background-color:var(--lb-fg);color:var(--lb-field);\nborder-color:var(--lb-fg)}\n@media (hover:hover){\n.lb-btn-submit:hover:not([disabled]):not([data-lb-state=loading]){\nbackground-color:color-mix(in oklch,var(--lb-fg),var(--color-gray-8) 32%);\nborder-color:color-mix(in oklch,var(--lb-fg),var(--color-gray-8) 32%)}}\n/* The app\'s ai variant \\u2014 what QualifyButton/Requalify wears. Purple ground,\n purple-foreground text, border at 65% transparency of the foreground; hover\n mixes 7% black into the ground. Tokens mirror the product\'s style package. */\n.lb-btn-ai{background-color:var(--color-purple-background);\nborder-color:var(--color-purple-border);color:var(--color-purple-foreground)}\n@media (hover:hover){\n.lb-btn-ai:hover:not([disabled]):not([data-lb-state=loading]){\nbackground-color:color-mix(in oklch,var(--color-purple-background),black 7%)}}\n/* Toggles that act on the same axis belong in one visual unit, so the row reads\n as "taste | status | inspect" rather than five equal buttons. The gap inside\n a group is half the gap between groups \\u2014 the 2x rule, applied horizontally. */\n.lb-group{display:inline-flex;align-items:center;gap:0.375rem;flex-wrap:wrap}\n/* Square icon button. Only for actions whose glyph is unambiguous on its own \\u2014\n thumbs up/down qualify, "Set status" does not. It MUST carry an aria-label\n and a title: with no text the icon is the entire affordance, so an unlabelled\n one is unreadable to a screen reader and to anyone who does not know the\n glyph. Same 2.125rem box as a text button, so it keeps the row\'s baseline. */\n.lb-btn-icon{padding:0;width:2.125rem;justify-content:center;display:inline-flex;\nalign-items:center}\n.lb-btn-icon svg{width:1.05rem;height:1.05rem;flex-shrink:0}\n/* Pressed state. The taste chip carries the durable fact; this is the control\n reflecting it, and it must not rely on colour alone \\u2014 aria-pressed is the\n accessible half and the fill is the visible half. */\n/* A heart that stays hollow does not read as "liked" \\u2014 fill it from the same\n currentColor the stroke uses, so one rule covers both themes. */\n.lb-btn-icon[aria-pressed=true] svg{fill:currentColor}\n.lb-btn-icon[aria-pressed=true]{border-color:var(--color-red-like);\ncolor:var(--color-red-like);\nbackground-color:color-mix(in srgb,var(--color-red-like) 10%,var(--lb-field))}\n.lb-btn-icon[data-taste=disliked][aria-pressed=true]{border-color:var(--lb-muted);\ncolor:var(--lb-muted);\nbackground-color:color-mix(in srgb,var(--lb-muted) 10%,var(--lb-field))}\n/* A glyph-only control has no text to widen its hit area, so extend the target\n past the 34px box without inflating the row. */\n.lb-btn-icon{position:relative}\n.lb-btn-icon::before{content:"";position:absolute;inset:-5px}\n.lb-btn:focus-visible,.lb-select:focus-visible,.lb-input:focus-visible,\n.lb-link:focus-visible{\noutline:2px solid var(--color-blue-foreground);outline-offset:1px;\nborder-radius:var(--lb-radius-sm)}\n/* Forced colours replaces every background and custom outline with a system\n colour, collapsing the state styling above into one flat control. */\n@media(forced-colors:active){\n.lb-btn,.lb-select,.lb-input,.lb-chip{border:1px solid CanvasText}\n.lb-btn:focus-visible,.lb-select:focus-visible,.lb-input:focus-visible,\n.lb-link:focus-visible,.lb-link-out:focus-visible{outline:2px solid Highlight}\n.lb-link-out{opacity:1}}\n/* Each state carries a glyph as well as a hue: success and error were two\n pills with identical labels, indistinguishable to a red-green colour-blind\n reader and identical under forced colours. */\n.lb-btn[data-lb-state=loading]{opacity:.55;cursor:progress}\n.lb-btn[data-lb-state=loading]::after{content:"\\u2026";margin-inline-start:.35rem}\n/* An icon button has no room for a trailing glyph \\u2014 it would push the icon\n off-centre. Its states read through border and fill, which the forced-colors\n block below keeps distinguishable. */\n.lb-btn-icon::after{content:none!important}\n.lb-btn[data-lb-state=success]::after{content:"\\u2713";margin-inline-start:.35rem}\n.lb-btn[data-lb-state=error]::after{content:"\\u2715";margin-inline-start:.35rem}\n.lb-btn[data-lb-state=unavailable]::after{content:"\\u26A0";margin-inline-start:.35rem}\n/* With save-on-change there is no button to reflect the write, so the select\n itself must. Only error was styled before; loading and success were invisible\n on a select, leaving the rep unsure whether the pick had landed. */\n.lb-select[data-lb-state=loading]{opacity:.55;cursor:progress}\n.lb-select[data-lb-state=success]{border-color:var(--color-green-foreground)}\n.lb-msg[data-tone=error]::before{content:"\\u2715 "}\n.lb-msg[data-tone=ok]::before{content:"\\u2713 "}\n.lb-btn[data-lb-state=success]{background-color:var(--color-green-background);\nborder-color:var(--color-green-foreground);color:var(--color-green-foreground)}\n.lb-btn[data-lb-state=error],.lb-select[data-lb-state=error]{\nbackground-color:var(--color-red-background);border-color:var(--color-red-foreground);\ncolor:var(--color-red-foreground)}\n.lb-btn[data-lb-state=unavailable],.lb-btn[disabled]{opacity:.5;cursor:not-allowed}\n.lb-msg{font-size:0.8125rem;line-height:1.45;color:var(--lb-muted)}\n/* Screen-reader-only text: control labels, unit suffixes, "opens in a new tab". */\n.lb-vh{position:absolute;width:1px;height:1px;margin:-1px;padding:0;\noverflow:hidden;clip-path:inset(50%);white-space:nowrap;border:0}\n.lb-msg[data-tone=error]{color:var(--color-red-foreground)}\n.lb-msg[data-tone=ok]{color:var(--color-green-foreground)}\n.lb-chip{display:inline-flex;align-items:center;gap:.25rem;white-space:nowrap;\nfont-size:0.75rem;font-weight:600;line-height:1rem;padding:0.125rem 0.5rem;\nborder-radius:var(--lb-radius-sm);corner-shape:squircle;\nbackground-color:var(--lb-chip-bg);color:var(--lb-muted)}\n.lb-chip[data-status=WANTED]{background-color:var(--color-blue-background);color:var(--color-blue-foreground)}\n.lb-chip[data-status=WON]{background-color:var(--color-green-background);color:var(--color-green-foreground)}\n.lb-chip[data-status=LOST]{background-color:var(--color-red-background);color:var(--color-red-foreground)}\n.lb-chip[data-status=UNWANTED]{background-color:var(--lb-chip-bg);color:var(--lb-muted)}\n.lb-chip[data-taste=liked]{background-color:var(--color-cherry-background);color:var(--color-red-like)}\n.lb-chip[data-taste=disliked]{background-color:var(--lb-chip-bg);color:var(--lb-muted)}\n.lb-chips{display:flex;align-items:center;gap:0.5rem;flex-wrap:wrap}\n/* \\u2500\\u2500 Tag rows \\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\n Two kinds, deliberately different so they never read as the same axis.\n FIRMOGRAPHIC (.lb-tags-plain): what the company IS \\u2014 location, size, sector.\n Quiet grey, because it is context the rep skims.\n INTENT (.lb-tags-intent): what the qualifier FOUND \\u2014 a buying signal. Teal,\n matching the product\'s own taste-profile tags, because it is the reason the\n lead is on screen. Both wrap and both sit on the section\'s 8px. */\n.lb-tags-plain,.lb-tags-intent{display:flex;flex-wrap:wrap;gap:0.5rem}\n/* Both tags round at 0.375rem: they are the innermost surface, and a tag whose\n radius drifts from its neighbours reads as a different kind of object. Their\n own padding is small enough that concentric math against the card does not\n apply \\u2014 past a point the layers are separate surfaces, each chosen on its\n own. */\n.lb-tags-plain>*{display:inline-flex;align-items:center;\nbackground-color:var(--lb-surface);border:1px solid var(--lb-border);\ncolor:var(--lb-muted);border-radius:0.375rem;padding:0.125rem 0.375rem;\nfont-size:0.75rem;font-weight:500;line-height:1.5;white-space:nowrap}\n.lb-tags-intent>*{display:inline-flex;align-items:center;\nbackground-color:var(--color-teal-background);border:1px solid var(--color-teal-border);\ncolor:var(--color-teal-foreground);border-radius:0.375rem;padding:0.1875rem 0.4375rem;\nfont-size:0.75rem;line-height:1.4}\n/* The honest empty state: a blank tag row reads as a rendering bug, so say\n what is missing and what fixes it. */\n.lb-tags-empty{font-size:0.78125rem;line-height:1.45;color:var(--lb-muted);\nfont-style:italic}\n/* The chips row is empty until a taste or a status lands. Left in flow it is a\n zero-height element that still consumes the gap above the first section, so\n hide it while it holds nothing \\u2014 set the hidden attribute from the code that\n paints the chips, since it is that code which knows. */\n.lb-chips[hidden]{display:none}\n.lb-chip[hidden]{display:none}\n.lb-table{width:100%;border-collapse:collapse;font-family:var(--lb-font);color:var(--lb-fg)}\n.lb-table th,.lb-table td{text-align:start;padding:0.5rem 0.4rem;\nborder-bottom:1px solid var(--lb-border);vertical-align:middle;font-size:0.8125rem}\n.lb-table th{font-size:0.75rem;font-weight:600;color:var(--lb-muted);\ntext-transform:uppercase;letter-spacing:.04em}\n/* Counts line up only when the digits do; a leaderboard whose numbers jitter\n column-to-column cannot be scanned down. */\n.lb-table td[data-num],.lb-table th[data-num]{text-align:end;\nfont-variant-numeric:tabular-nums}\n/* A sortable header is a control: it must look clickable and say which way it\n is sorting, in text as well as by arrow \\u2014 aria-sort is the accessible half. */\n.lb-table th[aria-sort]{cursor:pointer;user-select:none;color:var(--lb-fg)}\n.lb-table th[aria-sort]:hover{text-decoration:underline}\n.lb-table th[aria-sort=ascending]::after{content:" \\\\2191"}\n.lb-table th[aria-sort=descending]::after{content:" \\\\2193"}\n.lb-table th[aria-sort=none]::after{content:" \\\\2195";opacity:.35}\n/* The row a manager is acting on. */\n.lb-table tbody tr[aria-selected=true]{background-color:var(--lb-chip-bg)}\n.lb-link{color:var(--color-blue-foreground);text-decoration:none}\n.lb-link:hover{text-decoration:underline}\n/* Quiet text link out of the artifact. Button-height so it shares the row\'s\n baseline; understated so it never competes with the actions beside it. The\n arrow is a bare diagonal stroke \\u2014 an escape-hatch marker, not an icon that\n asks to be read. */\n.lb-link-out{display:inline-flex;align-items:center;gap:.3rem;\nfont-size:0.75rem;line-height:1rem;min-height:2.125rem;\ncolor:var(--lb-fg);text-decoration:none;opacity:.65;transition:opacity .15s}\n.lb-link-out:hover{opacity:1;text-decoration:underline}\n.lb-link-out:focus-visible{outline:2px solid var(--color-blue-foreground);outline-offset:1px;\nborder-radius:var(--lb-radius-sm)}\n.lb-link-out svg{width:.85em;height:.85em;flex-shrink:0}\n/* Pushes whatever follows it to the right edge of an .lb-row, so a trailing\n link sits on the SAME baseline as the row\'s buttons instead of on its own\n line below them. */\n/* A spacer only aligns items on its OWN flex line. .lb-row wraps, so once the\n link falls to a second line the spacer stays behind and the link renders\n left-aligned \\u2014 the opposite of what the contract promises. The auto margin\n re-anchors it to the trailing edge of whichever line it lands on, and is a\n no-op while the row is unwrapped. */\n.lb-spacer{flex:1 1 auto;min-width:0}\n.lb-row>.lb-link-out{margin-inline-start:auto}\n.lb-spinner{display:inline-block;width:.7em;height:.7em;border:2px solid var(--lb-border);\nborder-top-color:var(--color-blue-foreground);border-radius:50%;animation:lb-spin .8s linear infinite}\n@keyframes lb-spin{to{transform:rotate(1turn)}}\n/* animation:none left a static ring \\u2014 under reduced motion "sending" and\n "cannot click" looked identical. An opacity pulse is the sanctioned\n substitute. The 150ms colour fade is fine and stays. */\n@media(prefers-reduced-motion:reduce){.lb-spinner{animation:lb-pulse 1.4s ease-in-out infinite}}\n@keyframes lb-pulse{50%{opacity:.35}}\n`,D="lb-styles";var B="0.6.0",C=class extends Error{constructor(r,o={}){super(r);i(this,"code");i(this,"raw");this.name="LbError",this.code=o.code,this.raw=o.raw}},G=null,O=3e4;function V(){let e=globalThis.cowork;return e&&typeof e.callMcpTool=="function"?(t,r)=>e.callMcpTool(t,r):null}var W="leadbay_report_artifact_error",Q=40,q=new Set,F=0,H=!0;function J(e){H=e}function De(){q.clear(),F=0,H=!0,S=null,N="call"}function T(e){if(!H||F>=Q)return;let t=`${e.kind}|${e.surface}|${e.tool??""}|${e.code??""}`;if(q.has(t))return;q.add(t),F++;let r=V();if(r)try{let o=r(W,{kind:e.kind,surface:e.surface,kit_version:B,...e.tool?{tool:e.tool}:{},...e.code?{code:e.code}:{}});o&&typeof o.catch=="function"&&o.catch(()=>{})}catch{}}var N="call",S=null;function P(e,t,r){let o=N,n=S;N=e,S=r??null;try{return t()}finally{N=o,S=n}}function X(e,t,r){let o=K(r);T({kind:o==="timeout"?"call_timeout":o==="unavailable"?"bridge_unavailable":"call_failed",surface:e,tool:t,code:o})}function $(e){if(e&&typeof e=="object"&&"content"in e){let t=e.content;if(Array.isArray(t)&&t[0]&&typeof t[0].text=="string")return t[0].text}return null}function ee(e,t,r="call"){if(!e||typeof e!="object")return e;let o=e;if(o.isError)throw new C($(e)??"tool call failed",{raw:e});if("structuredContent"in o&&o.structuredContent!=null)return o.structuredContent;let n=$(e);if(n!=null)try{return JSON.parse(n)}catch{return T({kind:"parse_failed",surface:r,tool:t}),n}return e}function te(e){if(e instanceof Error)return e.message;if(e&&typeof e=="object"){let t=e.message;if(typeof t=="string"&&t)return t;try{return JSON.stringify(e)}catch{}}return String(e)}function K(e){if(e instanceof C)return e.code;if(e&&typeof e=="object"){let t=e.code;if(typeof t=="string"&&t)return t}}function M(e){let t=K(e);return{message:te(e),unavailable:t==="unavailable",code:t}}function re(e={}){G=e.call??null,O=e.timeoutMs??3e4}function oe(){if(typeof document>"u"||!document.head)return null;let e=document.getElementById(D);if(e)return e;let t=document.createElement("style");return t.id=D,t.textContent=U,document.head.appendChild(t),t}async function ne(e,t){if(!O||O<=0)return e;let r,o=new Promise((n,a)=>{r=setTimeout(()=>a(new C(`"${t}" timed out after ${O}ms`,{code:"timeout"})),O)});try{return await Promise.race([e,o])}finally{r&&clearTimeout(r)}}async function g(e,t={}){let r=e===W,o=N;!r&&S&&S.tool===void 0&&(S.tool=e);let n=G??V();if(!n)throw r||T({kind:"bridge_unavailable",surface:o,tool:e,code:"unavailable"}),new C("Leadbay bridge unavailable (window.cowork absent)",{code:"unavailable"});try{return ee(await ne(Promise.resolve(n(e,t)),e),r?void 0:e,o)}catch(a){throw r||X(o,e,a),a}}var L=class{constructor(){i(this,"subs",new Set)}subscribe(t){return this.subs.add(t),t(this),()=>this.subs.delete(t)}emit(){for(let t of this.subs)t(this)}};function ae(e){return Array.isArray(e)?e.map(t=>t&&typeof t=="object"?t:{value:t,label:String(t)}):[]}var I=class extends L{constructor(r={}){super();i(this,"kind");i(this,"value");i(this,"options",[]);i(this,"loading",!1);i(this,"error",null);i(this,"ready",!1);i(this,"cfg");i(this,"depUnsubs",[]);i(this,"seq",0);this.cfg=r,this.kind=r.kind,this.value=r.value??"";for(let o of r.dependsOn??[]){let n=o.value;this.depUnsubs.push(o.subscribe(()=>{o.value!==n&&(n=o.value,this.cfg.load&&this.load())}))}r.load&&(r.autoLoad??!0)&&this.load()}async load(){if(!this.cfg.load)return;let r=++this.seq;this.loading=!0,this.error=null,this.emit();try{let o={},n=await P("field",()=>this.cfg.load(),o);if(r!==this.seq)return;this.options=this.cfg.options?this.cfg.options(n):ae(n),this.ready=!0,this.options.length===0&&T({kind:"options_empty",surface:"field",tool:o.tool});let a=this.value==null?"":String(this.value);this.options.length&&(a===""||!this.options.some(l=>String(l.value)===a))&&(this.value=this.options[0].value)}catch(o){if(r!==this.seq)return;this.options=[],this.error=M(o)}finally{r===this.seq&&(this.loading=!1,this.emit())}}setValue(r){this.value=r;let o=this.validate();this.error=o?{message:o,unavailable:!1}:null,this.emit()}validate(){return this.cfg.validate?this.cfg.validate(this.value):null}get valid(){return this.validate()==null}reset(){this.value=this.cfg.value??"",this.error=null,this.emit()}dispose(){for(let r of this.depUnsubs)r();this.depUnsubs=[]}};function ie(e){if(!e||typeof e!="object")return null;let t=e;if(t.error!==!0)return null;let r=typeof t.message=="string"&&t.message?t.message:"tool call failed",o=typeof t.hint=="string"&&t.hint?` \\u2014 ${t.hint}`:"";return`${r}${o}`}var k=class extends L{constructor(r){super();i(this,"loading",!1);i(this,"error",null);i(this,"lastResult",null);i(this,"cfg");this.cfg=r}async run(){if(this.loading)return;for(let n of this.cfg.fields??[]){let a=n.validate();if(a!=null){this.error={message:a,unavailable:!1},this.emit(),T({kind:"action_blocked",surface:"action",tool:this.cfg.tool});return}}if(this.cfg.confirm&&typeof globalThis.confirm=="function"&&!globalThis.confirm(this.cfg.confirm))return;this.loading=!0,this.error=null,this.emit();let r;try{let n=typeof this.cfg.args=="function"?this.cfg.args():this.cfg.args??{};r=await P("action",()=>g(this.cfg.tool,n))}catch(n){this.error=M(n),this.loading=!1,this.emit(),this.cfg.onError?.(this.error);return}let o=ie(r)??this.cfg.checkResult?.(r)??null;if(o!=null){this.error={message:o,unavailable:!1},this.loading=!1,this.emit();let n=r&&typeof r=="object"?r.code:void 0;T({kind:"result_rejected",surface:"action",tool:this.cfg.tool,...typeof n=="string"&&n?{code:n}:{}}),this.cfg.onError?.(this.error);return}return this.lastResult=r,this.loading=!1,this.emit(),this.cfg.onSuccess?.(r),r}reset(){this.error=null,this.lastResult=null,this.emit()}},E=class extends L{constructor(r){super();i(this,"data",null);i(this,"loading",!1);i(this,"refreshing",!1);i(this,"error",null);i(this,"done",!1);i(this,"cfg");i(this,"timer",null);i(this,"seq",0);this.cfg=r,(r.autoLoad??!0)&&this.load()}async load(){this.clearTimer();let r=++this.seq;this.data==null?this.loading=!0:this.refreshing=!0,this.error=null,this.emit();try{let n=await P("resource",()=>this.cfg.load());if(r!==this.seq)return;this.data=n,this.done=this.cfg.until?this.cfg.until(n):!0,this.cfg.pollEvery&&!this.done&&(this.timer=setTimeout(()=>void this.load(),this.cfg.pollEvery))}catch(n){if(r!==this.seq)return;this.error=M(n)}finally{r===this.seq&&(this.loading=!1,this.refreshing=!1,this.emit())}}refresh(){return this.load()}stop(){this.clearTimer()}clearTimer(){this.timer&&(clearTimeout(this.timer),this.timer=null)}},R=class extends L{constructor(r){super();i(this,"items",[]);i(this,"page",0);i(this,"pageSize");i(this,"total",0);i(this,"loading",!1);i(this,"error",null);i(this,"cfg");i(this,"seq",0);this.cfg=r,this.pageSize=r.pageSize??20,(r.autoLoad??!0)&&this.loadPage(0)}async loadPage(r){let o=++this.seq;this.loading=!0,this.error=null,this.emit();try{let n=await P("list",()=>this.cfg.load({page:r,pageSize:this.pageSize}));if(o!==this.seq)return;this.items=n.items??[],this.total=n.total??this.items.length,this.page=r}catch(n){if(o!==this.seq)return;this.error=M(n)}finally{o===this.seq&&(this.loading=!1,this.emit())}}next(){return this.loadPage(this.page+1)}prev(){return this.loadPage(Math.max(0,this.page-1))}get hasMore(){return(this.page+1)*this.pageSize<this.total}};function le(e,t){let r=t.error?.unavailable?"unavailable":t.loading?"loading":t.error?"error":"ready";e.setAttribute("data-lb-state",r),t.error?e.setAttribute("data-lb-error",t.error.message):e.removeAttribute("data-lb-error")}function se(e,t){let r=()=>t.setValue(e.value);e.addEventListener("change",r);let o=t.subscribe(()=>{le(e,t),e.disabled=t.loading,e.innerHTML="";for(let n of t.options){let a=document.createElement("option");a.value=String(n.value),a.textContent=n.label,e.appendChild(a)}e.value=t.value==null?"":String(t.value)});return()=>{e.removeEventListener("change",r),o()}}function ce(e,t){let r=e.type==="checkbox",o=e.tagName==="SELECT"?"change":"input",n=()=>t.setValue(r?e.checked:e.value);e.addEventListener(o,n);let a=t.subscribe(()=>{if(r)e.checked=!!t.value;else{let l=t.value==null?"":String(t.value);e.value!==l&&(e.value=l)}e.setAttribute("data-lb-state",t.error?"error":"ready"),t.error?e.setAttribute("data-lb-error",t.error.message):e.removeAttribute("data-lb-error")});return()=>{e.removeEventListener(o,n),a()}}function de(e,t){let r=a=>{a.preventDefault(),t.run()};e.addEventListener("click",r);let o,n=t.subscribe(()=>{let a=t.error?.unavailable?"unavailable":t.loading?"loading":t.error?"error":t.lastResult!=null?"success":"ready";e.setAttribute("data-lb-state",a),"disabled"in e&&(e.disabled=t.loading),e.setAttribute("aria-busy",String(t.loading)),t.error?e.setAttribute("data-lb-error",t.error.message):e.removeAttribute("data-lb-error"),o&&clearTimeout(o),a==="success"&&(o=setTimeout(()=>{!t.loading&&!t.error&&e.setAttribute("data-lb-state","ready")},1600))});return()=>{e.removeEventListener("click",r),n(),o&&clearTimeout(o)}}var ue=["STILL_CHASING","COULD_NOT_REACH_STILL_TRYING","INTEREST_VALIDATED_OR_MEETING_PLANED","NOT_INTERESTED_LOST"],j=[{value:"",label:"Default ranking"},{value:"SCORE:DESC",label:"Score \\u2193"},{value:"SCORE:ASC",label:"Score \\u2191"},{value:"NAME:ASC",label:"Name A\\u2192Z"},{value:"NAME:DESC",label:"Name Z\\u2192A"},{value:"SIZE:DESC",label:"Size \\u2193"},{value:"SIZE:ASC",label:"Size \\u2191"},{value:"SECTOR:ASC",label:"Sector A\\u2192Z"},{value:"STATUS:ASC",label:"Status A\\u2192Z"},{value:"CONTACT_COUNT:DESC",label:"Contacts \\u2193"},{value:"LAST_PROSPECTING_ACTION_AT:DESC",label:"Last action \\u2193"},{value:"LAST_PROSPECTING_ACTION_AT:ASC",label:"Last action \\u2191"},{value:"EPILOGUE_STATUS_SET_AT:DESC",label:"Outcome set \\u2193"},{value:"LIKED:DESC",label:"Liked first"},{value:"DISLIKED:DESC",label:"Disliked first"}];function he(e){let t=String(e??"").trim().toUpperCase(),r=j.some(o=>o.value===t);return new I({kind:"select",value:r?t:"",load:async()=>j.slice()})}var z=[{value:"WANTED",label:"Wanted"},{value:"WON",label:"Won"},{value:"LOST",label:"Lost"},{value:"UNWANTED",label:"Unwanted"}],be={value:"",label:"Pick a status"};function ge(e){let t=String(e??"").trim().toUpperCase(),r=z.some(o=>o.value===t);return new I({kind:"select",value:r?t:"",validate:o=>String(o??"")===""?"Pick a status":null,load:async()=>r?z.slice():[be,...z]})}function fe(e){let t=()=>{let r=typeof e.leadIds=="function"?e.leadIds():e.leadIds;return Array.isArray(r)?r:e.leadId?[e.leadId]:[]};return new k({tool:"leadbay_set_lead_status",fields:e.date?[e.status,e.date]:[e.status],confirm:e.confirm,args:()=>({lead_ids:t(),status:e.status.value,...e.date&&e.date.value?{status_date:e.date.value}:{},...e.ask?{_triggered_by:e.ask}:{}}),checkResult:r=>{let o=r?.failed;if(!Array.isArray(o)||o.length===0)return null;let n=t().length,a=o[0]?.message??"write rejected";return o.length===n?`Status not applied: ${a}`:`${o.length} of ${n} leads failed: ${a}`}})}function pe(e){return new I({kind:"select",load:()=>g("leadbay_list_campaigns",{_triggered_by:e}),options:t=>(t?.campaigns??[]).map(o=>{let n=o?.campaign??o;return n?.id?{value:n.id,label:n.name??n.ai_generated_name??String(n.id)}:null}).filter(o=>o!=null)})}function me(e){return new k({tool:"leadbay_report_outreach",fields:e.note?[e.note]:[],args:()=>({lead_id:e.leadId,...e.status?{epilogue_status:e.status.value}:{},note:e.note?e.note.value:"",verification:{source:"user_confirmed",ref:e.ref??"logged from artifact"},_triggered_by:e.ask})})}function ye(e){return new k({tool:"leadbay_add_note",fields:[e.note],args:()=>({leadId:e.leadId,note:e.note.value})})}function ve(e){return new k({tool:"leadbay_like_lead",args:{lead_id:e}})}function we(e){return new k({tool:"leadbay_dislike_lead",args:{lead_id:e}})}function ke(e,t){return new E({autoLoad:!1,load:()=>g("leadbay_account_history",{leadId:e,_triggered_by:t})})}function xe(e,t){return new E({autoLoad:!1,load:()=>g("leadbay_research_lead_by_id",{leadId:e,_triggered_by:t})})}function Se(e){let t=null;return new E({...e.autoLoad!==void 0?{autoLoad:e.autoLoad}:{},pollEvery:e.pollEvery??4e3,until:r=>!!r?.all_done,load:async()=>{if(!t){let r=await g("leadbay_enrich_titles",{...e.leadIds?{leadIds:e.leadIds}:{},titles:e.titles,...e.email!==void 0?{email:e.email}:{},...e.phone!==void 0?{phone:e.phone}:{},...e.confirm!==void 0?{confirm:e.confirm}:{},_triggered_by:e.ask}),o=Array.isArray(r?.lead_ids)?r.lead_ids:[],n=r?.notification_id??null;if(t=n||o.length>0?{notification_id:n,lead_ids:o}:null,!t)return{...r,all_done:!0,no_job:!0}}return g("leadbay_bulk_enrich_status",{...t.notification_id?{notification_id:t.notification_id}:{},...t.lead_ids.length>0?{lead_ids:t.lead_ids}:{},...e.titles?{titles:e.titles}:{},...e.email!==void 0?{email:e.email}:{},...e.phone!==void 0?{phone:e.phone}:{},_triggered_by:e.ask})}})}function Te(e){let t=()=>typeof e.order=="string"?e.order:String(e.order?.value??"");return new R({pageSize:e.pageSize??20,load:async({page:r,pageSize:o})=>{let n=await g("leadbay_pull_leads",{page:r,count:o,...e.lensId?{lensId:e.lensId}:{},...t()?{order:t()}:{},_triggered_by:e.ask}),a=n.leads??[];return{items:a,total:n.pagination?.total??a.length}}})}function Ee(e){let t=e.source??"followups",r=()=>typeof e.order=="string"?e.order:String(e.order?.value??"");return new R({pageSize:e.pageSize??20,load:async({page:o,pageSize:n})=>{let l=t==="campaign"?await g("leadbay_campaign_call_sheet",{campaign_id:e.campaignId,page:o,count:n,_triggered_by:e.ask}):await g("leadbay_pull_followups",{page:o,count:n,...e.city?{city:e.city}:{},...r()?{order:r()}:{},_triggered_by:e.ask}),p=l.leads??l.items??[];return{items:p,total:l.total_leads??l.pagination?.total??p.length}}})}async function _e(e){let t=await g("leadbay_pull_followups",{count:e.sample??200,filtered:!1,...e.personal===void 0?{}:{personal:e.personal},_triggered_by:e.ask}),r=new Map;for(let n of t.leads??[]){let a=n?.sector_id;if(a==null||a==="")continue;let l=String(a);r.set(l,(r.get(l)??0)+1)}let o=e.sectors??{};return[...r.entries()].map(([n,a])=>{let l=o[n];return{id:n,label:l??`Sector ${n}`,sampled:a,resolved:l!=null}}).sort((n,a)=>a.sampled-n.sampled||n.id.localeCompare(a.id))}async function Ae(e){let t=[];e.sectorIds?.length&&t.push({type:"sector_ids",sectors:e.sectorIds,is_excluded:!1});let r=await g("leadbay_pull_followups",{count:1,set_filter:{criteria:t},...e.city?{city:e.city}:{},...e.cityId?{city_id:e.cityId}:{},...e.personal===void 0?{}:{personal:e.personal},_triggered_by:e.ask}),o=r.active_filters?.criteria??[],n=new Set;e.sectorIds?.length&&n.add("sector_ids"),(e.city||e.cityId)&&n.add("location_ids");let a=new Set(o.map(s=>typeof s?.type=="string"?s.type:null).filter(s=>s!=null)),l=(e.sectorIds??[]).slice().sort().join(","),p=o.filter(s=>s?.type==="sector_ids").flatMap(s=>Array.isArray(s.sectors)?s.sectors:[]).slice().sort().join(","),m=[...n].every(s=>a.has(s)),y=[...a].every(s=>n.has(s));return{total:w(r.pagination?.total),applied:o,trusted:m&&y&&l===p}}function Ce(e){return new E({load:()=>g("leadbay_team_activity",{weeks:e.weeks??4,_triggered_by:e.ask})})}var Le="http://www.w3.org/2000/svg",x=(e,t)=>{let r=document.createElementNS(Le,e);for(let[o,n]of Object.entries(t))r.setAttribute(o,String(n));return r};function w(e){return typeof e=="number"&&isFinite(e)?e:0}function Ie(e,t={}){let r=Array.isArray(e)?e:[];if(r.length===0){let d=document.createElement("div");d.className="lb-empty";let f=document.createElement("div");if(f.className="lb-empty-title",f.textContent=t.emptyTitle??"Nothing in this window",d.appendChild(f),t.emptyHint){let A=document.createElement("div");A.className="lb-empty-hint",A.textContent=t.emptyHint,d.appendChild(A)}return d}let o=t.width??640,n=t.height??160,a=40,l=8,p=12,m=26,y=o-a-l,s=n-p-m,_=Math.max(1,...r.map(d=>w(d.count))),h=d=>r.length===1?a+y/2:a+d/(r.length-1)*y,c=d=>p+s-w(d)/_*s,u=x("svg",{class:"lb-chart",viewBox:`0 0 ${o} ${n}`,preserveAspectRatio:"none",role:"img","aria-label":t.label??`Trend across ${r.length} points, peak ${_}`});for(let d of[0,_]){u.appendChild(x("line",{class:"lb-chart-grid",x1:a,x2:o-l,y1:c(d),y2:c(d)}));let f=x("text",{x:a-6,y:c(d)+4,"text-anchor":"end"});f.textContent=String(d),u.appendChild(f)}let b=r.map((d,f)=>`${h(f)},${c(d.count)}`);u.appendChild(x("path",{class:"lb-chart-area",d:`M${h(0)},${c(0)} L${b.join(" L")} L${h(r.length-1)},${c(0)} Z`})),u.appendChild(x("path",{class:"lb-chart-line",d:`M${b.join(" L")}`})),r.forEach((d,f)=>{u.appendChild(x("circle",{class:"lb-chart-dot",cx:h(f),cy:c(d.count),r:3}))});let v=r.length===1?[[0,"middle"]]:[[0,"start"],[r.length-1,"end"]];for(let[d,f]of v){let A=x("text",{x:h(d),y:n-8,"text-anchor":f});A.textContent=String(r[d].date??"").slice(0,10),u.appendChild(A)}return u}function Oe(e){let t=document.createElement("div");t.className="lb-tiles";for(let r of e){let o=document.createElement("div");o.className="lb-tile";let n=document.createElement("span");n.className="lb-tile-label",n.textContent=r.label;let a=document.createElement("span");a.className="lb-tile-value",a.textContent=String(r.value),o.append(n,a),t.appendChild(o)}return t}function Ne(e){let{rows:t,columns:r}=e;if(!t||t.length===0){let m=document.createElement("div");m.className="lb-empty";let y=document.createElement("div");if(y.className="lb-empty-title",y.textContent=e.emptyTitle??"Nothing to show",m.appendChild(y),e.emptyHint){let s=document.createElement("div");s.className="lb-empty-hint",s.textContent=e.emptyHint,m.appendChild(s)}return m}let o=e.sortKey??r[0].key,n=e.sortDir??"descending",a=document.createElement("table");a.className="lb-table";let l=()=>{a.textContent="";let m=document.createElement("thead"),y=document.createElement("tr");for(let h of r){let c=document.createElement("th");c.textContent=h.label,h.num&&c.setAttribute("data-num",""),c.setAttribute("aria-sort",h.key===o?n:"none"),c.setAttribute("tabindex","0"),c.setAttribute("role","button");let u=()=>{o===h.key?n=n==="ascending"?"descending":"ascending":(o=h.key,n=h.num?"descending":"ascending"),l(),e.onSort?.(o,n)};c.addEventListener("click",u),c.addEventListener("keydown",b=>{(b.key==="Enter"||b.key===" ")&&(b.preventDefault(),u())}),y.appendChild(c)}m.appendChild(y),a.appendChild(m);let s=t.slice().sort((h,c)=>{let u=h[o],b=c[o];if(typeof u=="string"||typeof b=="string"){let v=String(u??"").localeCompare(String(b??""));return n==="ascending"?v:-v}return n==="ascending"?w(u)-w(b):w(b)-w(u)}),_=document.createElement("tbody");for(let h of s){let c=document.createElement("tr");c.setAttribute("aria-selected","false");for(let u of r){let b=document.createElement("td");u.num&&b.setAttribute("data-num","");let v=u.cell?.(h);v==null?b.textContent=u.num?String(w(h[u.key])):String(h[u.key]??"\\u2014"):typeof v=="string"?b.textContent=v:b.appendChild(v),c.appendChild(b)}_.appendChild(c)}a.appendChild(_)};l();let p=document.createElement("div");return p.style.overflowX="auto",p.appendChild(a),p}var Re={VERSION:B,configure:re,styles:oe,call:g,report:T,setTelemetry:J,field:e=>new I(e),action:e=>new k(e),resource:e=>new E(e),list:e=>new R(e),bindSelect:se,bindValue:ce,bindAction:de,sparkline:Ie,tiles:Oe,leaderboard:Ne,campaigns:pe,segmentCount:Ae,portfolioSectors:_e,outreach:me,note:ye,like:ve,dislike:we,leadStatus:ge,setStatus:fe,sortOrder:he,leadHistory:ke,leadProfile:xe,enrichment:Se,callList:Ee,leadList:Te,teamActivity:Ce,EPILOGUE_STATUSES:ue,LEAD_STATUSES:z,SORT_ORDERS:j};typeof globalThis<"u"&&(globalThis.LeadbayArtifacts=Re);})();';
29064
- var ARTIFACT_USAGE_GUIDE = '# Leadbay Artifact Kit \u2014 headless domain components\n\nYou are building a single-file HTML **artifact** the user runs inside cowork. This\nkit gives you **headless view-models** that own a control\'s whole data lifecycle \u2014\nload/populate from a Leadbay call, hold value/state, poll, validate, and\nencapsulate the API call + business rules. **You own 100% of markup/layout/style.**\nThe library renders nothing. Inline the runtime once as a `<script>`; it exposes\none global `window.LeadbayArtifacts` (call it `lb`). Vanilla, no React, no build.\n\nPass every tool you use as the artifact\'s `mcp_tools` so the host permits it.\n\n## Two layers\n\n**Primitives** (generic):\n- `lb.field({ load, options, value, validate, dependsOn })` \u2014 a value + optionally\n API-populated options. `.value/.setValue/.options/.loading/.error/.valid/.subscribe`.\n- `lb.action({ tool, args, fields, confirm, onSuccess, onError })` \u2014 a write/submit.\n `.run()/.loading/.error/.lastResult/.subscribe`.\n- `lb.resource({ load, pollEvery?, until?, autoLoad? })` \u2014 one read that may change:\n load-on-click or poll-until-`until`. `.data/.loading/.refreshing/.error/.done/.load()/.refresh()/.stop()/.subscribe`.\n- `lb.list({ load, pageSize })` \u2014 paginated rows. `.items/.page/.total/.loading/.loadPage(n)/.next()/.prev()/.hasMore/.subscribe`.\n\n`.error` is `{ message, unavailable } | null`. `subscribe(cb)` fires immediately\nthen on every change \u2014 render your own DOM from it.\n\n**Domain components** (pre-wired \u2014 bake in the tool name, arg shape, and footguns):\n\n| Call | Returns | For |\n|---|---|---|\n| `lb.campaigns(ask)` | field | a campaign `<select>`, options from `leadbay_list_campaigns` |\n| `lb.segmentCount({sectorIds, city, ask})` | `Promise<{total, applied, trusted}>` | how many Monitor leads match one sector/location \u2014 `count: 1` + `pagination.total`, so a segment costs one cheap call |\n| `lb.portfolioSectors({sample, sectors, ask})` | `Promise<PortfolioSector[]>` | which sectors the user ACTUALLY holds \u2014 samples one page of followups, tallies `sector_id`, resolves names against an embedded taxonomy |\n| `lb.outreach({leadId, ask, status?, note?})` | action | log a call \u2192 `report_outreach` (verification + `_triggered_by` baked in) |\n| `lb.note({leadId, note})` | action | add a note \u2192 `add_note` |\n| `lb.like(leadId)` / `lb.dislike(leadId)` | action | taste signal |\n| `lb.leadStatus(current?)` | field | a status `<select>` (Wanted/Won/Lost/Unwanted) |\n| `lb.setStatus({leadId or leadIds, status, date?, ask})` | action | write the org CRM status \u2192 `set_lead_status` |\n| `lb.leadHistory(leadId, ask)` | resource (lazy) | notes + activities + engagement \u2192 `account_history` |\n| `lb.leadProfile(leadId, ask)` | resource (lazy) | full lead profile \u2192 `research_lead_by_id` |\n| `lb.sortOrder(current?)` | field | a sort `<select>` mirroring the app\'s TableSort |\n| `lb.leadList({lensId?, order?, ask})` | list | a sortable Discover batch \u2192 `pull_leads` |\n| `lb.callList({source:\'followups\'\\|\'campaign\', campaignId?, city?, ask})` | list | a cold-call list (Monitor or a campaign) |\n| `lb.enrichment({leadIds, titles, ask, pollEvery?})` | resource (polling) | launch + watch contact enrichment |\n| `lb.teamActivity({weeks, ask})` | resource | manager leaderboard + activity trend \u2192 `leadbay_team_activity` |\n\n`lb.EPILOGUE_STATUSES` = the 4 disposition values\n(`STILL_CHASING`, `COULD_NOT_REACH_STILL_TRYING`, `INTEREST_VALIDATED_OR_MEETING_PLANED`, `NOT_INTERESTED_LOST`).\n`lb.LEAD_STATUSES` = the 4 org CRM statuses as `{value,label}` (`WANTED`, `WON`, `LOST`, `UNWANTED`).\n`lb.SORT_ORDERS` = the sort options as `{value,label}`; values are the backend `FIELD:ASC|DESC` enum.\n\n**Sorting is a SERVER concern.** `lb.leadList` and `lb.callList` take an `order`\n(a `lb.sortOrder()` field or a literal) and send it upstream; the backend sorts\nthe whole lens / Monitor and returns the requested page of that. Never re-sort\nrows in the browser \u2014 you would be reordering one page of a larger set, showing\nleads that do not belong at that position. The empty value means "no order\nparam", i.e. the tab\'s own ranking, which is the right default. Changing the\nsort should reset to page 0. Campaign call sheets cannot sort:\n`leadbay_campaign_call_sheet` has no `order` param, and `lb.callList` drops it\nfor that source rather than sending something the tool would reject.\n\n**Two different systems.** Epilogue = how one outreach attempt went (drives\nfollow-up ranking). Lead status = the commercial outcome, org-wide \u2014 the same\nfield the website\'s status selector writes. A won deal is a LEAD STATUS;\n"she didn\'t pick up" is an EPILOGUE. Setting one never sets the other, so when\nthe user reports both in one breath, fire both actions.\n\n**Rendering helpers** \u2014 the only three things the library draws, and only\nbecause hand-rolling them goes wrong the same way every time: an SVG whose\npoints escape the viewBox, a series that draws empty axes when it is empty, a\nleaderboard whose digits do not line up. Each returns a DETACHED element you\nplace; none injects itself.\n\n| Call | Returns | For |\n|---|---|---|\n| `lb.sparkline(points, {label, emptyTitle, emptyHint})` | `<svg class="lb-chart">` or an `lb-empty` block | a trend from `[{date, count}]`, themed so it reads in light and dark |\n| `lb.tiles([{label, value}])` | `<div class="lb-tiles">` | headline figures \u2014 only the few that ARE the point |\n| `lb.leaderboard({rows, columns, sortKey, sortDir, cell})` | a scroll-wrapped `lb-table` | a sortable table; `aria-sort` + keyboard headers, `num` columns tabular and end-aligned |\n\n`sparkline` returns the empty block INSTEAD of a chart when the series is\nempty \u2014 an empty window is a real answer, and empty axes read as broken.\n`leaderboard` does the same for no rows. Sorting there is client-side by\ndesign: a roll-up arrives whole, unlike a lead list the backend pages.\n\n**Binding sugar** (optional; binds a view-model to YOUR native element, no style):\n`lb.bindSelect(selectEl, field)` (populates options + value), `lb.bindValue(inputEl, field)`,\n`lb.bindAction(buttonEl, action)`. They set `data-lb-state`\n(`ready|loading|error|success|unavailable`) + `data-lb-error` on your element as\nstyling hooks. For lists/resources, use `.subscribe()` and render yourself.\n\n`ask` is the user\'s request this artifact serves \u2014 it becomes `_triggered_by`.\n\n## The skin (optional) \u2014 `lb.styles()`\n\nCall it once and you get a small `lb-*` stylesheet, so every artifact you build\nshares one visual language instead of re-inventing padding and colours. It is\n**opt-in**: skip it and you get exactly the unstyled HTML you wrote. It injects\nno markup and never touches your `class` attributes.\n\n```js\nlb.styles(); // idempotent \u2014 safe to call per row\n```\n\n| Class | For |\n|---|---|\n| `lb-card` / `lb-card-head` / `lb-title` / `lb-sub` | a lead card + its header |\n| `lb-sections` / `lb-section` / `lb-sec-title` | the card\'s section stack (16px between, 8px within) and a section\'s uppercase title |\n| `lb-card-top` / `lb-lead-title` / `lb-card-foot` | header row (leading control \xB7 title \xB7 trailing controls), the bold underlined company name, and the footer\'s leading-action/spacer/trailing-link row |\n| `lb-tags-plain` / `lb-tags-intent` / `lb-tags-empty` | firmographic tags (grey: what the company IS) and intent tags (teal: what the qualifier FOUND), plus the honest empty state for either |\n| `lb-toolbar` / `lb-field` / `lb-field-label` / `lb-field-inline` | the bar above a deck: three groups (tally \xB7 narrow \xB7 act), and a labelled control whose caption stacks above it \u2014 `lb-field-inline` keeps a checkbox beside its words |\n| `lb-tally` / `lb-status` / `lb-status-dot` | the row count (tabular, so it does not jitter as filters change) and a connection indicator whose live/dead states differ in SHAPE, not only colour |\n| `lb-pager` / `lb-pager-range` | the pager below a deck \u2014 prev/next plus an honest "21\u201340 of 60" range; pair it with `data-lb-state="loading"` on the deck so a page flip dims the old rows instead of blanking them |\n| `lb-tiles` / `lb-tile` / `lb-tile-label` / `lb-tile-value` | a row of figures for a dashboard\'s headline numbers \u2014 only for the few that ARE the point |\n| `lb-chart` (+ `lb-chart-line` / `-area` / `-dot` / `-grid`) | an inline-SVG trend that takes its colours from the theme, so it reads in both; no CDN |\n| `lb-empty` / `lb-empty-title` / `lb-empty-hint` | the honest empty state \u2014 what it means and the way out, in place of a blank chart or table |\n| `lb-table th[aria-sort]` / `td[data-num]` | a sortable header (arrow + `aria-sort`) and a tabular numeric cell, so counts line up down a column |\n| `lb-row` / `lb-stack` / `lb-spacer` | control row / vertical spacing / flex filler that right-aligns what follows |\n| `lb-link-out` | quiet external link (icon inherits currentColor) \u2014 "Open in Leadbay" |\n| `lb-select` / `lb-input` / `lb-btn` | form controls (state-aware, see below) |\n| `lb-btn-submit` / `lb-btn-ai` / `lb-btn-lg` | the app\'s `primary` / `ai` variants, and its `large` size |\n| `lb-msg` (`data-tone="error\\|ok"`) | inline feedback |\n| `lb-chip` (`data-status="WON\\|LOST"`) | a status pill |\n| `lb-table` | leads table |\n| `lb-spinner` | inline busy indicator \u2014 decorative, mark it `aria-hidden` |\n| `lb-vh` | visually-hidden text \u2014 labels heard but not seen |\n\nControls react to the `data-lb-state` the bind helpers already set \u2014 a bound\n`lb-btn` dims while loading, goes green on success, red on error, all with no\nextra CSS from you.\n\nThe palette is the **product design system**, ported from\n`frontend/packages/style/color.css` \u2014 same `--color-gray-1\u20269` ramp, same\nsemantic `--color-{green,red,blue,gold}-{background,foreground}` pairs, same\n`1.5rem` / `0.625rem` radii (concentric: outer = inner + padding) and\n`corner-shape: squircle`, matching the app\'s components.\nAn artifact therefore looks like Leadbay, not like a generic page.\n\nUse the tokens rather than hardcoded colours \u2014 the same rule the style package\nenforces. Re-theme by overriding them; don\'t fight specificity:\n\n```css\n:root { --lb-surface: var(--color-gray-2); --lb-radius: 0.5rem; }\n```\n\nDark mode works two ways: `data-theme="dark"` on `<html>` (the frontend\'s own\nhook) **and** `prefers-color-scheme`, because an artifact renders inside a host\nwhose theme attribute it cannot set. Never hardcode a light background over the\nskin.\n\nThe product face is `Nikkei Maru`; the stack names it first and falls back to\nthe system UI font. Do **not** add an `@font-face` \u2014 artifacts are inline-only\nand a remote font URL will silently fail.\n\n## What every lead card MUST carry\n\nA card is the artifact form of the `pull_leads` table, and it inherits that\ntable\'s rules. A card with a name and a button is not enough: the rep cannot\ntell *why* this lead is on screen. Five lines, in this order.\n\n```html\n<div class="lb-card">\n <div class="lb-card-head">\n <span class="lb-title"></span> <!-- 1. company -->\n <span class="lb-chips"> <!-- 2. state -->\n <span class="lb-chip" data-taste hidden></span>\n <span class="lb-chip" data-status hidden></span>\n </span>\n </div>\n <div class="lb-facts"> <!-- one group, tight 4px gap -->\n <div class="lb-sub"></div> <!-- 3. firmographics -->\n <div class="lb-sub" data-who></div> <!-- 4. the person -->\n <div class="lb-sub" data-how></div> <!-- 5. company channels -->\n </div>\n <div class="lb-sub" data-why></div> <!-- 6. why it fits -->\n <div class="lb-row"><!-- actions --></div>\n <div class="lb-msg" role="status" aria-live="polite"></div>\n</div>\n```\n\n1. **Company** \u2014 `name`, linked to `website` (prefix `https://` on a bare host).\n Never render the numeric `score`; use the `\u25B0\u2756\u25B1` bar if you want the signal.\n\n Keep `.lb-msg` OUT of `.lb-row`. The result of a write \u2014 "Could not reach the\n host" \u2014 is the most important thing on the card at that moment; parked between\n two buttons it reads as a control, and as a wide flex item it forces the\n trailing link onto a line of its own. Give it its own row after the actions.\n\n Also give every card an **Open in Leadbay** link to the lead\'s panel in the\n product. Put it at the **trailing end of the card\'s last action row** \u2014\n same row as the buttons, pushed right by an `lb-spacer`, not on a line of\n its own. Style it `lb-link-out`: quiet text plus a plain arrow-up-right,\n never a filled button. It is an escape hatch, not a call to action.\n\n Group the controls by what they act on, so the row does not read as a set of\n peers: wrap each axis in an `lb-group`. Status is NOT one of those axes \u2014 it\n saves on change and carries no button at all (see "Save on change is the\n default for status" below). Reserve `lb-btn-submit` for a write that really\n does need a second step, such as the bulk apply across checked rows.\n\n Taste is the one pair worth reducing to icons: thumbs up/down are unambiguous,\n they repeat on every card, and dropping the words buys the width a narrow chat\n host needs. Use `lb-btn-icon` \u2014 and note the three attributes it REQUIRES,\n because with no text the glyph is the whole affordance:\n\n - `aria-label` naming the lead ("Like Acme Corp"), since the control repeats\n N times down the list;\n - `title` so a sighted user who does not know the glyph gets a tooltip;\n - `aria-pressed` reflecting the current taste \u2014 a toggle must say whether it\n is on, and `[aria-pressed=true]` is what the skin styles.\n\n Do **not** reduce "Set status" to an icon: no glyph says "commit the value in\n the select beside me". Icons work for a fixed, well-known action; they fail\n for one whose meaning comes from a neighbouring control. The status select\n sidesteps the question entirely by carrying no button at all \u2014 it saves on\n change \u2014 but the rule stands for any write whose meaning comes from the\n control beside it.\n\n ```html\n <div class="lb-row">\n <span class="lb-group"> <!-- taste: two toggles -->\n <button class="lb-btn lb-btn-icon" aria-label="Like Acme Corp"\n title="Like" aria-pressed="false">\n <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"\n stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">\n <path d="M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4.05 3 5.5l7 7Z"/>\n </svg>\n </button>\n <button class="lb-btn lb-btn-icon" data-taste="disliked"\n aria-label="Dislike Acme Corp" title="Dislike" aria-pressed="false">\n <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"\n stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">\n <path d="M17 14V2"/>\n <path d="M9 18.12 10 14H4.17a2 2 0 0 1-1.92-2.56l2.33-8A2 2 0 0 1 6.5 2H20a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2h-2.76a2 2 0 0 0-1.79 1.11L12 22a3.13 3.13 0 0 1-3-3.88Z"/>\n </svg>\n </button>\n </span>\n <select class="lb-select" aria-label="Lead status for Acme Corp"></select>\n <span class="lb-spacer"></span> <!-- pushes the link to the end -->\n <a class="lb-link-out" data-k="open" target="_blank" rel="noopener">\n Open in Leadbay\n <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"\n stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">\n <line x1="7" y1="17" x2="17" y2="7"/><polyline points="7 7 17 7 17 17"/>\n </svg>\n </a>\n </div>\n ```\n\n Keep the arrow a bare diagonal stroke \u2014 the text already says where the link\n goes, so the glyph only has to mark "leaves this page". Mark the `<svg>`\n `aria-hidden="true"`: it is decorative, and the link text is the accessible\n name.\n **Pick the view the lead actually lives in** \u2014 the URL is\n `/app/<view>?lead=<uuid>`, and the three views are `discover`, `monitor`,\n `campaign`. Landing a Monitor lead on Discover drops the rep into a list\n that does not contain it:\n\n ```js\n function leadUrl(lead, campaignId) {\n const id = encodeURIComponent(lead.id);\n // A campaign card carries TWO params \u2014 the campaign selects the list, the\n // lead opens the panel inside it. Campaign wins even when in_monitor is\n // also true, because that is the list the rep is looking at.\n if (campaignId) {\n return `https://leadbay.app/app/campaign?campaign=${encodeURIComponent(campaignId)}&lead=${id}`;\n }\n const view = lead.in_monitor ? "monitor" : "discover";\n return `https://leadbay.app/app/${view}?lead=${id}`;\n }\n openEl.href = leadUrl(lead, campaignId);\n ```\n\n `in_monitor` / `in_discover` are booleans on the `pull_followups` payload \u2014\n every follow-up carries `in_monitor: true`, so a call sheet must link to\n `monitor`. `pull_leads` omits both flags entirely; its leads are the Discover\n batch by definition, so `discover` is the default. A campaign card\n (`lb.callList({source:"campaign", campaignId})`) needs `?campaign=<id>&lead=<id>`\n \u2014 the param names are `CAMPAIGN_QUERY_PARAM` and `LEAD_QUERY_PARAM`, and the\n app\'s own `useLeadPanel` preserves whatever params are already set, so the\n two coexist by design. Omitting `campaign=` opens an empty campaign view.\n\n Inline the glyph as SVG rather than an emoji or `\u2197` \u2014 it inherits\n `currentColor` and scales with the text, so it stays legible in both themes.\n `?lead=<uuid>` is the real deep-link (`LEAD_QUERY_PARAM` in the web app, read\n on load; the panel is an overlay, so the view choice only decides what sits\n behind it). This is the ONE place a card may use `lead.id`: as a link target,\n never as visible text.\n2. **State chips** \u2014 taste (`data-taste`) and CRM status (`data-status`) are\n INDEPENDENT axes; render both, hide the empty one. Never collapse to one chip.\n3. **Firmographics** \u2014 sector of activity first, then city, then size, then the\n contact. `sector_id` is a RAW ID (`"5136"`), not a label: resolve it via\n `leadbay_list_sectors` (1346 rows \u2014 fetch once, cache, never inline the lot)\n or omit it. Never print the raw id.\n\n **Always show whether the lead is reachable \u2014 and never merge the person\n with the company\'s switchboard.** These are two separate lines:\n\n ```js\n // WHO \u2014 recommended_contact. Name, and job_title ONLY when present; on list\n // payloads it is usually null, and inventing one is worse than omitting it.\n const rc = lead.recommended_contact;\n const who = rc ? [rc.first_name, rc.last_name].filter(Boolean).join(" ") : null;\n const whoLine = who ? who + (rc.job_title ? " \xB7 " + rc.job_title : "") : "No contact yet \u2014 enrich to find one";\n\n // HOW \u2014 company-level channels. `phone_numbers` and `email` belong to the\n // COMPANY, not to `recommended_contact`. Rendering "Jean \xB7 \u260E 0123\u2026" claims a\n // direct line that does not exist; it is the switchboard.\n // The API returns the literal STRING "null" for a missing value \u2014 in\n // phone_numbers as well as email (a real lead ships phone_numbers:["null"]).\n // Guard BOTH or the card prints "\u260E null" as if it were a number.\n const real = (v) => (v && v !== "null" ? v : null);\n const phone = real((lead.phone_numbers || [])[0]);\n const email = real(lead.email);\n const howLine = [phone && "\u260E " + phone, email && "\u2709 " + email].filter(Boolean)\n .join(" \xB7 ") || "No phone or email \u2014 enrich to look for them";\n ```\n\n ```html\n <div class="lb-sub">Sector \xB7 City \xB7 Size</div>\n <div class="lb-sub"><span aria-hidden="true">\u{1F464}</span> Jean-Fran\xE7ois Froemer \xB7 G\xE9rant</div>\n <div class="lb-sub"><span class="lb-vh">Company switchboard: </span><span aria-hidden="true">\u{1F3E2} \u260E</span> 01 23 45 67 89 (company line)</div>\n ```\n\n Label the channel line as the **company\'s**, so a rep reading fast cannot\n mistake it for a direct line. A per-contact email or phone exists only after\n enrichment \u2014 `research_lead_by_id` exposes it as `contacts.reachable[]`, and\n `_meta.has_reachable_contact` is the authoritative flag. The list payloads\n carry neither, so a card built from `pull_leads` / `pull_followups` can only\n ever show company channels. Say "enrich to reveal" rather than implying the\n contact is callable.\n\n Two things that look like reachability and are not: a `linkedin_page` alone\n (the rep cannot message a URL without leaving the artifact \u2014 same rule\n `research_lead_by_id` applies), and `contacts_count > 0` (it counts known\n people, not people you can contact; a lead can show 2518 contacts and zero\n channels). `pull_followups` carries `has_phone` as a ready-made boolean;\n `pull_leads` omits it, so derive from `phone_numbers` there.\n4. **Why it fits** \u2014 one sentence, \u226420 words. Walk this chain and stop at the\n first hit:\n\n 1. `short_description`\n 2. `description` (longer; only on `research_lead_by_id` /\n `research_lead_by_name_fuzzy` \u2014 the trim payloads omit it)\n 3. top 2 `tags[].display_name`\n 4. `qualification_summary.best_response_excerpt`, trimmed to one sentence\n 5. `keywords`, first 3, joined with ` \xB7 `\n 6. the resolved sector label \u2014 better than nothing, and if step 3 already\n printed the sector on the firmographics line, skip to step 7\n 7. the literal *"No description yet \u2014 qualify to add one"* ("qualify" is the\n product\'s own verb; "run qualification to generate one" is a nominalisation)\n\n Never leave this line blank: a silent gap reads as a rendering bug, whereas\n the fallback tells the rep the data is missing and what fixes it.\n\n **The two list payloads are complementary, so the chain must span both.**\n `pull_leads` returns `short_description` on every lead but no `sector_id`;\n `pull_followups` returns `sector_id` but no `short_description` at all. A\n card fed by one will fall through to a different step than the same card fed\n by the other \u2014 that is expected, not a bug. Never call\n `research_lead_by_id` per row just to fill this line: it is one request per\n lead. Fetch it lazily when the rep expands a card.\n\n**Never show** on a card: `id`, `sector_id`, `location.pos`, `location.country`\n(unless city and state are both missing), `is_hq`, `*_in_progress`,\n`highlighted_fields`, `custom_fields`, `stale_at`, `deal_insights`,\n`need_attention*`, any count that is 0, any value that is the string `"null"`.\n\n**Minimum actions.** A card that only displays is a table row that costs more \u2014\nif you are not wiring an action, render the markdown table instead. Wire at\nleast one write, and prefer the set the rep actually needs:\n\n| Card is for | Wire |\n|---|---|\n| triage a discovery batch | `lb.like` / `lb.dislike` + `lb.setStatus` |\n| working a call list | `lb.outreach` (gated on a note) + `lb.leadHistory` |\n| pipeline review | `lb.setStatus` + `lb.note` |\n\nAlways render the `.error` branch of every view-model \u2014 a control that cannot\nreach the host must say so, not sit silent.\n\n **Accessibility is the markup\'s job, not the skin\'s.** `data-lb-state` is a\n STYLING hook; it sets no ARIA. A card renders N times in a list, so every\n repeated control needs a name that says *which* lead it acts on:\n\n ```html\n <select class="lb-select" aria-label="Lead status for Acme Corp"></select>\n <button class="lb-btn" aria-label="Like Acme Corp">Like</button>\n <a class="lb-link-out" aria-label="Open Acme Corp in Leadbay" \u2026>\n <span class="lb-msg" role="status" aria-live="polite"></span>\n ```\n\n Without the `role="status"` node the rep hears nothing when a write fails \u2014\n `bindAction` puts the message in a `data-lb-error` attribute that nothing\n renders. Mark the `\u25B0\u2756\u25B1` bar `aria-hidden="true"` and follow it with\n `<span class="lb-vh">Fit: strong</span>`; the glyphs otherwise read aloud as\n "black parallelogram black parallelogram\u2026" before the company name. Use\n `lb-vh` for any label that should be heard but not seen.\n\n## Recipe: the pull-leads triage board (THE default board)\n\nWhen the rep accepts an interactive board after ANY tool that returns a batch\nof leads \u2014 `leadbay_pull_leads`, `leadbay_find_new_leads`,\n`leadbay_pull_followups`, `leadbay_campaign_call_sheet` \u2014 build THIS. It is a\nfixed recipe, not a starting point: the same board every time means a rep who\nlearned it once knows it everywhere. Deviate only when the rep asks for\nsomething specific.\n\nBuild it from the data ALREADY IN HAND \u2014 never re-call the tool that produced\nthe batch just to populate the board.\n\nTwo things change with the source, and nothing else does:\n\n- **The deep link\'s view.** `pull_leads` omits `in_monitor`/`in_discover`, so\n its leads are the Discover batch by definition; `pull_followups` carries\n `in_monitor: true` on every row, so a call board links to `monitor`; a\n campaign sheet needs `?campaign=<id>&lead=<id>`. See the leadUrl helper above.\n- **Which write leads the card.** A discovery batch is triaged (taste + status);\n a follow-up list is worked (`lb.outreach`, gated on a note). Order the\n Outreach and Status sections to match, but ship both either way.\n\n**Per card** \u2014 a header, then a stack of titled SECTIONS, then the write\nfeedback. The skin ships the whole geometry (`lb-sections` = 16px between\nsections, `lb-section` = 8px within), so build the structure and add no\nspacing CSS of your own:\n\n```html\n<article class="lb-card">\n <div class="lb-card-top"> <!-- checkbox \xB7 title \xB7 taste -->\n <input type="checkbox" aria-label="Select Acme Corp for bulk apply">\n <div class="lb-lead-title"><a href="https://acme.com">Acme Corp</a></div>\n <span class="lb-group"><!-- like / dislike, lb-btn-icon --></span>\n </div>\n <div class="lb-chips" hidden><!-- data-taste + data-status, see contract --></div>\n\n <div class="lb-sections">\n <div class="lb-section"> <!-- untitled: what it is -->\n <div class="lb-tags-plain"><span>Honfleur, Normandie</span><span>50\u201399</span></div>\n <div class="lb-sub">Why it fits, one sentence.</div>\n </div>\n <div class="lb-section"><div class="lb-sec-title">Fit</div>\u2026</div>\n <div class="lb-section">\n <div class="lb-sec-title">Intent tags</div>\n <div class="lb-tags-intent"><span>Vente terrain</span>\u2026</div>\n <!-- no tags? <div class="lb-tags-empty">None \u2014 the qualifier found no\n buying signal</div>, never an empty row -->\n </div>\n <div class="lb-section"><div class="lb-sec-title">Data</div>\u2026</div>\n <div class="lb-section">\n <div class="lb-sec-title">Status</div>\n <select class="lb-select" aria-label="Lead status for Acme Corp"></select>\n </div>\n <div class="lb-section">\n <div class="lb-sec-title">Outreach</div>\n <div class="lb-stack"> <!-- one control per line -->\n <select class="lb-select" aria-label="Outreach result for Acme Corp"></select>\n <input class="lb-input" aria-label="Outreach note for Acme Corp">\n <button class="lb-btn lb-btn-submit">Log outreach</button>\n </div>\n </div>\n <details class="lb-section"><!-- lazy full profile, see below --></details>\n <div class="lb-card-foot">\n <button class="lb-btn lb-btn-ai">Requalify</button>\n <span class="lb-spacer"></span>\n <a class="lb-link-out">Open in Leadbay</a>\n </div>\n </div>\n\n <div class="lb-msg" role="status" aria-live="polite"></div>\n</article>\n```\n\nWire them with `lb.like` / `lb.dislike`, `lb.leadStatus()` +\n`lb.setStatus({leadId, status, ask})`, and `lb.outreach({leadId, ask, status,\nnote})` \u2014 the note field gated by a `validate` so an empty note cannot log.\n\nFour rules the structure encodes, each of which a hand-built card gets wrong:\n\n- **One section per titled block.** Two titles in one `lb-section` share its\n 8px gap instead of the 16px between sections, so the second title reads as\n part of the first block. Fit and Intent tags are the pair this catches.\n- **A section title sits at section level**, as a direct child. Nested inside a\n row (beside a verdict, say) it stops being a peer of the other titles and the\n card loses its scan order.\n- **No date input.** Status saves on change and the backend stamps the date as\n "now"; a picker is only for backdating, which this board does not do.\n- **Controls stack and span their section.** A select sizes to its longest\n option and a text input to a UA default, so side by side they come out\n different widths despite identical padding and height.\n\nOnly the bulk apply keeps a submit button, because it fans out across checked\nrows and takes a `confirm`.\n\n**Toolbar** \u2014 a tally, four filters (CRM status \xB7 taste \xB7 qualifier verdict \xB7\nsector), select-all + one `lb.setStatus({leadIds})` bulk write, and\n`lb.sortOrder()` bound to the list\'s `order`. Filters are CLIENT-side over the\nloaded page; sort is SERVER-side, so changing it must `loadPage(0)` (see the\nsorting rule above). The skin ships its geometry too:\n\n```html\n<div class="lb-toolbar">\n <span class="lb-tally">5 leads</span>\n\n <span class="lb-group"> <!-- narrow: sort + filters -->\n <label class="lb-field">\n <span class="lb-field-label">Sort</span>\n <select class="lb-select" aria-label="Sort order"></select>\n </label>\n <label class="lb-field">\n <span class="lb-field-label">Status</span>\n <select class="lb-select" aria-label="Filter by CRM status"></select>\n </label>\n <!-- taste, verdict \u2026 -->\n </span>\n\n <span class="lb-spacer"></span>\n\n <span class="lb-group"> <!-- act on the selection -->\n <label class="lb-field lb-field-inline">\n <input type="checkbox"><span class="lb-field-label">Select all</span>\n </label>\n <select class="lb-select" aria-label="Status for selected leads"></select>\n <button class="lb-btn lb-btn-submit">Apply to selected</button>\n </span>\n\n <span class="lb-status" data-live="yes"> <!-- shape, not just colour -->\n <span class="lb-status-dot"></span>live\n </span>\n\n <div class="lb-msg" role="status" aria-live="polite" style="flex:1 0 100%"></div>\n</div>\n```\n\nThree groups, because the bar does three things \u2014 report, narrow, act. Flat, the\nbulk commit reads as one more filter. Sort belongs in the narrow group but costs\na round trip the others do not, so mark that boundary (a divider, or its own\n`lb-group`) rather than letting four selects look interchangeable.\n\n**Pagination** \u2014 `lb.leadList` is already a paginated model: `.page`,\n`.pageSize`, `.total`, `.hasMore`, `.next()`, `.prev()`, `.loadPage(n)`. A\nboard that renders only page 0 silently hides the rest of the lens, so render\nthe pager whenever `total` exceeds one page:\n\n```html\n<div class="lb-pager">\n <button class="lb-btn" data-k="prev">Previous</button>\n <button class="lb-btn" data-k="next">Next</button>\n <span class="lb-spacer"></span>\n <span class="lb-pager-range">21\u201340 of 60</span>\n</div>\n```\n\n```js\nconst list = lb.leadList({ order: sort, ask: ASK, pageSize: 20 });\nlist.subscribe((l) => {\n deck.setAttribute("data-lb-state", l.loading ? "loading" : "ready");\n if (l.error) { renderError(l.error); return; } // .code says what to do\n if (!l.loading) renderCards(l.items); // keep old rows while loading\n const from = l.page * l.pageSize + 1;\n range.textContent = `${from}\u2013${Math.min(from + l.items.length - 1, l.total)} of ${l.total}`;\n prev.disabled = l.page === 0 || l.loading;\n next.disabled = !l.hasMore || l.loading;\n});\nprev.onclick = () => list.prev();\nnext.onclick = () => list.next();\n```\n\nFour things this gets right that a hand-rolled pager usually does not:\n\n- **A range, not a page number.** "21\u201340 of 60" says how much is left; "page 2"\n does not, and a rep cannot tell a short lens from a long one.\n- **Disable at the ends, and while loading.** `hasMore` is\n `(page + 1) * pageSize < total`, so it is false on the last page even when\n that page is full \u2014 a Next that fetches nothing reads as a broken button.\n- **Keep the old rows while the next page loads.** Blanking the deck loses the\n scroll position; `lb-deck[data-lb-state=loading]` dims and locks it instead.\n `loadPage` already drops a stale response if the rep flips pages quickly.\n- **Selection and page are independent.** A checked lead on page 1 stays in the\n bulk set after a flip, so either carry the selection across pages or clear it\n on the flip \u2014 silently dropping it means a bulk apply writes fewer leads than\n the rep ticked. Say which you chose in the UI.\n\nChanging the sort resets to page 0 (`loadPage(0)`), since the backend sorts the\nwhole lens and page 2 of the old order is not page 2 of the new one.\n\n**Rich profile data is LAZY.** The card renders from the list payload alone. A\n`lb.leadProfile(leadId, ask)` resource \u2014 sector label, `location.full`, real\n`linkedin_page`, the qualification Q&A \u2014 loads only when the rep EXPANDS that\ncard, one call for one lead they chose to open:\n\n```js\nconst profile = lb.leadProfile(lead.id, ASK); // autoLoad:false\nprofile.subscribe((p) => renderDetail(els.detail, p));\nels.expand.onclick = () => profile.load(); // one call, on demand\n```\n\nNever prefetch it for the batch: a 20-lead board would fire 20 requests to fill\nlines the rep may never read. The list payload already carries everything the\ncollapsed card shows.\n\n**Requalify** is `leadbay_bulk_qualify_leads` with `leadIds` (camelCase \u2014 NOT\n`lead_ids`) and `wait_for_completion: false`, so the button returns as soon as\nthe job is queued instead of holding through the poll. Give it\n`class="lb-btn lb-btn-ai"` \u2014 purple is the product\'s AI affordance, and the\napp\'s own QualifyButton is `variant="ai"`.\n\n## Recipe: cold-call sheet (one row per lead)\n\n```js\nconst lb = window.LeadbayArtifacts; lb.configure();\nconst ASK = "<the user\'s request>";\n\nconst list = lb.callList({ source: "campaign", campaignId: CID, ask: ASK });\nlist.subscribe((l) => renderRows(l.items, l.loading)); // your render\n\n// per lead row (call when you build a row):\nfunction wireRow(lead, els) {\n const status = lb.field({ value: "STILL_CHASING" }); // static-enum <select>\n const note = lb.field({ validate: (v) => (v && v.trim() ? null : "Add a note") });\n lb.bindValue(els.status, status);\n lb.bindValue(els.note, note);\n lb.bindAction(els.log, lb.outreach({ leadId: lead.id, ask: ASK, status, note }));\n lb.bindAction(els.like, lb.like(lead.id));\n\n const history = lb.leadHistory(lead.id, ASK); // lazy\n history.subscribe((h) => renderHistory(els.history, h));\n els.expand.onclick = () => history.load(); // load on click\n}\n```\n\n## Recipe: lead-status dropdown (Wanted / Won / Lost)\n\nThe org-wide CRM status, as a `<select>` that writes on change \u2014 no Apply\nbutton. You write the markup; `lb.leadStatus` fills the options and holds the\nvalue, `lb.setStatus` does the write.\n\n```html\n<div class="lb-card">\n <div class="lb-card-head">\n <span class="lb-title">Acme Corp</span>\n <span class="lb-chips">\n <span id="taste" class="lb-chip" data-taste="liked">Liked</span>\n <span id="crm" class="lb-chip" data-status="WANTED">Wanted</span>\n </span>\n </div>\n <div class="lb-row">\n <select id="st" class="lb-select" aria-label="Lead status for Acme Corp"></select>\n </div>\n <span id="msg" class="lb-msg" role="status" aria-live="polite"></span>\n</div>\n```\n\n**Two badges, never one.** Taste (`liked`/`disliked`, from `lb.like`/`lb.dislike`)\nand CRM status (`WANTED`/`WON`/`LOST`/`UNWANTED`, from `lb.setStatus`) are\nindependent axes \u2014 a lead can be liked *and* lost. Collapsing them into a single\nchip destroys information: the rep can no longer see that a lead they liked went\nnowhere. Render `data-taste` and `data-status` as separate chips inside\n`lb-chips`, and hide the one that has no value rather than reusing it.\n\n```js\nlb.styles(); // once per artifact \u2014 see below\n\nconst status = lb.leadStatus(lead.org_lead_status); // seed with the CURRENT value\nconst save = lb.setStatus({ leadId: lead.id, status, ask: ASK });\n\nconst sel = document.getElementById("st");\nlb.bindSelect(sel, status); // populates the 4 options\nsel.addEventListener("change", () => save.run()); // change \u2192 write, no button\n\nsave.subscribe((a) => { // render your own feedback\n sel.setAttribute("data-lb-state", // the select IS the surface\n a.loading ? "loading" : a.error ? "error" : a.lastResult ? "success" : "ready");\n msg.textContent = a.loading ? "Saving\u2026"\n : a.error ? a.error.message // includes partial failures\n : a.lastResult ? `Set to ${a.lastResult.status}` : "";\n msg.dataset.tone = a.error ? "error" : a.lastResult ? "ok" : "";\n});\n```\n\nLoading / success / error styling comes free: `bindAction` and `bindSelect` set\n`data-lb-state` (`ready|loading|error|success|unavailable`) and the skin already\ntargets those attributes. No extra wiring.\n\n**Save on change is the default for status.** The rep picks a value and it\nwrites \u2014 one interaction, no second button, matching the web app\'s own status\nselector. Drop `bindAction` and run the action from the change event:\n\n```js\nconst sel = document.getElementById("st");\nsel.addEventListener("change", () => save.run());\nsave.subscribe((a) => { // the select IS the feedback surface\n sel.setAttribute("data-lb-state",\n a.loading ? "loading" : a.error ? "error" : a.lastResult ? "success" : "ready");\n msg.textContent = a.loading ? "Saving\u2026" : a.error ? a.error.message\n : a.lastResult ? "Saved" : "";\n msg.dataset.tone = a.error ? "error" : a.lastResult ? "ok" : "";\n});\n```\n\nWithout a submit button the select becomes the only affordance, so it MUST show\nthe write: mirror `data-lb-state` onto it (the skin already styles loading /\nsuccess / error on `.lb-select`) and put the outcome in the `role="status"`\nline. A silent select leaves the rep unsure whether anything happened.\n\nKeep a submit button ONLY where a mis-click is expensive and the value is not\nself-evident \u2014 a bulk apply across checked rows, for instance, which already\ntakes a `confirm`. For one lead, the extra step buys nothing: the value is\nvisible in the select, and the rep can simply pick again.\n\n**Bulk apply** across checked rows \u2014 pass `leadIds` and a `confirm`, since one\nclick rewrites a field every rep in the org sees:\n\n```js\nconst bulk = lb.setStatus({\n leadIds: () => checkedIds, // \u2190 read at run() time, not at build time\n status, ask: ASK,\n confirm: "Set this status on every selected lead?",\n});\n```\n\n`leadIds` is read when the action runs, so a live selection works \u2014 but pass the\narray itself if your selection is fixed. A partial write (some leads rejected)\nsurfaces as `.error`, never as a green button: `setStatus` checks the `failed[]`\nthe tool returns.\n\nThe backend stamps the status date as "now" on every write, which is what a rep\nclicking a dropdown means. Don\'t add a date picker unless the user asks to\nbackdate \u2014 then pass an optional `date` field holding `YYYY-MM-DD`:\n`lb.setStatus({ leadId, status, date, ask })`.\n\n## Recipe: segment coverage (sector / location)\n\n"How many leads do we have in sector X" is one cheap call: the Monitor filter\nplus `count: 1`, read off `pagination.total`. `lb.segmentCount` wraps it.\n\n### Never hardcode the sector list\n\nA board that offers sectors must offer the ones the user HOLDS. Typing a few\nids into the markup gets this wrong in both directions \u2014 one real portfolio\'s\nhand-written list offered a sector holding 3 leads while omitting the\nthird-largest at 555.\n\nThere is no group-by on the Monitor, but every lead carries `sector_id`, so one\npage of followups names the sectors that matter:\n\n```js\n// SECTORS is { id: label }, embedded at BUILD time from leadbay_list_sectors.\n// The visible taxonomy is ~1,346 entries and does not change between runs, so\n// fetching it per page load buys nothing.\nconst held = await lb.portfolioSectors({ sectors: SECTORS, ask: ASK });\nfor (const s of held) {\n const opt = document.createElement(\'option\');\n opt.value = s.id;\n opt.textContent = s.label; // falls back to `Sector <id>` when unresolved\n sel.appendChild(opt);\n}\n```\n\n`sampled` is a SAMPLE count, for ordering the list. It is not the user\'s total\n\u2014 call `lb.segmentCount` for the exact figure once a sector is picked.\n\n**The taxonomy\'s `number_of_leads` is the WHOLE MARKET, not this user\'s book.**\nSupermarch\xE9s reads 11,460 nationally against 3,656 in one real portfolio, and\nSup\xE9rettes 5,710 against 143. Wiring a dropdown to that field ranks sectors the\nuser barely holds above ones they live in. Use it as a denominator if you are\nexplicitly showing market coverage, and label it as such \u2014 never present it as\nthe user\'s number.\n\n```js\nconst supermarkets = await lb.segmentCount({ sectorIds: ["5134"], ask: ASK });\n// \u2192 { total: 3656, applied: [...], trusted: true }\n```\n\n**Two traps, both observed against the live API \u2014 a hand-rolled version hits\nboth:**\n\n1. **The filter is server-side and STATEFUL.** `set_filter` overwrites ONE\n stored FilterItem per user, so consecutive calls are not independent: the\n next one inherits what the last one stored. Always send the complete\n criteria set, never a delta, and never assume a fresh call starts clean.\n2. **A rejected criterion fails SILENTLY.** Send a malformed criterion and the\n call returns **200 with the PREVIOUS filter still applied** \u2014 a plausible\n number answering a different question. `segmentCount` compares the echoed\n `active_filters` against what it sent and returns `trusted: false` on a\n mismatch. Never chart an untrusted count; say the segment could not be\n measured instead.\n\n The echo must match the request in BOTH directions. Every criterion asked\n for has to come back \u2014 a `city` / `cityId` as a `location_ids` criterion,\n since the composite resolves the free text through `/geo/search` into a type\n you never sent \u2014 and **nothing may come back that was not asked for**. The\n stored filter is cumulative, so narrowing a segment (sector+city \u2192 sector\n alone) leaves the dropped criterion in force: the count stays fenced to a\n city nobody asked about while every requested type is dutifully present. An\n unrequested criterion narrows a count exactly as a dropped one widens it.\n That rule also covers the unfiltered call on its own terms \u2014 with nothing\n wanted, "nothing extra" is "the echo is empty", which is what makes a\n whole-book denominator trustworthy.\n\n Sector VALUES are compared too, since a stale sector filter is still a sector\n filter. Locations are checked for presence only: the server picks the\n `admin_area_id` from free text, so the caller has nothing to compare against.\n\n**What you cannot build this way.** A score histogram needs every lead\'s\n`ai_agent_lead_score`, and there is no aggregation endpoint \u2014 bucketing 3,656\nleads means 732 pages. Sample the tails instead (`order: "SCORE:ASC"` and\n`"SCORE:DESC"`, a few hundred each) and **label the chart as a sample with its\nn**, or do not draw it. The same applies to a density map: coordinates are on\nevery lead, but aggregating thousands of points client-side is a batch job, not\na dashboard. Both want a backend stats endpoint.\n\n**Budget the calls.** A filtered count is normally 1\u20132 s, but adding a\n`last_action_date` criterion was observed at **54 s** on a 3.6k segment. Fire\nsegments in sequence with a visible progress cue, not a parallel burst, and\nnever block the first paint on them.\n\n## Recipe: manager dashboard\n\n`lb.teamActivity` returns `{range, reps, trend}`. A manager reads before they\nact, so the page is three bands in that order: the figures, the trend behind\nthem, then the per-rep table.\n\n```html\n<div class="lb-tiles"> <!-- only figures that ARE the point -->\n <div class="lb-tile"><span class="lb-tile-label">Activities</span>\n <span class="lb-tile-value">0</span></div>\n <!-- meetings / notes / contacts added \u2026 -->\n</div>\n\n<svg class="lb-chart" viewBox="0 0 640 160" role="img"\n aria-label="Weekly activity, 24 Jun to 16 Sep">\u2026</svg>\n\n<table class="lb-table">\n <thead><tr>\n <th aria-sort="none">Rep</th>\n <th data-num aria-sort="descending">Activities</th>\n <th data-num aria-sort="none">Meetings</th>\n </tr></thead>\n <tbody><tr aria-selected="false">\u2026</tr></tbody>\n</table>\n```\n\n```js\nconst team = lb.teamActivity({ weeks: 4, ask: ASK });\nteam.subscribe((t) => {\n if (t.error) { renderError(t.error); return; } // .code says what to do\n if (!t.data) return; // first load\n const { reps, trend, range } = t.data;\n const sum = (k) => reps.reduce((a, r) => a + (r[k] || 0), 0);\n\n stage.replaceChildren(\n lb.tiles([\n { label: "Activities", value: sum("total_activities") },\n { label: "Meetings", value: sum("meetings_or_interest") },\n { label: "Notes", value: sum("notes") },\n ]),\n lb.sparkline(trend, { // [] \u2192 the empty block\n label: `Activity from ${range.from} to ${range.to}`,\n emptyTitle: "No activity in this window",\n emptyHint: `Nothing logged between ${range.from} and ${range.to}. Widen the window.`,\n }),\n lb.leaderboard({\n rows: reps,\n sortKey: "total_activities",\n columns: [\n // reps[] carries email and nothing else \u2014 a mailto is the honest\n // affordance, since no MCP tool messages a rep.\n { key: "name", label: "Rep", cell: (r) => {\n const a = document.createElement("a");\n a.href = `mailto:${r.email}`; a.textContent = r.name || r.email;\n return a;\n } },\n { key: "total_activities", label: "Activities", num: true },\n { key: "meetings_or_interest", label: "Meetings", num: true },\n { key: "lost", label: "Lost", num: true },\n ],\n emptyTitle: "No reps in this window",\n emptyHint: "The backend scopes non-admins to themselves.",\n }),\n );\n});\nrefreshBtn.onclick = () => team.refresh();\n```\n\nFive things this gets right that a hand-built dashboard usually does not:\n\n- **An empty window is a real answer.** A quiet team returns `total_activities:\n 0` for every rep and `trend: []` \u2014 the common case on a new account. Draw\n `lb-empty` with what it means ("no activity logged in this window") and a way\n out (widen the range), never empty axes, which read as a broken chart.\n- **Chart from the tokens, not a CDN.** `lb-chart` styles an inline SVG from\n the theme, so it reads in light and dark. A hardcoded stroke disappears in\n one of the two, and a CDN chart that fails to load shows nothing at all \u2014 a\n sparse weekly series does not earn the dependency.\n- **Sorting is CLIENT-side here**, unlike a lead list: `reps` is the whole team\n in one response, so re-sorting reorders data you already hold. Reflect it in\n `aria-sort` on the header, not only with an arrow.\n- **Tabular numerals on every count** (`data-num`), or the columns will not\n line up and the leaderboard cannot be scanned down.\n- **Name the window.** `range.from`/`range.to` are resolved server-side and may\n not match what was asked for; printing them is what makes the figures\n auditable.\n\n**Writing to a rep is not in this payload.** `reps[]` carries `user_id`, `name`\nand `email` \u2014 enough to open a mail client with `mailto:`, and nothing more.\nThere is no MCP tool that messages a rep, so do not render a "message" button\nthat silently does nothing; a `mailto:` link is the honest affordance until\none exists.\n\n## Recipe: live enrichment\n\n```js\nconst job = lb.enrichment({ leadIds: [LEAD], titles: ["CEO", "VP Sales"], ask: ASK });\njob.subscribe((j) => {\n const p = j.data && j.data.overall_progress; // {done,total,done_ratio}\n renderBar(p);\n if (j.done) renderContacts(j.data.leads); // enriched contacts\n});\nrefreshBtn.onclick = () => job.refresh();\n```\n\n## Write-call rules\n\nThe domain factories handle these for you. If you hand-roll an action:\n`leadbay_report_outreach` args MUST include `verification:{source:"user_confirmed", ref}`\nAND `_triggered_by`; `leadbay_add_leads_to_campaign` needs `_triggered_by`;\n`add_note`/`like_lead`/`dislike_lead` take only their own args. `epilogue_status` is\none of `lb.EPILOGUE_STATUSES`. Snoozing (pushback) is advanced-gated \u2014 not\ncallable from a default artifact. Org lead status IS on the default surface:\nuse `lb.setStatus`, which owns the arg shape AND the partial-write check \u2014\n`leadbay_set_lead_status` writes each lead separately, so it can resolve 200\nwith a non-empty `failed[]`. Hand-rolling that action will report a green\nbutton over a write that never landed.\n\n## Degradation + live updates\n\nIf the host bridge is absent, a view-model\'s `.error` is set with `.error.unavailable\n=== true` (bind helpers set `data-lb-state="unavailable"`) \u2014 nothing throws. Every\ncall also has a **30s timeout** (configurable via `lb.configure({ timeoutMs })`): a\nhost call that never settles becomes `.error` with `code:"timeout"`, so a control is\nnever stuck loading forever \u2014 always render the `.error` branch so the user can retry.\nAuto-poll (`pollEvery`) depends on the cowork host serving FRESH reads; `.refresh()`\nis the guaranteed manual path \u2014 always wire a Refresh control for polling resources.\n\n\n## Runtime diagnostics (automatic \u2014 you do not wire this)\n\nThe runtime reports its own failures to the Leadbay team so a broken artifact\ndoes not stay invisible: a picker that loads zero options, a button blocked by\nvalidation, a call that timed out, failed, came back as an error envelope, or\nanswered with unparseable text. It sends these itself, over the same bridge,\ndeduped and capped \u2014 **you do not need to add anything**, and you should not\ncall `leadbay_report_artifact_error` yourself.\n\nOnly bounded values travel: the failure kind, which view-model surfaced it, the\ntool name, an error code, and the kit version. Never a message, never user text,\nnever anything the user typed. A user who has turned telemetry off with\n`leadbay_set_telemetry` sends nothing at all.\n\nTwo knobs, both optional. `lb.setTelemetry(false)` opts a page out entirely.\n`lb.report({kind, surface, tool?, code?})` reports a failure the library cannot\nsee \u2014 a render that threw, or a control you wired by hand rather than through a\nview-model. Everything else is automatic.\n\nThis is NOT the way to report a problem the USER raised. If the rep tells you\nthe artifact is wrong and asks you to pass it on, that is\n`leadbay_report_friction`, which carries their own words and needs their\nconsent.';
29533
+ var ARTIFACT_RUNTIME = '"use strict";(()=>{var fe=Object.defineProperty;var me=(e,t,n)=>t in e?fe(e,t,{enumerable:!0,configurable:!0,writable:!0,value:n}):e[t]=n;var s=(e,t,n)=>me(e,typeof t!="symbol"?t+"":t,n);var W=`\n:root{\n--color-black:#191919;--color-white:#fff;\n--color-gray-1:#f9f9f9;--color-gray-2:#f0f0f0;--color-gray-3:#e0e0e0;--color-gray-4:#cecece;\n--color-gray-5:#c4c4c4;--color-gray-6:#8d8d8d;--color-gray-7:#787878;--color-gray-8:#646464;\n--color-gray-9:#202020;\n--color-linkedin:#0a66c2;\n--color-blue-background:oklch(0.947 0.029 251);--color-blue-foreground:oklch(0.564 0.181 251);\n--color-green-background:oklch(0.947 0.029 141);--color-green-foreground:oklch(0.564 0.181 141);\n--color-red-background:oklch(0.947 0.029 26);--color-red-foreground:oklch(0.564 0.191 26);\n--color-gold-background:oklch(0.972 0.049 91);--color-gold-foreground:oklch(0.667 0.177 91);\n/* Yellow is the web app\'s "Could not reach" prospecting action. Its\n foreground is light (0.864) by design: the web app uses it for icons and\n borders on the tinted background, never as text on white. */\n--color-yellow-background:oklch(0.972 0.054 91);--color-yellow-foreground:oklch(0.864 0.181 91);\n--color-yellow-border:color-mix(in oklch,var(--color-yellow-foreground),transparent 65%);\n--color-cherry-background:oklch(0.947 0.029 15);--color-cherry-foreground:oklch(0.44 0.146 15);\n/* Purple carries the product\'s "AI" affordance (Qualify / Requalify). The\n border is the foreground at 65% transparency, exactly as the style package\n derives every *-border token. */\n--color-purple-background:oklch(0.947 0.029 288);--color-purple-foreground:oklch(0.464 0.181 288);\n--color-purple-border:color-mix(in oklch,var(--color-purple-foreground),transparent 65%);\n/* Teal carries the qualifier\'s intent tags, as the product\'s taste-profile\n tags do. Border derived at 65% transparency, like every *-border token. */\n--color-teal-background:oklch(0.977 0.019 180);--color-teal-foreground:oklch(0.574 0.182 180);\n--color-teal-border:color-mix(in oklch,var(--color-teal-foreground),transparent 65%);\n--color-red-like:var(--color-cherry-foreground);\n--lb-font:"Nikkei Maru",system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;\n/* Concentric: outer = inner + padding (0.625 + 0.875 = 1.5rem). The card\'s\n padding is what closes this equation, so the two move together \\u2014 see\n .lb-card, which pads 0.875rem for exactly this reason. */\n--lb-radius:1.5rem;--lb-radius-sm:0.625rem;--lb-gap:0.75rem;\n--lb-surface:var(--color-gray-1);--lb-border:var(--color-gray-3);\n--lb-fg:var(--color-black);--lb-muted:var(--color-gray-8);--lb-field:var(--color-white);\n/* Chips and shadows need their own aliases: a chip painted with a raw ramp\n value cannot theme, and a shadow tinted with --color-gray-9 is invisible in\n dark because gray-9 IS the dark surface. */\n--lb-chip-bg:var(--color-gray-2);\n--lb-control-border:var(--color-gray-6);\n--lb-shadow:0 0.5rem 1.5rem oklch(0 0 0/.06),0 0.125rem 0.5rem oklch(0 0 0/.04);\ncolor-scheme:light;\n/* macOS renders text heavier than intended. This belongs once on the root,\n never per component \\u2014 an artifact that injects this sheet gets it for free\n instead of each page re-declaring it on its own body. */\n-webkit-font-smoothing:antialiased;\n-moz-osx-font-smoothing:grayscale;\n}\n:root[data-theme=dark],:root[data-lb-theme=dark]{\n--lb-surface:var(--color-gray-9);--lb-border:var(--color-gray-8);\n--lb-fg:var(--color-white);--lb-muted:var(--color-gray-3);\n/* --lb-field must NOT equal --lb-surface: a control needs its own ground or it\n reads as text on the card (was 1.00:1). */\n--lb-field:#2c2c2c;\n--lb-chip-bg:#3b3b3b;\n--lb-control-border:var(--color-gray-6);\n--lb-shadow:0 0.5rem 1.5rem oklch(0 0 0/.5),0 0.125rem 0.5rem oklch(0 0 0/.35);\ncolor-scheme:dark;\n--color-blue-background:oklch(0.30 0.055 251);--color-blue-foreground:oklch(0.80 0.11 251);\n--color-green-background:oklch(0.30 0.055 141);--color-green-foreground:oklch(0.80 0.13 141);\n--color-red-background:oklch(0.30 0.060 26);--color-red-foreground:oklch(0.80 0.11 26);\n--color-gold-background:oklch(0.30 0.055 91);--color-gold-foreground:oklch(0.82 0.13 91);\n--color-yellow-background:oklch(0.30 0.055 91);--color-yellow-foreground:oklch(0.86 0.14 91);\n--color-cherry-background:oklch(0.30 0.055 15);--color-cherry-foreground:oklch(0.82 0.10 15);\n--color-purple-background:oklch(0.30 0.055 288);--color-purple-foreground:oklch(0.82 0.11 288);\n--color-teal-background:oklch(0.30 0.055 180);--color-teal-foreground:oklch(0.82 0.11 180);\n}\n@media(prefers-color-scheme:dark){:root:not([data-theme=light]):not([data-lb-theme=light]){\n--lb-surface:var(--color-gray-9);--lb-border:var(--color-gray-8);\n--lb-fg:var(--color-white);--lb-muted:var(--color-gray-3);\n/* --lb-field must NOT equal --lb-surface: a control needs its own ground or it\n reads as text on the card (was 1.00:1). */\n--lb-field:#2c2c2c;\n--lb-chip-bg:#3b3b3b;\n--lb-control-border:var(--color-gray-6);\n--lb-shadow:0 0.5rem 1.5rem oklch(0 0 0/.5),0 0.125rem 0.5rem oklch(0 0 0/.35);\ncolor-scheme:dark;\n--color-blue-background:oklch(0.30 0.055 251);--color-blue-foreground:oklch(0.80 0.11 251);\n--color-green-background:oklch(0.30 0.055 141);--color-green-foreground:oklch(0.80 0.13 141);\n--color-red-background:oklch(0.30 0.060 26);--color-red-foreground:oklch(0.80 0.11 26);\n--color-gold-background:oklch(0.30 0.055 91);--color-gold-foreground:oklch(0.82 0.13 91);\n--color-yellow-background:oklch(0.30 0.055 91);--color-yellow-foreground:oklch(0.86 0.14 91);\n--color-cherry-background:oklch(0.30 0.055 15);--color-cherry-foreground:oklch(0.82 0.10 15);\n--color-purple-background:oklch(0.30 0.055 288);--color-purple-foreground:oklch(0.82 0.11 288);\n--color-teal-background:oklch(0.30 0.055 180);--color-teal-foreground:oklch(0.82 0.11 180);\n}}\n/* 0.875rem padding is not arbitrary: it closes the concentric equation with\n --lb-radius (1.5rem) and --lb-radius-sm (0.625rem). Changing it without\n moving --lb-radius leaves a control\'s arc running wider than the card\'s in\n the corner. */\n.lb-card{display:grid;gap:var(--lb-gap);padding:0.875rem;\nbackground-color:var(--lb-surface);border:1px solid var(--lb-border);\nborder-radius:var(--lb-radius);corner-shape:squircle;color:var(--lb-fg);\nfont-family:var(--lb-font);\nbox-shadow:var(--lb-shadow)}\n/* \\u2500\\u2500 Card layout \\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\n A lead card is a stack of SECTIONS, each a titled block (Fit, Intent tags,\n Data, Status, Outreach). One wrapper owns the rhythm \\u2014 16px between\n sections, 8px between the items inside one \\u2014 so a section can be added,\n removed or reordered without touching spacing, and no margin can collapse.\n Every artifact that builds cards gets the same geometry from here rather\n than hand-rolling it. */\n.lb-sections{display:flex;flex-direction:column;gap:1rem}\n.lb-section{display:grid;gap:0.5rem}\n/* A section title: black, uppercase, tracked. It names the block below it, so\n it must sit at SECTION level \\u2014 nested inside a row it stops being a peer of\n the other titles and the card loses its scan order. */\n.lb-sec-title{font-size:0.75rem;font-weight:700;letter-spacing:.08em;\ntext-transform:uppercase;color:var(--lb-fg);line-height:1.4;\ntext-wrap:balance}\n/* Header row: leading control (a bulk checkbox), the title, then trailing\n controls (taste) pushed to the far edge. */\n.lb-card-top{display:flex;align-items:center;gap:0.625rem;flex-wrap:wrap}\n.lb-card-top>.lb-group{flex:0 0 auto;margin-inline-start:auto}\n.lb-card-top>input[type=checkbox]{flex:0 0 auto;width:1rem;height:1rem;margin:0}\n/* The company name. Bold and underlined so it reads as the card\'s subject and\n as a link, without competing with the uppercase section titles. */\n/* text-wrap:balance keeps a two-line company name from breaking one-word-over;\n the underline takes its position and thickness from the font\'s own metrics\n rather than wherever the browser puts them, and skip-ink clears descenders. */\n.lb-lead-title{flex:1 1 9rem;min-width:0;font-size:0.9375rem;font-weight:700;\nline-height:1.3;color:var(--lb-fg);text-wrap:balance;text-decoration:underline;\ntext-underline-position:from-font;text-decoration-thickness:from-font;\ntext-underline-offset:2px;text-decoration-skip-ink:auto;overflow-wrap:break-word}\n.lb-lead-title a{color:inherit;text-decoration:inherit}\n/* Controls in a section stack, each spanning it, so every control on the card\n shares one leading edge and one width. Three different widths across a row\n read as three unrelated things. */\n.lb-section>.lb-select,.lb-section>.lb-input,\n.lb-stack>.lb-select,.lb-stack>.lb-input,.lb-stack>.lb-btn{width:100%;min-width:0}\n/* Footer: a leading action, a spacer, a trailing escape hatch. */\n.lb-card-foot{display:flex;align-items:center;gap:0.625rem;\npadding-block-start:0.875rem;border-block-start:1px solid var(--lb-border)}\n.lb-card-foot .lb-spacer{flex:1 1 auto}\n/* \\u2500\\u2500 Toolbar \\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\n The bar above a deck of cards: a tally, the controls that narrow what is\n shown, and the controls that act on a selection. Those are three different\n jobs, so they are three groups \\u2014 0.5rem within one, 1.5rem between \\u2014 rather\n than one flat row where "apply to selected" reads as another filter.\n Concentric: it pads 0.75rem around controls that round at 0.625rem, so it\n rounds at 1.375rem. */\n.lb-toolbar{display:flex;flex-wrap:wrap;align-items:flex-end;gap:0.75rem 1.5rem;\npadding:0.75rem 1rem;background-color:var(--lb-surface);\nborder:1px solid var(--lb-border);border-radius:1.375rem;corner-shape:squircle;\nfont-family:var(--lb-font);color:var(--lb-fg)}\n.lb-toolbar .lb-group{gap:0.5rem}\n/* A labelled control stacks: caption above, control below, as a card section\n does. One label pattern across the whole artifact. */\n.lb-field{display:inline-flex;flex-direction:column;align-items:flex-start;\ngap:0.25rem;font-size:0.8125rem;line-height:1.4;color:var(--lb-muted);\nwhite-space:nowrap}\n/* A checkbox belongs BESIDE its words \\u2014 under a caption it reads as orphaned. */\n.lb-field-inline{flex-direction:row;align-items:center;gap:0.5rem}\n/* A control\'s caption is not body copy: it names the control, so it takes the\n same treatment as a card\'s section title. */\n.lb-field-label{font-size:0.75rem;font-weight:700;letter-spacing:.08em;\ntext-transform:uppercase;color:var(--lb-fg)}\n/* A labelled field sizes to its own content, so width:100% on the control\n inside only fills whatever the field already became. Two fields side by side\n still come out unequal \\u2014 they have no shared reference to be equal against.\n Give the FIELD the basis: flex:1 1 9rem makes every labelled field claim the\n same share of the row, so a select and a text input beside it match. */\n.lb-field{flex:1 1 9rem;min-width:9rem;max-width:16rem}\n.lb-field>.lb-select,.lb-field>.lb-input{width:100%;min-width:0}\n/* An inline field holds a checkbox, not a sized control: natural width, no floor. */\n.lb-field-inline{flex:0 0 auto;min-width:0;max-width:none}\n.lb-field-inline>*{min-width:0;width:auto;flex:0 0 auto}\n/* Readouts sit on the control baseline, not the caption\'s. */\n.lb-toolbar .lb-tally,.lb-toolbar .lb-status{align-self:center}\n.lb-tally{font-size:0.8125rem;line-height:1.4;color:var(--lb-muted);\nfont-variant-numeric:tabular-nums;white-space:nowrap}\n/* Connection state, never carried by colour alone: live is a filled dot, dead\n a hollow ring, so the two differ in shape as well as hue \\u2014 a red and a green\n circle are the same circle to a colour-blind reader, and identical under\n forced colours. */\n.lb-status{display:inline-flex;align-items:center;gap:0.5rem;\nfont-size:0.75rem;line-height:1.4;color:var(--lb-muted);white-space:nowrap}\n.lb-status-dot{width:0.5rem;height:0.5rem;border-radius:50%;flex:none;\nbackground-color:var(--color-gray-5);border:2px solid transparent}\n.lb-status[data-live=yes] .lb-status-dot{background-color:var(--color-green-foreground)}\n.lb-status[data-live=no] .lb-status-dot{background-color:transparent;\nborder-color:var(--color-red-foreground)}\n/* \\u2500\\u2500 Pager \\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\n Below a deck, never above it: the rep reads the rows, then decides whether\n to go on. The range ("21\\u201340 of 60") is the honest label \\u2014 "page 2" alone\n does not say how much is left, and a rep who cannot see the end cannot tell\n a short lens from a long one. Tabular, because the numbers change. */\n.lb-pager{display:flex;align-items:center;gap:0.75rem;flex-wrap:wrap}\n.lb-pager .lb-spacer{flex:1 1 auto}\n.lb-pager-range{font-size:0.8125rem;line-height:1.4;color:var(--lb-muted);\nfont-variant-numeric:tabular-nums;white-space:nowrap}\n/* \\u2500\\u2500 Metrics + chart \\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\n A manager dashboard reports before it lists: a row of figures, then the\n trend behind them, then the per-rep table. Tiles are for the few numbers\n that ARE the point \\u2014 a tile per field turns a dashboard into a wall. */\n.lb-tiles{display:grid;gap:0.75rem;\ngrid-template-columns:repeat(auto-fit,minmax(min(9rem,100%),1fr))}\n.lb-tile{display:grid;gap:0.25rem;padding:0.75rem 0.875rem;\nbackground-color:var(--lb-field);border:1px solid var(--lb-border);\nborder-radius:1.375rem;corner-shape:squircle}\n.lb-tile-label{font-size:0.75rem;font-weight:700;letter-spacing:.08em;\ntext-transform:uppercase;color:var(--lb-muted);line-height:1.4}\n.lb-tile-value{font-size:1.5rem;font-weight:700;line-height:1.15;\ncolor:var(--lb-fg);font-variant-numeric:tabular-nums}\n/* The chart draws from the theme tokens, so it reads in light and dark alike;\n a hardcoded stroke disappears on one of the two. Inline SVG rather than a\n CDN library: a sparse weekly series does not earn 200KB, and an artifact\n that silently fails to load its chart shows nothing at all. */\n.lb-chart{display:block;width:100%;height:auto}\n.lb-chart .lb-chart-grid{stroke:var(--lb-border);stroke-width:1}\n.lb-chart .lb-chart-line{fill:none;stroke:var(--color-blue-foreground);stroke-width:2;\nstroke-linejoin:round;stroke-linecap:round}\n.lb-chart .lb-chart-area{fill:var(--color-blue-background);opacity:.55}\n.lb-chart .lb-chart-dot{fill:var(--color-blue-foreground)}\n/* Axis labels are UI text and take the same 12px floor as everything else \\u2014\n a chart is not a licence to shrink type below what is readable. */\n.lb-chart text{fill:var(--lb-muted);font-family:var(--lb-font);font-size:0.75rem}\n/* An empty series is a real answer, not a failure: say so in place of the\n chart rather than drawing empty axes that look broken. */\n.lb-empty{display:grid;place-items:center;gap:0.25rem;padding:1.5rem 1rem;\nbackground-color:var(--lb-field);border:1px dashed var(--lb-border);\nborder-radius:1.375rem;corner-shape:squircle;text-align:center}\n.lb-empty-title{font-size:0.8125rem;font-weight:600;color:var(--lb-fg);line-height:1.4}\n.lb-empty-hint{font-size:0.78125rem;color:var(--lb-muted);line-height:1.45;max-width:44ch}\n/* A page flip replaces the whole deck, so the rows must read as pending or the\n rep cannot tell a slow page from a finished one. Dim and lock the deck\n rather than blanking it: keeping the old rows on screen preserves the\n scroll position and the sense of place. */\n.lb-deck[data-lb-state=loading]{opacity:.55;pointer-events:none}\n/* The head must be able to wrap. Both children default to flex:0 1 auto, so\n without this they shrink together and a long company name is crushed to three\n lines while the chips keep full width. flex-basis:11rem is a content breakpoint:\n below ~176px for the title, the chips drop to their own line instead. */\n.lb-card-head{display:flex;flex-wrap:wrap;justify-content:space-between;\nalign-items:baseline;gap:var(--lb-gap)}\n/* An email or URL has no break opportunity \\u2014 browsers do not break at "@" or\n ".", so without this a company address escapes the card and the chat host\n clips it with no way to recover the tail. */\n.lb-title{font-size:0.875rem;font-weight:600;line-height:1.3;color:var(--lb-fg);\noverflow-wrap:break-word;min-width:0;flex:1 1 11rem}\n/* pretty stops a lone word landing on the last line of a why-it-fits sentence\n or a fact \\u2014 the orphan that makes a card look unfinished. */\n.lb-sub{font-size:0.8125rem;line-height:1.45;color:var(--lb-muted);\ntext-wrap:pretty;overflow-wrap:break-word;max-width:68ch}\n/* An empty fact line is a 30px void that reads as a rendering bug. */\n.lb-sub:empty{display:none}\n/* The tightest group on the card: consecutive data lines (the contact, then\n the company switchboard) are one block, so they sit at HALF the section\'s\n own 8px. That 2x ratio is what makes them read as a unit rather than as\n separate facts \\u2014 the same rule .lb-section applies one level up against\n .lb-sections\' 16px. The scale halves at each level: 16 \\u2192 8 \\u2192 4. */\n.lb-facts{display:grid;gap:0.25rem}\n/* The contract\'s canonical skeleton already emits data-why; the sheet just did\n not honour it, so consumers hand-patched an inline font-style. */\n.lb-sub[data-why]{font-style:italic;color:var(--lb-fg)}\n/* Icon column: the glyph gets its own cell so a wrapping line hangs on one\n shared text edge instead of running back under the emoji. */\n.lb-fact{display:grid;grid-template-columns:1.15rem 1fr;gap:0.35rem;align-items:baseline}\n.lb-row{display:flex;align-items:center;gap:var(--lb-gap);flex-wrap:wrap}\n/* row-gap matters once the row wraps: without it the wrapped line sits flush\n against the one above. */\n.lb-row{row-gap:0.5rem}\n/* 8px, matching .lb-section \\u2014 a stack inside a section must not space its\n controls wider than the section spaces its own children. */\n.lb-stack{display:grid;gap:0.5rem}\n/* A select is border-box by UA default; a text input is content-box. Given the\n same width, padding and border, the input therefore renders ~2rem wider \\u2014 the\n padding lands outside its box and inside the select\'s. Neither is wrong on its\n own; they just cannot be sized together until they agree. Every control the\n kit styles is border-box, so a declared width is the width that shows. */\n.lb-btn,.lb-select,.lb-input{box-sizing:border-box}\n.lb-select,.lb-input{font:inherit;font-family:var(--lb-font);font-size:0.8125rem;color:var(--lb-fg);\nbackground-color:var(--lb-field);border:1px solid var(--lb-control-border);\nborder-radius:var(--lb-radius-sm);corner-shape:squircle;padding:0.4rem 0.55rem;min-height:2.125rem}\n/* Geometry, states and motion ported from the product\'s Button component\n (packages/ui/components/Button) so an artifact button and an app button are\n the same object. MUI spacing is 8px/unit, so its size=medium (py .875,\n px .875, borderRadius .875) is 7px/7px/7px and large (py 1.75, px 2,\n borderRadius 1.25) is 14px/16px/10px. A control sitting in a row beside a\n select reads as medium, so that is the default here; .lb-btn-lg opts into\n large. The app\'s default variant is secondary \\u2014 white ground, gray-5\n border, black text \\u2014 which is what an unadorned .lb-btn already was. */\n.lb-btn{font:inherit;font-family:var(--lb-font);font-size:0.8125rem;font-weight:600;\ncolor:var(--color-black);background-color:var(--color-white);\nborder:1px solid var(--color-gray-5);\nborder-radius:0.4375rem;corner-shape:squircle;padding:0.4375rem 0.4375rem;min-height:2.125rem;\ncursor:pointer;user-select:none;-webkit-user-select:none;text-wrap:nowrap;\nfont-variant-numeric:tabular-nums;\ntransition:background-color .16s ease,border .16s ease,color .16s ease,\nopacity .16s ease,transform .16s cubic-bezier(0.23,1,0.32,1)}\n/* The app hovers the GROUND to gray-2, not the border. */\n@media (hover:hover){\n.lb-btn:hover:not([disabled]):not([data-lb-state=loading]){\nbackground-color:var(--color-gray-2)}}\n/* Front-loaded press, so the tap leads the colour crossfade. */\n.lb-btn:active:not([disabled]):not([data-lb-state=loading]){transform:scale(0.97)}\n.lb-btn[disabled]{opacity:.4;cursor:default}\n/* A text button with a label + icon: the app lays the content out as a flex row\n with a 6px gap (its content style uses gap 0.75). */\n.lb-btn{display:inline-flex;align-items:center;justify-content:center;gap:0.375rem}\n.lb-btn>svg{display:block;flex-shrink:0;width:1.05rem;height:1.05rem}\n/* Opt-in large, for a button that stands alone rather than in a control row. */\n.lb-btn-lg{border-radius:0.625rem;padding:0.875rem 1rem}\n/* A SUBMIT is not a toggle. Like/Dislike are independent switches; "Set status"\n commits the value sitting in the select beside it. Rendered identically, the\n row reads as five peers and the rep cannot see which control ends the task.\n The accent border + tinted ground marks it as the commit without making it a\n loud filled button \\u2014 the state styles above still win when they apply. */\n/* INVERTED, not recoloured. A normal button is --lb-fg on --lb-field; the\n submit swaps them. That introduces no new hue, and it themes for free: light\n gives near-black on white, dark gives white on near-black \\u2014 the polarity flip\n reads as "this one commits" in both, without competing with the semantic\n state colours (success green / error red) that still override it. */\n/* This is the app\'s primary variant verbatim: black ground, white text, and a\n hover that mixes 32% gray-8 into the black rather than lightening opacity. */\n.lb-btn-submit{background-color:var(--lb-fg);color:var(--lb-field);\nborder-color:var(--lb-fg)}\n@media (hover:hover){\n.lb-btn-submit:hover:not([disabled]):not([data-lb-state=loading]){\nbackground-color:color-mix(in oklch,var(--lb-fg),var(--color-gray-8) 32%);\nborder-color:color-mix(in oklch,var(--lb-fg),var(--color-gray-8) 32%)}}\n/* The app\'s ai variant \\u2014 what QualifyButton/Requalify wears. Purple ground,\n purple-foreground text, border at 65% transparency of the foreground; hover\n mixes 7% black into the ground. Tokens mirror the product\'s style package. */\n.lb-btn-ai{background-color:var(--color-purple-background);\nborder-color:var(--color-purple-border);color:var(--color-purple-foreground)}\n@media (hover:hover){\n.lb-btn-ai:hover:not([disabled]):not([data-lb-state=loading]){\nbackground-color:color-mix(in oklch,var(--color-purple-background),black 7%)}}\n/* Toggles that act on the same axis belong in one visual unit, so the row reads\n as "taste | status | inspect" rather than five equal buttons. The gap inside\n a group is half the gap between groups \\u2014 the 2x rule, applied horizontally. */\n.lb-group{display:inline-flex;align-items:center;gap:0.375rem;flex-wrap:wrap}\n/* Square icon button. Only for actions whose glyph is unambiguous on its own \\u2014\n thumbs up/down qualify, "Set status" does not. It MUST carry an aria-label\n and a title: with no text the icon is the entire affordance, so an unlabelled\n one is unreadable to a screen reader and to anyone who does not know the\n glyph. Same 2.125rem box as a text button, so it keeps the row\'s baseline. */\n.lb-btn-icon{padding:0;width:2.125rem;justify-content:center;display:inline-flex;\nalign-items:center}\n.lb-btn-icon svg{width:1.05rem;height:1.05rem;flex-shrink:0}\n/* Pressed state. The taste chip carries the durable fact; this is the control\n reflecting it, and it must not rely on colour alone \\u2014 aria-pressed is the\n accessible half and the fill is the visible half. */\n/* A heart that stays hollow does not read as "liked" \\u2014 fill it from the same\n currentColor the stroke uses, so one rule covers both themes. */\n.lb-btn-icon[aria-pressed=true] svg{fill:currentColor}\n.lb-btn-icon[aria-pressed=true]{border-color:var(--color-red-like);\ncolor:var(--color-red-like);\nbackground-color:color-mix(in srgb,var(--color-red-like) 10%,var(--lb-field))}\n.lb-btn-icon[data-taste=disliked][aria-pressed=true]{border-color:var(--lb-muted);\ncolor:var(--lb-muted);\nbackground-color:color-mix(in srgb,var(--lb-muted) 10%,var(--lb-field))}\n/* A glyph-only control has no text to widen its hit area, so extend the target\n past the 34px box without inflating the row. */\n.lb-btn-icon{position:relative}\n.lb-btn-icon::before{content:"";position:absolute;inset:-5px}\n.lb-btn:focus-visible,.lb-select:focus-visible,.lb-input:focus-visible,\n.lb-link:focus-visible{\noutline:2px solid var(--color-blue-foreground);outline-offset:1px;\nborder-radius:var(--lb-radius-sm)}\n/* Forced colours replaces every background and custom outline with a system\n colour, collapsing the state styling above into one flat control. */\n@media(forced-colors:active){\n.lb-btn,.lb-select,.lb-input,.lb-chip{border:1px solid CanvasText}\n.lb-btn:focus-visible,.lb-select:focus-visible,.lb-input:focus-visible,\n.lb-link:focus-visible,.lb-link-out:focus-visible{outline:2px solid Highlight}\n.lb-link-out{opacity:1}}\n/* Each state carries a glyph as well as a hue: success and error were two\n pills with identical labels, indistinguishable to a red-green colour-blind\n reader and identical under forced colours. */\n.lb-btn[data-lb-state=loading]{opacity:.55;cursor:progress}\n.lb-btn[data-lb-state=loading]::after{content:"\\u2026";margin-inline-start:.35rem}\n/* An icon button has no room for a trailing glyph \\u2014 it would push the icon\n off-centre. Its states read through border and fill, which the forced-colors\n block below keeps distinguishable. */\n.lb-btn-icon::after{content:none!important}\n.lb-btn[data-lb-state=success]::after{content:"\\u2713";margin-inline-start:.35rem}\n.lb-btn[data-lb-state=error]::after{content:"\\u2715";margin-inline-start:.35rem}\n.lb-btn[data-lb-state=unavailable]::after{content:"\\u26A0";margin-inline-start:.35rem}\n/* With save-on-change there is no button to reflect the write, so the select\n itself must. Only error was styled before; loading and success were invisible\n on a select, leaving the rep unsure whether the pick had landed. */\n.lb-select[data-lb-state=loading]{opacity:.55;cursor:progress}\n.lb-select[data-lb-state=success]{border-color:var(--color-green-foreground)}\n.lb-msg[data-tone=error]::before{content:"\\u2715 "}\n.lb-msg[data-tone=ok]::before{content:"\\u2713 "}\n.lb-btn[data-lb-state=success]{background-color:var(--color-green-background);\nborder-color:var(--color-green-foreground);color:var(--color-green-foreground)}\n.lb-btn[data-lb-state=error],.lb-select[data-lb-state=error]{\nbackground-color:var(--color-red-background);border-color:var(--color-red-foreground);\ncolor:var(--color-red-foreground)}\n.lb-btn[data-lb-state=unavailable],.lb-btn[disabled]{opacity:.5;cursor:not-allowed}\n.lb-msg{font-size:0.8125rem;line-height:1.45;color:var(--lb-muted)}\n/* Screen-reader-only text: control labels, unit suffixes, "opens in a new tab". */\n.lb-vh{position:absolute;width:1px;height:1px;margin:-1px;padding:0;\noverflow:hidden;clip-path:inset(50%);white-space:nowrap;border:0}\n.lb-msg[data-tone=error]{color:var(--color-red-foreground)}\n.lb-msg[data-tone=ok]{color:var(--color-green-foreground)}\n.lb-chip{display:inline-flex;align-items:center;gap:.25rem;white-space:nowrap;\nfont-size:0.75rem;font-weight:600;line-height:1rem;padding:0.125rem 0.5rem;\nborder-radius:var(--lb-radius-sm);corner-shape:squircle;\nbackground-color:var(--lb-chip-bg);color:var(--lb-muted)}\n.lb-chip[data-status=WANTED]{background-color:var(--color-blue-background);color:var(--color-blue-foreground)}\n.lb-chip[data-status=WON]{background-color:var(--color-green-background);color:var(--color-green-foreground)}\n.lb-chip[data-status=LOST]{background-color:var(--color-red-background);color:var(--color-red-foreground)}\n.lb-chip[data-status=UNWANTED]{background-color:var(--lb-chip-bg);color:var(--lb-muted)}\n.lb-chip[data-taste=liked]{background-color:var(--color-cherry-background);color:var(--color-red-like)}\n.lb-chip[data-taste=disliked]{background-color:var(--lb-chip-bg);color:var(--lb-muted)}\n.lb-chips{display:flex;align-items:center;gap:0.5rem;flex-wrap:wrap}\n/* \\u2500\\u2500 Tag rows \\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\n Two kinds, deliberately different so they never read as the same axis.\n FIRMOGRAPHIC (.lb-tags-plain): what the company IS \\u2014 location, size, sector.\n Quiet grey, because it is context the rep skims.\n INTENT (.lb-tags-intent): what the qualifier FOUND \\u2014 a buying signal. Teal,\n matching the product\'s own taste-profile tags, because it is the reason the\n lead is on screen. Both wrap and both sit on the section\'s 8px. */\n.lb-tags-plain,.lb-tags-intent{display:flex;flex-wrap:wrap;gap:0.5rem}\n/* Both tags round at 0.375rem: they are the innermost surface, and a tag whose\n radius drifts from its neighbours reads as a different kind of object. Their\n own padding is small enough that concentric math against the card does not\n apply \\u2014 past a point the layers are separate surfaces, each chosen on its\n own. */\n.lb-tags-plain>*{display:inline-flex;align-items:center;\nbackground-color:var(--lb-surface);border:1px solid var(--lb-border);\ncolor:var(--lb-muted);border-radius:0.375rem;padding:0.125rem 0.375rem;\nfont-size:0.75rem;font-weight:500;line-height:1.5;white-space:nowrap}\n.lb-tags-intent>*{display:inline-flex;align-items:center;\nbackground-color:var(--color-teal-background);border:1px solid var(--color-teal-border);\ncolor:var(--color-teal-foreground);border-radius:0.375rem;padding:0.1875rem 0.4375rem;\nfont-size:0.75rem;line-height:1.4}\n/* The honest empty state: a blank tag row reads as a rendering bug, so say\n what is missing and what fixes it. */\n.lb-tags-empty{font-size:0.78125rem;line-height:1.45;color:var(--lb-muted);\nfont-style:italic}\n/* The chips row is empty until a taste or a status lands. Left in flow it is a\n zero-height element that still consumes the gap above the first section, so\n hide it while it holds nothing \\u2014 set the hidden attribute from the code that\n paints the chips, since it is that code which knows. */\n.lb-chips[hidden]{display:none}\n.lb-chip[hidden]{display:none}\n.lb-table{width:100%;border-collapse:collapse;font-family:var(--lb-font);color:var(--lb-fg)}\n.lb-table th,.lb-table td{text-align:start;padding:0.5rem 0.4rem;\nborder-bottom:1px solid var(--lb-border);vertical-align:middle;font-size:0.8125rem}\n.lb-table th{font-size:0.75rem;font-weight:600;color:var(--lb-muted);\ntext-transform:uppercase;letter-spacing:.04em}\n/* Counts line up only when the digits do; a leaderboard whose numbers jitter\n column-to-column cannot be scanned down. */\n.lb-table td[data-num],.lb-table th[data-num]{text-align:end;\nfont-variant-numeric:tabular-nums}\n/* A sortable header is a control: it must look clickable and say which way it\n is sorting, in text as well as by arrow \\u2014 aria-sort is the accessible half. */\n.lb-table th[aria-sort]{cursor:pointer;user-select:none;color:var(--lb-fg)}\n.lb-table th[aria-sort]:hover{text-decoration:underline}\n.lb-table th[aria-sort=ascending]::after{content:" \\\\2191"}\n.lb-table th[aria-sort=descending]::after{content:" \\\\2193"}\n.lb-table th[aria-sort=none]::after{content:" \\\\2195";opacity:.35}\n/* The row a manager is acting on. */\n.lb-table tbody tr[aria-selected=true]{background-color:var(--lb-chip-bg)}\n.lb-link{color:var(--color-blue-foreground);text-decoration:none}\n.lb-link:hover{text-decoration:underline}\n/* Quiet text link out of the artifact. Button-height so it shares the row\'s\n baseline; understated so it never competes with the actions beside it. The\n arrow is a bare diagonal stroke \\u2014 an escape-hatch marker, not an icon that\n asks to be read. */\n.lb-link-out{display:inline-flex;align-items:center;gap:.3rem;\nfont-size:0.75rem;line-height:1rem;min-height:2.125rem;\ncolor:var(--lb-fg);text-decoration:none;opacity:.65;transition:opacity .15s}\n.lb-link-out:hover{opacity:1;text-decoration:underline}\n.lb-link-out:focus-visible{outline:2px solid var(--color-blue-foreground);outline-offset:1px;\nborder-radius:var(--lb-radius-sm)}\n.lb-link-out svg{width:.85em;height:.85em;flex-shrink:0}\n/* Pushes whatever follows it to the right edge of an .lb-row, so a trailing\n link sits on the SAME baseline as the row\'s buttons instead of on its own\n line below them. */\n/* A spacer only aligns items on its OWN flex line. .lb-row wraps, so once the\n link falls to a second line the spacer stays behind and the link renders\n left-aligned \\u2014 the opposite of what the contract promises. The auto margin\n re-anchors it to the trailing edge of whichever line it lands on, and is a\n no-op while the row is unwrapped. */\n.lb-spacer{flex:1 1 auto;min-width:0}\n.lb-row>.lb-link-out{margin-inline-start:auto}\n.lb-spinner{display:inline-block;width:.7em;height:.7em;border:2px solid var(--lb-border);\nborder-top-color:var(--color-blue-foreground);border-radius:50%;animation:lb-spin .8s linear infinite}\n@keyframes lb-spin{to{transform:rotate(1turn)}}\n/* animation:none left a static ring \\u2014 under reduced motion "sending" and\n "cannot click" looked identical. An opacity pulse is the sanctioned\n substitute. The 150ms colour fade is fine and stays. */\n@media(prefers-reduced-motion:reduce){.lb-spinner{animation:lb-pulse 1.4s ease-in-out infinite}}\n@keyframes lb-pulse{50%{opacity:.35}}\n`,D="lb-styles";var Y="0.6.0",C=class extends Error{constructor(n,r={}){super(n);s(this,"code");s(this,"raw");this.name="LbError",this.code=r.code,this.raw=r.raw}},Z=null,O=3e4,J="Leadbay",X=J;function ee(){let e=globalThis.cowork;if(e&&typeof e.callMcpTool=="function")return(n,r)=>e.callMcpTool(n,r);let t=globalThis.claude;return t&&typeof t.use=="function"?async(n,r)=>{let o=await pe(t.use);if(!o||typeof o.callTool!="function")throw new C("Leadbay is not reachable from this page \\u2014 the artifact must declare the Leadbay connector in its capabilities, and the viewer must have it enabled.",{code:"unavailable"});let a=await o.callTool(X,n,r);return a&&"payload"in a&&a.payload!==void 0?a.payload:a}:null}var P=null;function pe(e){return P||(P=Promise.resolve(e("mcp")).catch(()=>null)),P}var te="leadbay_report_artifact_error",ye=40,F=new Set,$=0,B=!0;function we(e){B=e}function dt(){F.clear(),$=0,B=!0,A=null,N="call"}function T(e){if(!B||$>=ye)return;let t=`${e.kind}|${e.surface}|${e.tool??""}|${e.code??""}`;if(F.has(t))return;F.add(t),$++;let n=ee();if(n)try{let r=n(te,{kind:e.kind,surface:e.surface,kit_version:Y,...e.tool?{tool:e.tool}:{},...e.code?{code:e.code}:{}});r&&typeof r.catch=="function"&&r.catch(()=>{})}catch{}}var N="call",A=null;function q(e,t,n){let r=N,o=A;N=e,A=n??null;try{return t()}finally{N=r,A=o}}function ke(e,t,n){let r=ne(n);T({kind:r==="timeout"?"call_timeout":r==="unavailable"?"bridge_unavailable":"call_failed",surface:e,tool:t,code:r})}function Q(e){if(e&&typeof e=="object"&&"content"in e){let t=e.content;if(Array.isArray(t)&&t[0]&&typeof t[0].text=="string")return t[0].text}return null}function ve(e,t,n="call"){if(!e||typeof e!="object")return e;let r=e;if(r.isError)throw new C(Q(e)??"tool call failed",{raw:e});if("structuredContent"in r&&r.structuredContent!=null)return r.structuredContent;let o=Q(e);if(o!=null)try{return JSON.parse(o)}catch{return T({kind:"parse_failed",surface:n,tool:t}),o}return e}function G(e){if(e instanceof Error)return e.message;if(e&&typeof e=="object"){let t=e.message;if(typeof t=="string"&&t)return t;try{return JSON.stringify(e)}catch{}}return String(e)}function ne(e){if(e instanceof C)return e.code;if(e&&typeof e=="object"){let t=e.code;if(typeof t=="string"&&t)return t}}function z(e){let t=ne(e);return{message:G(e),unavailable:t==="unavailable",code:t}}function xe(e={}){Z=e.call??null,O=e.timeoutMs??3e4,X=e.server??J,P=null,H.clear()}function _e(){if(typeof document>"u"||!document.head)return null;let e=document.getElementById(D);if(e)return e;let t=document.createElement("style");return t.id=D,t.textContent=W,document.head.appendChild(t),t}async function Se(e,t){if(!O||O<=0)return e;let n,r=new Promise((o,a)=>{n=setTimeout(()=>a(new C(`"${t}" timed out after ${O}ms`,{code:"timeout"})),O)});try{return await Promise.race([e,r])}finally{n&&clearTimeout(n)}}async function m(e,t={}){let n=e===te,r=N;!n&&A&&A.tool===void 0&&(A.tool=e);let o=Z??ee();if(!o)throw n||T({kind:"bridge_unavailable",surface:r,tool:e,code:"unavailable"}),new C(\'Leadbay bridge unavailable \\u2014 no window.cowork and no window.claude.use("mcp")\',{code:"unavailable"});try{return ve(await Se(Promise.resolve(o(e,t)),e),n?void 0:e,r)}catch(a){throw n||ke(r,e,a),a}}var I=class{constructor(){s(this,"subs",new Set)}subscribe(t){return this.subs.add(t),t(this),()=>this.subs.delete(t)}emit(){for(let t of this.subs)t(this)}};function Te(e){return Array.isArray(e)?e.map(t=>t&&typeof t=="object"?t:{value:t,label:String(t)}):[]}var S=class extends I{constructor(n={}){super();s(this,"kind");s(this,"value");s(this,"options",[]);s(this,"loading",!1);s(this,"error",null);s(this,"ready",!1);s(this,"cfg");s(this,"depUnsubs",[]);s(this,"seq",0);this.cfg=n,this.kind=n.kind,this.value=n.value??"";for(let r of n.dependsOn??[]){let o=r.value;this.depUnsubs.push(r.subscribe(()=>{r.value!==o&&(o=r.value,this.cfg.load&&this.load())}))}n.load&&(n.autoLoad??!0)&&this.load()}async load(){if(!this.cfg.load)return;let n=++this.seq;this.loading=!0,this.error=null,this.emit();try{let r={},o=await q("field",()=>this.cfg.load(),r);if(n!==this.seq)return;this.options=this.cfg.options?this.cfg.options(o):Te(o),this.ready=!0,this.options.length===0&&T({kind:"options_empty",surface:"field",tool:r.tool});let a=this.value==null?"":String(this.value);this.options.length&&(a===""||!this.options.some(i=>String(i.value)===a))&&(this.value=this.options[0].value)}catch(r){if(n!==this.seq)return;this.options=[],this.error=z(r)}finally{n===this.seq&&(this.loading=!1,this.emit())}}setValue(n){this.value=n;let r=this.validate();this.error=r?{message:r,unavailable:!1}:null,this.emit()}validate(){return this.cfg.validate?this.cfg.validate(this.value):null}get valid(){return this.validate()==null}reset(){this.value=this.cfg.value??"",this.error=null,this.emit()}dispose(){for(let n of this.depUnsubs)n();this.depUnsubs=[]}};function Ee(e){if(!e||typeof e!="object")return null;let t=e;if(t.error!==!0)return null;let n=typeof t.message=="string"&&t.message?t.message:"tool call failed",r=typeof t.hint=="string"&&t.hint?` \\u2014 ${t.hint}`:"";return`${n}${r}`}var v=class extends I{constructor(n){super();s(this,"loading",!1);s(this,"error",null);s(this,"lastResult",null);s(this,"cfg");this.cfg=n}async run(){if(this.loading)return;for(let o of this.cfg.fields??[]){let a=o.validate();if(a!=null){this.error={message:a,unavailable:!1},this.emit(),T({kind:"action_blocked",surface:"action",tool:this.cfg.tool});return}}if(this.cfg.confirm){if(typeof globalThis.confirm!="function"){this.error={message:"This action needs confirmation, which this page cannot show.",unavailable:!1},this.emit(),T({kind:"action_blocked",surface:"action",tool:this.cfg.tool});return}if(!globalThis.confirm(this.cfg.confirm))return}this.loading=!0,this.error=null,this.emit();let n;try{let o=typeof this.cfg.args=="function"?this.cfg.args():this.cfg.args??{};n=await q("action",()=>m(this.cfg.tool,o))}catch(o){this.error=z(o),this.loading=!1,this.emit(),this.cfg.onError?.(this.error);return}let r=Ee(n)??this.cfg.checkResult?.(n)??null;if(r!=null){this.error={message:r,unavailable:!1},this.loading=!1,this.emit();let o=n&&typeof n=="object"?n.code:void 0;T({kind:"result_rejected",surface:"action",tool:this.cfg.tool,...typeof o=="string"&&o?{code:o}:{}}),this.cfg.onError?.(this.error);return}return this.lastResult=n,this.loading=!1,this.emit(),this.cfg.onSuccess?.(n),n}reset(){this.error=null,this.lastResult=null,this.emit()}},x=class extends I{constructor(n){super();s(this,"data",null);s(this,"loading",!1);s(this,"refreshing",!1);s(this,"error",null);s(this,"done",!1);s(this,"cfg");s(this,"timer",null);s(this,"seq",0);this.cfg=n,(n.autoLoad??!0)&&this.load()}async load(){this.clearTimer();let n=++this.seq;this.data==null?this.loading=!0:this.refreshing=!0,this.error=null,this.emit();try{let o=await q("resource",()=>this.cfg.load());if(n!==this.seq)return;this.data=o,this.done=this.cfg.until?this.cfg.until(o):!0,this.cfg.pollEvery&&!this.done&&(this.timer=setTimeout(()=>void this.load(),this.cfg.pollEvery))}catch(o){if(n!==this.seq)return;this.error=z(o)}finally{n===this.seq&&(this.loading=!1,this.refreshing=!1,this.emit())}}refresh(){return this.load()}stop(){this.clearTimer()}clearTimer(){this.timer&&(clearTimeout(this.timer),this.timer=null)}},R=class extends I{constructor(n){super();s(this,"items",[]);s(this,"page",0);s(this,"pageSize");s(this,"total",0);s(this,"loading",!1);s(this,"error",null);s(this,"cfg");s(this,"seq",0);this.cfg=n,this.pageSize=n.pageSize??20,(n.autoLoad??!0)&&this.loadPage(0)}async loadPage(n){let r=++this.seq;this.loading=!0,this.error=null,this.emit();try{let o=await q("list",()=>this.cfg.load({page:n,pageSize:this.pageSize}));if(r!==this.seq)return;this.items=o.items??[],this.total=o.total??this.items.length,this.page=n}catch(o){if(r!==this.seq)return;this.error=z(o)}finally{r===this.seq&&(this.loading=!1,this.emit())}}next(){return this.loadPage(this.page+1)}prev(){return this.loadPage(Math.max(0,this.page-1))}get hasMore(){return(this.page+1)*this.pageSize<this.total}};function Ae(e,t){let n=t.error?.unavailable?"unavailable":t.loading?"loading":t.error?"error":"ready";e.setAttribute("data-lb-state",n),t.error?e.setAttribute("data-lb-error",t.error.message):e.removeAttribute("data-lb-error")}function Ce(e,t){let n=()=>t.setValue(e.value);e.addEventListener("change",n);let r=t.subscribe(()=>{Ae(e,t),e.disabled=t.loading,e.innerHTML="";for(let o of t.options){let a=document.createElement("option");a.value=String(o.value),a.textContent=o.label,e.appendChild(a)}e.value=t.value==null?"":String(t.value)});return()=>{e.removeEventListener("change",n),r()}}function Le(e,t){let n=e.type==="checkbox",r=e.tagName==="SELECT"?"change":"input",o=()=>t.setValue(n?e.checked:e.value);e.addEventListener(r,o);let a=t.subscribe(()=>{if(n)e.checked=!!t.value;else{let i=t.value==null?"":String(t.value);e.value!==i&&(e.value=i)}e.setAttribute("data-lb-state",t.error?"error":"ready"),t.error?e.setAttribute("data-lb-error",t.error.message):e.removeAttribute("data-lb-error")});return()=>{e.removeEventListener(r,o),a()}}function Ie(e,t){let n=a=>{a.preventDefault(),t.run()};e.addEventListener("click",n);let r,o=t.subscribe(()=>{let a=t.error?.unavailable?"unavailable":t.loading?"loading":t.error?"error":t.lastResult!=null?"success":"ready";e.setAttribute("data-lb-state",a),"disabled"in e&&(e.disabled=t.loading),e.setAttribute("aria-busy",String(t.loading)),t.error?e.setAttribute("data-lb-error",t.error.message):e.removeAttribute("data-lb-error"),r&&clearTimeout(r),a==="success"&&(r=setTimeout(()=>{!t.loading&&!t.error&&e.setAttribute("data-lb-state","ready")},1600))});return()=>{e.removeEventListener("click",n),o(),r&&clearTimeout(r)}}var j=["STILL_CHASING","COULD_NOT_REACH_STILL_TRYING","INTEREST_VALIDATED_OR_MEETING_PLANED","NOT_INTERESTED_LOST"],U=[{value:"",label:"Default ranking"},{value:"SCORE:DESC",label:"Score \\u2193"},{value:"SCORE:ASC",label:"Score \\u2191"},{value:"NAME:ASC",label:"Name A\\u2192Z"},{value:"NAME:DESC",label:"Name Z\\u2192A"},{value:"SIZE:DESC",label:"Size \\u2193"},{value:"SIZE:ASC",label:"Size \\u2191"},{value:"SECTOR:ASC",label:"Sector A\\u2192Z"},{value:"STATUS:ASC",label:"Status A\\u2192Z"},{value:"CONTACT_COUNT:DESC",label:"Contacts \\u2193"},{value:"LAST_PROSPECTING_ACTION_AT:DESC",label:"Last action \\u2193"},{value:"LAST_PROSPECTING_ACTION_AT:ASC",label:"Last action \\u2191"},{value:"EPILOGUE_STATUS_SET_AT:DESC",label:"Outcome set \\u2193"},{value:"LIKED:DESC",label:"Liked first"},{value:"DISLIKED:DESC",label:"Disliked first"}];function Re(e){let t=String(e??"").trim().toUpperCase(),n=U.some(r=>r.value===t);return new S({kind:"select",value:n?t:"",load:async()=>U.slice()})}var M=[{value:"WANTED",label:"Wanted"},{value:"WON",label:"Won"},{value:"LOST",label:"Lost"},{value:"UNWANTED",label:"Unwanted"}],Oe={value:"",label:"Pick a status"};function re(e){let t=String(e??"").trim().toUpperCase(),n=M.some(r=>r.value===t);return new S({kind:"select",value:n?t:"",validate:r=>String(r??"")===""?"Pick a status":null,load:async()=>n?M.slice():[Oe,...M]})}function oe(e){let t=()=>{let n=typeof e.leadIds=="function"?e.leadIds():e.leadIds;return Array.isArray(n)?n:e.leadId?[e.leadId]:[]};return new v({tool:"leadbay_set_lead_status",fields:e.date?[e.status,e.date]:[e.status],confirm:e.confirm,args:()=>({lead_ids:t(),status:e.status.value,...e.date&&e.date.value?{status_date:e.date.value}:{},...e.ask?{_triggered_by:e.ask}:{}}),checkResult:n=>{let r=n?.failed;if(!Array.isArray(r)||r.length===0)return null;let o=t().length,a=r[0]?.message??"write rejected";return r.length===o?`Status not applied: ${a}`:`${r.length} of ${o} leads failed: ${a}`}})}function Ne(e){return new S({kind:"select",load:()=>m("leadbay_list_campaigns",{_triggered_by:e}),options:t=>(t?.campaigns??[]).map(r=>{let o=r?.campaign??r;return o?.id?{value:o.id,label:o.name??o.ai_generated_name??String(o.id)}:null}).filter(r=>r!=null)})}function ae(e){return new v({tool:"leadbay_report_outreach",fields:e.note?[e.note]:[],args:()=>({lead_id:e.leadId,...e.status?{epilogue_status:e.status.value}:{},note:e.note?e.note.value:"",verification:{source:"user_confirmed",ref:e.ref??"logged from artifact"},_triggered_by:e.ask})})}function Pe(e){return new v({tool:"leadbay_add_note",fields:[e.note],args:()=>({leadId:e.leadId,note:e.note.value})})}function ie(e){return new v({tool:"leadbay_like_lead",args:{lead_id:e}})}function le(e){return new v({tool:"leadbay_dislike_lead",args:{lead_id:e}})}function se(e){let t=e??{},n=t.ai_agent_lead_score;if(typeof n=="number"&&n>0)return"Requalify";let r=t.qualification_summary?.answered;return typeof r=="number"&&r>0?"Requalify":"Qualify"}function ce(e){let t=()=>{let o=typeof e.leadIds=="function"?e.leadIds():e.leadIds;return Array.isArray(o)?o:e.leadId?[e.leadId]:[]},n=0,r=new v({tool:"leadbay_bulk_qualify_leads",confirm:e.confirm,args:()=>{let o=t();return n=o.length,{leadIds:o,wait_for_completion:!1,...e.ask?{_triggered_by:e.ask}:{}}},checkResult:o=>{let a=o??{},i=Array.isArray(a.failed)?a.failed:[],l=n;if(i.length>0){let c=i[0]?.error??"launch rejected";return i.length>=l?`Qualification did not start: ${c}`:`${i.length} of ${l} leads did not start: ${c}`}if(a.quota_exceeded){let c=a.launched_count??0;return c>0?`Quota reached \\u2014 ${c} of ${l} launched, the rest were not started.`:"Quota reached \\u2014 no leads were queued."}return null}});return Object.defineProperty(r,"label",{enumerable:!0,get:()=>(typeof e.scored=="function"?e.scored():e.scored)?"Requalify":"Qualify"}),r}function Me(e,t,n=15e3){return new x({pollEvery:n,load:()=>m("leadbay_qualify_status",{...e?.notification_id?{notification_id:e.notification_id}:{},...e?.lead_ids?.length?{lead_ids:e.lead_ids}:{},...typeof e?.lens_id=="number"?{lens_id:e.lens_id}:{},...t?{_triggered_by:t}:{}}),until:r=>{let o=r??{};return Array.isArray(o.still_running)?o.still_running.length===0:o.status!=null&&o.status!=="running"}})}function qe(e,t){return new x({autoLoad:!1,load:()=>m("leadbay_account_history",{leadId:e,_triggered_by:t})})}function ze(e,t){return new x({autoLoad:!1,load:()=>m("leadbay_research_lead_by_id",{leadId:e,_triggered_by:t})})}var H=new Map;function de(e={}){let t=e.lang??"",n=H.get(t);return n||(n=(async()=>{try{let r=await m("leadbay_list_sectors",{...e.lang?{lang:e.lang}:{}}),o=Array.isArray(r)?r:Array.isArray(r?.sectors)?r.sectors:[],a={};for(let i of o){let l=i??{};l.id!=null&&typeof l.label=="string"&&l.label&&(a[String(l.id)]=l.label)}return a}catch{return{}}})(),H.set(t,n)),n}function ue(e,t={}){let n=e??{},r=i=>typeof i=="string"&&i.trim()&&i.trim()!=="null"?i.trim():void 0,o=n.sector_id!=null?t[String(n.sector_id)]:void 0,a=Array.isArray(n.phone_numbers)?n.phone_numbers:[];return{summary:r(n.short_description)??r(n.description)??o,sector:o,phone:a.map(r).find(Boolean),email:r(n.email)}}function De(e){let t=()=>{let a=e.kind;return typeof a=="string"?a:typeof a=="function"?a():String(a?.value??"followups")||"followups"},n=()=>{let a=e.campaignId,i=typeof a=="string"?a:a?.value;return i?String(i):void 0},r=()=>typeof e.order=="string"?e.order:String(e.order?.value??""),o=new R({pageSize:e.pageSize??20,load:async({page:a,pageSize:i})=>{let l=t(),c;if(l==="campaign"){let h=n();if(!h)return{items:[],total:0};c=await m("leadbay_campaign_call_sheet",{campaign_id:h,page:a,count:i,_triggered_by:e.ask})}else l==="discover"?c=await m("leadbay_pull_leads",{page:a,count:i,...e.lensId?{lensId:e.lensId}:{},...r()?{order:r()}:{},_triggered_by:e.ask}):c=await m("leadbay_pull_followups",{page:a,count:i,...e.city?{city:e.city}:{},...r()?{order:r()}:{},_triggered_by:e.ask});let g=c??{},p=g.leads??g.items??[];return{items:p,total:g.pagination?.total??g.total_leads??p.length}}});return Object.assign(o,{leadUrl:a=>{let i=a??{},l=encodeURIComponent(String(i.id??"")),c=t();if(c==="campaign"){let p=n();if(p)return`https://leadbay.app/app/campaign?campaign=${encodeURIComponent(p)}&lead=${l}`}return`https://leadbay.app/app/${c==="discover"||i.in_monitor===!1?"discover":"monitor"}?lead=${l}`}})}function Fe(e){let t=e?.location?.pos;if(!Array.isArray(t)||t.length<2)return null;let[n,r]=t;return typeof n!="number"||typeof r!="number"||!Number.isFinite(n)||!Number.isFinite(r)||n===0&&r===0||n<-90||n>90||r<-180||r>180?null:[n,r]}function K(e,t){let n=Math.PI/180,r=(t[0]-e[0])*n,o=(t[1]-e[1])*n,a=Math.sin(r/2)**2+Math.cos(e[0]*n)*Math.cos(t[0]*n)*Math.sin(o/2)**2;return 6371*2*Math.atan2(Math.sqrt(a),Math.sqrt(1-a))}var he=11;function $e(e){let t=e.filter(a=>Array.isArray(a?.pos)&&a.pos.length===2);if(t.length===0)return null;let n=t.slice(0,he),r=n.map(a=>a.pos.join(",")),o=new URLSearchParams({api:"1",travelmode:"driving"});return r.length===1?o.set("destination",r[0]):(o.set("origin",r[0]),o.set("destination",r[r.length-1]),r.length>2&&o.set("waypoints",r.slice(1,-1).join("|"))),{url:`https://www.google.com/maps/dir/?${o.toString()}`,used:n.length,truncated:t.length-n.length}}function je(e){let t=0;for(let n=1;n<e.length;n++)t+=K(e[n-1].pos,e[n].pos);return t}function Ue(e,t){let n=e.slice();if(n.length<=2)return n;let r=[],o=t??n[0].pos;for(;n.length;){let a=0,i=1/0;for(let c=0;c<n.length;c++){let g=K(o,n[c].pos);g<i&&(i=g,a=c)}let[l]=n.splice(a,1);r.push(l),o=l.pos}return r}function He(e){let t=re(e.currentStatus),n=new S({kind:"select",value:j[0],load:async()=>j.map(i=>({value:i,label:be[i]??i}))}),r=new S({validate:i=>String(i??"").trim()?null:"Add a note before logging"}),o=new x({autoLoad:!1,load:async()=>{let i=await m("leadbay_research_lead_by_id",{leadId:e.leadId,_triggered_by:e.ask}),l=Array.isArray(i?.contacts?.reachable)?i.contacts.reachable:[],c=Array.isArray(i?.contacts?.candidates)?i.contacts.candidates:[],g=new Set,p=[];for(let u of[...l,...c]){let d=u?.id??u?.contact_id;d&&g.has(d)||(d&&g.add(d),p.push(u))}let h=i?.engagement?.recommended_contact?.contact_id;return p.map(u=>Ge(u,h))}}),a=new x({load:async()=>ue(e.lead,await de())});return{leadId:e.leadId,contacts:o,context:a,status:t,saveStatus:oe({leadId:e.leadId,status:t,ask:e.ask}),epilogue:n,note:r,logOutreach:ae({leadId:e.leadId,ask:e.ask,status:n,note:r}),like:ie(e.leadId),dislike:le(e.leadId),qualify:ce({leadId:e.leadId,ask:e.ask,scored:se(e.lead)==="Requalify"})}}function Be(e){let t=r=>typeof r=="function"?r():r!==!1,n=new S({value:"ok",validate:()=>t(e.email)||t(e.phone)?null:"Pick email, phone, or both."});return new v({tool:"leadbay_enrich_contacts",fields:[n],confirm:e.confirm===""?void 0:e.confirm??"Reveal this contact? It uses enrichment quota.",args:()=>({leadId:e.leadId,contactId:e.contactId,email:t(e.email),phone:t(e.phone),...e.ask?{_triggered_by:e.ask}:{}}),checkResult:r=>{let o=r??{};return o.error?o.message??"Enrichment failed":null},onSuccess:()=>e.onDone?.()})}var be={STILL_CHASING:"Still chasing",COULD_NOT_REACH_STILL_TRYING:"Could not reach \\u2014 still trying",INTEREST_VALIDATED_OR_MEETING_PLANED:"Interested / meeting planned",NOT_INTERESTED_LOST:"Not interested \\u2014 lost"};function Ge(e,t){let n=e??{},r=i=>typeof i=="string"&&i.trim()&&i!=="null"?i.trim():void 0,o=[r(n.first_name),r(n.last_name)].filter(Boolean).join(" ")||r(n.name)||"Unnamed contact",a=r(n.id)??r(n.contact_id);return{contactId:a,name:o,title:r(n.job_title)??r(n.title),email:r(n.email),phone:r(n.phone)??r(n.phone_number)??r((n.phone_numbers??[])[0]),linkedin:r(n.linkedin_page),recommended:n.recommended===!0||t!=null&&a===t,enriched:n.enrichment_done===!0}}function Ke(e){let t=null;return new x({...e.autoLoad!==void 0?{autoLoad:e.autoLoad}:{},pollEvery:e.pollEvery??4e3,until:n=>!!n?.all_done,load:async()=>{if(!t){let n=await m("leadbay_enrich_titles",{...e.leadIds?{leadIds:e.leadIds}:{},titles:e.titles,...e.email!==void 0?{email:e.email}:{},...e.phone!==void 0?{phone:e.phone}:{},...e.confirm!==void 0?{confirm:e.confirm}:{},_triggered_by:e.ask}),r=Array.isArray(n?.lead_ids)?n.lead_ids:[],o=n?.notification_id??null;if(t=o||r.length>0?{notification_id:o,lead_ids:r}:null,!t)return{...n,all_done:!0,no_job:!0}}return m("leadbay_bulk_enrich_status",{...t.notification_id?{notification_id:t.notification_id}:{},...t.lead_ids.length>0?{lead_ids:t.lead_ids}:{},...e.titles?{titles:e.titles}:{},...e.email!==void 0?{email:e.email}:{},...e.phone!==void 0?{phone:e.phone}:{},_triggered_by:e.ask})}})}function Ve(e){let t=()=>typeof e.order=="string"?e.order:String(e.order?.value??"");return new R({pageSize:e.pageSize??20,load:async({page:n,pageSize:r})=>{let o=await m("leadbay_pull_leads",{page:n,count:r,...e.lensId?{lensId:e.lensId}:{},...t()?{order:t()}:{},_triggered_by:e.ask}),a=o.leads??[];return{items:a,total:o.pagination?.total??a.length}}})}function We(e){let t=e.source??"followups",n=()=>typeof e.order=="string"?e.order:String(e.order?.value??"");return new R({pageSize:e.pageSize??20,load:async({page:r,pageSize:o})=>{let i=t==="campaign"?await m("leadbay_campaign_call_sheet",{campaign_id:e.campaignId,page:r,count:o,_triggered_by:e.ask}):await m("leadbay_pull_followups",{page:r,count:o,...e.city?{city:e.city}:{},...n()?{order:n()}:{},_triggered_by:e.ask}),l=i.leads??i.items??[];return{items:l,total:i.total_leads??i.pagination?.total??l.length}}})}async function Qe(e){let t=await m("leadbay_pull_followups",{count:e.sample??200,filtered:!1,...e.personal===void 0?{}:{personal:e.personal},_triggered_by:e.ask}),n=new Map;for(let o of t.leads??[]){let a=o?.sector_id;if(a==null||a==="")continue;let i=String(a);n.set(i,(n.get(i)??0)+1)}let r=e.sectors??{};return[...n.entries()].map(([o,a])=>{let i=r[o];return{id:o,label:i??`Sector ${o}`,sampled:a,resolved:i!=null}}).sort((o,a)=>a.sampled-o.sampled||o.id.localeCompare(a.id))}async function V(e){let t=await m("leadbay_pull_followups",{count:1,set_filter:{criteria:e.criteria},...e.city?{city:e.city}:{},...e.cityId?{city_id:e.cityId}:{},...e.personal===void 0?{}:{personal:e.personal},_triggered_by:e.ask}),n=t.active_filters?.criteria??[],r=h=>typeof h?.type=="string"?h.type:null,o=new Set;for(let h of e.criteria){let u=r(h);u&&o.add(u)}(e.city||e.cityId)&&o.add("location_ids");let a=new Set(n.map(r).filter(h=>h!=null)),i=[...o].every(h=>a.has(h)),l=[...a].every(h=>o.has(h)),c=new Set(["location_ids"]),g=(h,u)=>h.filter(d=>r(d)===u).map(d=>JSON.stringify(Object.keys(d).filter(b=>b!=="type").sort().map(b=>[b,d[b]]))).sort().join("|"),p=[...o].every(h=>c.has(h)||g(e.criteria,h)===g(n,h));return{total:k(t.pagination?.total),applied:n,trusted:i&&l&&p}}async function Ye(e){let t=[];return e.sectorIds?.length&&t.push({type:"sector_ids",sectors:e.sectorIds,is_excluded:!1}),V({criteria:t,personal:e.personal,ask:e.ask,city:e.city,cityId:e.cityId})}async function Ze(e){let t=[],n=e.buckets.length;for(let r of e.buckets){let o;try{let a=await V({criteria:r.criterion?[r.criterion]:[],personal:e.personal,ask:e.ask});o={...r,total:a.total,trusted:a.trusted}}catch(a){o={...r,total:0,trusted:!1,error:G(a)}}t.push(o),e.onProgress?.(t.length,n,o)}return t}async function Je(e){try{let t=await V({criteria:[],personal:e.personal,ask:e.ask});return{id:"__all__",label:"Whole book",total:t.total,trusted:t.trusted}}catch(t){return{id:"__all__",label:"Whole book",total:0,trusted:!1,error:G(t)}}}async function Xe(e){let t=await m("leadbay_pull_followups",{count:e.sample??200,filtered:!1,...e.personal===void 0?{}:{personal:e.personal},_triggered_by:e.ask}),n=new Map;for(let a of t.leads??[]){let i=a?.[e.field];if(i==null||i===""||i==="null")continue;let l=String(i);n.set(l,(n.get(l)??0)+1)}let r=e.labels??{},o=[...n.entries()].map(([a,i])=>({id:a,label:r[a]??`${e.field} ${a}`,sampled:i,...e.criterion?{criterion:e.criterion(a)}:{}})).sort((a,i)=>i.sampled-a.sampled||a.id.localeCompare(i.id));return e.limit!=null?o.slice(0,e.limit):o}function ge(e){let t=e??{},n=l=>typeof l=="string"&&l&&l!=="null"?l:null,r=Array.isArray(t.phone_numbers)?t.phone_numbers:[],o=t.has_phone===!0||r.some(l=>n(l)!=null),a=n(t.email)!=null;return o||a?"reachable":k(t.contacts_count)+k(t.org_contacts_count)>0?"contacts_only":"empty"}async function et(e){let t=e.sample??200,n=await m("leadbay_pull_followups",{count:t,filtered:!1,...e.personal===void 0?{}:{personal:e.personal},_triggered_by:e.ask}),r=Array.isArray(n.leads)?n.leads:[],o={reachable:0,contacts_only:0,empty:0};for(let i of r)o[ge(i)]++;let a=[["reachable","Callable now"],["contacts_only","Contacts, no channel"],["empty","No contacts"]];return{sampled:r.length,bookTotal:k(n.pagination?.total),rows:a.map(([i,l])=>({id:i,label:l,total:o[i],trusted:!0,sampled:o[i]}))}}function tt(e){return new x({load:()=>m("leadbay_team_activity",{weeks:e.weeks??4,_triggered_by:e.ask})})}var nt="http://www.w3.org/2000/svg",E=(e,t)=>{let n=document.createElementNS(nt,e);for(let[r,o]of Object.entries(t))n.setAttribute(r,String(o));return n};function k(e){return typeof e=="number"&&isFinite(e)?e:0}function rt(e,t={}){let n=Array.isArray(e)?e:[];if(n.length===0){let f=document.createElement("div");f.className="lb-empty";let w=document.createElement("div");if(w.className="lb-empty-title",w.textContent=t.emptyTitle??"Nothing in this window",f.appendChild(w),t.emptyHint){let L=document.createElement("div");L.className="lb-empty-hint",L.textContent=t.emptyHint,f.appendChild(L)}return f}let r=t.width??640,o=t.height??160,a=40,i=8,l=12,c=26,g=r-a-i,p=o-l-c,h=Math.max(1,...n.map(f=>k(f.count))),u=f=>n.length===1?a+g/2:a+f/(n.length-1)*g,d=f=>l+p-k(f)/h*p,b=E("svg",{class:"lb-chart",viewBox:`0 0 ${r} ${o}`,preserveAspectRatio:"none",role:"img","aria-label":t.label??`Trend across ${n.length} points, peak ${h}`});for(let f of[0,h]){b.appendChild(E("line",{class:"lb-chart-grid",x1:a,x2:r-i,y1:d(f),y2:d(f)}));let w=E("text",{x:a-6,y:d(f)+4,"text-anchor":"end"});w.textContent=String(f),b.appendChild(w)}let y=n.map((f,w)=>`${u(w)},${d(f.count)}`);b.appendChild(E("path",{class:"lb-chart-area",d:`M${u(0)},${d(0)} L${y.join(" L")} L${u(n.length-1)},${d(0)} Z`})),b.appendChild(E("path",{class:"lb-chart-line",d:`M${y.join(" L")}`})),n.forEach((f,w)=>{b.appendChild(E("circle",{class:"lb-chart-dot",cx:u(w),cy:d(f.count),r:3}))});let _=n.length===1?[[0,"middle"]]:[[0,"start"],[n.length-1,"end"]];for(let[f,w]of _){let L=E("text",{x:u(f),y:o-8,"text-anchor":w});L.textContent=String(n[f].date??"").slice(0,10),b.appendChild(L)}return b}function ot(e){let t=document.createElement("div");t.className="lb-tiles";for(let n of e){let r=document.createElement("div");r.className="lb-tile";let o=document.createElement("span");o.className="lb-tile-label",o.textContent=n.label;let a=document.createElement("span");a.className="lb-tile-value",a.textContent=String(n.value),r.append(o,a),t.appendChild(r)}return t}function at(e){let{rows:t,columns:n}=e;if(!t||t.length===0){let c=document.createElement("div");c.className="lb-empty";let g=document.createElement("div");if(g.className="lb-empty-title",g.textContent=e.emptyTitle??"Nothing to show",c.appendChild(g),e.emptyHint){let p=document.createElement("div");p.className="lb-empty-hint",p.textContent=e.emptyHint,c.appendChild(p)}return c}let r=e.sortKey??n[0].key,o=e.sortDir??"descending",a=document.createElement("table");a.className="lb-table";let i=()=>{a.textContent="";let c=document.createElement("thead"),g=document.createElement("tr");for(let u of n){let d=document.createElement("th");d.textContent=u.label,u.num&&d.setAttribute("data-num",""),d.setAttribute("aria-sort",u.key===r?o:"none"),d.setAttribute("tabindex","0"),d.setAttribute("role","button");let b=()=>{r===u.key?o=o==="ascending"?"descending":"ascending":(r=u.key,o=u.num?"descending":"ascending"),i(),e.onSort?.(r,o)};d.addEventListener("click",b),d.addEventListener("keydown",y=>{(y.key==="Enter"||y.key===" ")&&(y.preventDefault(),b())}),g.appendChild(d)}c.appendChild(g),a.appendChild(c);let p=t.slice().sort((u,d)=>{let b=u[r],y=d[r];if(typeof b=="string"||typeof y=="string"){let _=String(b??"").localeCompare(String(y??""));return o==="ascending"?_:-_}return o==="ascending"?k(b)-k(y):k(y)-k(b)}),h=document.createElement("tbody");for(let u of p){let d=document.createElement("tr");d.setAttribute("aria-selected","false");for(let b of n){let y=document.createElement("td");b.num&&y.setAttribute("data-num","");let _=b.cell?.(u);_==null?y.textContent=b.num?String(k(u[b.key])):String(u[b.key]??"\\u2014"):typeof _=="string"?y.textContent=_:y.appendChild(_),d.appendChild(y)}h.appendChild(d)}a.appendChild(h)};i();let l=document.createElement("div");return l.style.overflowX="auto",l.appendChild(a),l}var it={VERSION:Y,configure:xe,styles:_e,call:m,report:T,setTelemetry:we,field:e=>new S(e),action:e=>new v(e),resource:e=>new x(e),list:e=>new R(e),bindSelect:Ce,bindValue:Le,bindAction:Ie,sparkline:rt,tiles:ot,leaderboard:at,campaigns:Ne,segmentCount:Ye,portfolioSectors:Qe,coverage:Ze,coverageBuckets:Xe,coverageTotal:Je,reachCoverage:et,leadReach:ge,outreach:ae,note:Pe,like:ie,dislike:le,qualify:ce,qualifyLabel:se,qualifyStatus:Me,leadStatus:re,setStatus:oe,relanceRow:He,enrichContact:Be,sectorLabels:de,leadContext:ue,leadPos:Fe,distanceKm:K,routeUrl:$e,routeDistanceKm:je,orderByProximity:Ue,GOOGLE_MAPS_STOP_LIMIT:he,EPILOGUE_LABELS:be,sortOrder:Re,leadHistory:qe,leadProfile:ze,enrichment:Ke,callList:We,leadList:Ve,leadSource:De,teamActivity:tt,EPILOGUE_STATUSES:j,LEAD_STATUSES:M,SORT_ORDERS:U};typeof globalThis<"u"&&(globalThis.LeadbayArtifacts=it);})();';
29534
+ var ARTIFACT_USAGE_GUIDE = '# Leadbay Artifact Kit \u2014 headless domain components\n\nYou are building a single-file HTML **artifact** the user runs inside cowork. This\nkit gives you **headless view-models** that own a control\'s whole data lifecycle \u2014\nload/populate from a Leadbay call, hold value/state, poll, validate, and\nencapsulate the API call + business rules. **You own 100% of markup/layout/style.**\nThe library renders nothing. Inline the runtime once as a `<script>`; it exposes\none global `window.LeadbayArtifacts` (call it `lb`). Vanilla, no React, no build.\n\nPass every tool you use as the artifact\'s `mcp_tools` so the host permits it.\n\n## Two layers\n\n**Primitives** (generic):\n- `lb.field({ load, options, value, validate, dependsOn })` \u2014 a value + optionally\n API-populated options. `.value/.setValue/.options/.loading/.error/.valid/.subscribe`.\n- `lb.action({ tool, args, fields, confirm, onSuccess, onError })` \u2014 a write/submit.\n `.run()/.loading/.error/.lastResult/.subscribe`.\n- `lb.resource({ load, pollEvery?, until?, autoLoad? })` \u2014 one read that may change:\n load-on-click or poll-until-`until`. `.data/.loading/.refreshing/.error/.done/.load()/.refresh()/.stop()/.subscribe`.\n- `lb.list({ load, pageSize })` \u2014 paginated rows. `.items/.page/.total/.loading/.loadPage(n)/.next()/.prev()/.hasMore/.subscribe`.\n\n`.error` is `{ message, unavailable } | null`. `subscribe(cb)` fires immediately\nthen on every change \u2014 render your own DOM from it.\n\n**Domain components** (pre-wired \u2014 bake in the tool name, arg shape, and footguns):\n\n| Call | Returns | For |\n|---|---|---|\n| `lb.campaigns(ask)` | field | a campaign `<select>`, options from `leadbay_list_campaigns` |\n| `lb.segmentCount({sectorIds, city, ask})` | `Promise<{total, applied, trusted}>` | how many Monitor leads match one sector/location \u2014 `count: 1` + `pagination.total`, so a segment costs one cheap call |\n| `lb.portfolioSectors({sample, sectors, ask})` | `Promise<PortfolioSector[]>` | which sectors the user ACTUALLY holds \u2014 samples one page of followups, tallies `sector_id`, resolves names against an embedded taxonomy |\n| `lb.coverage({buckets, ask, onProgress?})` | `Promise<CoverageRow[]>` | sweep a WHOLE dimension (sector, size, recency, liked, custom field) \u2014 sequential, complete criteria per call, `trusted` per bucket |\n| `lb.coverageBuckets({field, labels?, criterion?, limit?, ask})` | `Promise<CoverageBucket[]>` | derive a dimension\'s values from the book by tallying a lead field \u2014 never hardcode the list |\n| `lb.coverageTotal({ask, personal?})` | `Promise<CoverageRow>` | the unfiltered whole-book denominator |\n| `lb.reachCoverage({sample?, personal?, ask})` | `Promise<ReachCoverage>` | callable / contacts-only / empty \u2014 the segment that says what to ENRICH. Sampled (not a filter), so rows are an estimate against `bookTotal` |\n| `lb.leadReach(lead)` | `"reachable"` \\| `"contacts_only"` \\| `"empty"` | one lead\'s reachability \u2014 `contacts_count > 0` is NOT a channel |\n| `lb.outreach({leadId, ask, status?, note?})` | action | log a call \u2192 `report_outreach` (verification + `_triggered_by` baked in). **NOT from a page\'s button** \u2014 it waits 60s on a confirmation prompt a page cannot show; see *Writing from a page* below |\n| `lb.note({leadId, note})` | action | add a note \u2192 `add_note` |\n| `lb.like(leadId)` / `lb.dislike(leadId)` | action | taste signal |\n| `lb.qualify({leadId or leadIds, ask, scored?})` | action | the MANDATORY Qualify/Requalify button \u2192 `bulk_qualify_leads` (camelCase `leadIds`, queue-not-wait, `failed[]` + quota checked) |\n| `lb.qualifyLabel(lead)` | `"Qualify"` \\| `"Requalify"` | which word the button takes, from the lead\'s own score |\n| `lb.qualifyStatus(launch, ask?)` | resource (polling) | watches a launch to its verdict \u2192 `leadbay_qualify_status` |\n| `lb.relanceRow({leadId, ask, currentStatus?})` | row bundle | ONE follow-up table row: lazy `contacts` (with email/phone), `status` + `saveStatus`, `epilogue` + `note` + `logOutreach` |\n| `lb.enrichContact({leadId, contactId, email?, phone?, ask?, onDone?})` | action | buy ONE contact\'s email / phone / both \u2192 `enrich_contacts`. Confirms the spend; the channel may land on a DIFFERENT contact, so `onDone` re-reads |\n| `lb.sectorLabels()` | `Promise<{id: label}>` | the sector taxonomy, fetched ONCE per page and cached \u2014 so no artifact inlines ~1,091 rows or prints a raw id |\n| `lb.leadContext(lead, labels)` | `LeadContext` | one lead\'s company line: `summary` (short_description \u2192 description \u2192 sector), `sector`, and the COMPANY `phone` / `email` |\n| `lb.EPILOGUE_LABELS` | `Record<string,string>` | the four epilogue values in the rep\'s words, for the select |\n| `lb.leadStatus(current?)` | field | a status `<select>` (Wanted/Won/Lost/Unwanted) |\n| `lb.setStatus({leadId or leadIds, status, date?, ask})` | action | write the org CRM status \u2192 `set_lead_status` |\n| `lb.leadHistory(leadId, ask)` | resource (lazy) | notes + activities + engagement \u2192 `account_history` |\n| `lb.leadProfile(leadId, ask)` | resource (lazy) | full lead profile \u2192 `research_lead_by_id` |\n| `lb.sortOrder(current?)` | field | a sort `<select>` mirroring the app\'s TableSort |\n| `lb.leadList({lensId?, order?, ask})` | list | a sortable Discover batch \u2192 `pull_leads` |\n| `lb.callList({source:\'followups\'\\|\'campaign\', campaignId?, city?, ask})` | list | a cold-call list (Monitor or a campaign) |\n| `lb.leadSource({kind, campaignId?, lensId?, order?, ask})` | list + `.leadUrl(lead)` | ONE list over any source \u2014 Monitor / Discover lens / campaign \u2014 with the per-source deep link and the campaign\'s no-sort rule built in |\n| `lb.leadPos(lead)` | `[lat,lng]` \\| `null` | a lead\'s coordinates, rejecting the `0,0` sentinel and out-of-range values |\n| `lb.distanceKm(a, b)` | number | great-circle km \u2014 never use flat geometry, it mis-orders east-west legs |\n| `lb.orderByProximity(stops, start?)` | stops | nearest-neighbour ordering, so the default route is not the API\'s arbitrary one |\n| `lb.routeUrl(stops)` | `{url, used, truncated}` \\| `null` | a Google Maps driving link; `truncated` counts the stops past its 11-stop cap |\n| `lb.routeDistanceKm(stops)` | number | total straight-line km in driving order \u2014 label it "as the crow flies" |\n| `lb.enrichment({leadIds, titles, ask, pollEvery?})` | resource (polling) | launch + watch contact enrichment |\n| `lb.teamActivity({weeks, ask})` | resource | manager leaderboard + activity trend \u2192 `leadbay_team_activity` |\n\n`lb.EPILOGUE_STATUSES` = the 4 disposition values\n(`STILL_CHASING`, `COULD_NOT_REACH_STILL_TRYING`, `INTEREST_VALIDATED_OR_MEETING_PLANED`, `NOT_INTERESTED_LOST`).\n`lb.LEAD_STATUSES` = the 4 org CRM statuses as `{value,label}` (`WANTED`, `WON`, `LOST`, `UNWANTED`).\n`lb.SORT_ORDERS` = the sort options as `{value,label}`; values are the backend `FIELD:ASC|DESC` enum.\n\n**Sorting is a SERVER concern.** `lb.leadList` and `lb.callList` take an `order`\n(a `lb.sortOrder()` field or a literal) and send it upstream; the backend sorts\nthe whole lens / Monitor and returns the requested page of that. Never re-sort\nrows in the browser \u2014 you would be reordering one page of a larger set, showing\nleads that do not belong at that position. The empty value means "no order\nparam", i.e. the tab\'s own ranking, which is the right default. Changing the\nsort should reset to page 0. Campaign call sheets cannot sort:\n`leadbay_campaign_call_sheet` has no `order` param, and `lb.callList` drops it\nfor that source rather than sending something the tool would reject.\n\n**Two different systems.** Epilogue = how one outreach attempt went (drives\nfollow-up ranking). Lead status = the commercial outcome, org-wide \u2014 the same\nfield the website\'s status selector writes. A won deal is a LEAD STATUS;\n"she didn\'t pick up" is an EPILOGUE. Setting one never sets the other, so when\nthe user reports both in one breath, fire both actions.\n\n### Writing from a page: a note, and the prospecting actions\n\nEvery board that lets a rep record what happened \u2014 call sheet, lead desk,\ntriage card, route planner \u2014 writes the way the web app does, through two\ncalls, and never through `lb.outreach`:\n\n```js\n// The note: the web app\'s note field.\nlb.bindAction(els.log, lb.note({ leadId: lead.id, note })); // note = an lb.field gated on non-empty\n\n// The four prospecting actions: toggles over TODAY\'s list, as in the web app.\nconst today = (lead) => new Set((lead.epilogue_today_statuses ?? []).map((e) => e.type.replace(/^EPILOGUE_/, "")));\nasync function toggle(lead, value) { // value = one of lb.EPILOGUE_STATUSES\n const selected = !today(lead).has(value);\n await lb.call("leadbay_set_prospecting_action", { lead_id: lead.id, action: value, selected, _triggered_by: ASK });\n}\n```\n\n- **Why not `lb.outreach`.** It goes to `report_outreach`, which asks a human\n to type a confirmation for every `user_confirmed` call. A page has nowhere to\n show that prompt: the button waited 60 seconds, the write landed anyway, and\n the page said Leadbay "took too long". Reps retried and logged one visit\n twice. The prompt cannot be skipped for pages \u2014 the server cannot tell a\n page from an agent that claims to be one \u2014 so a page takes the path that\n never asks. `lb.outreach` stays the AGENT\'s tool for an outreach the user\n tells it about.\n- **The actions are toggles, several on at once.** The web app\'s Prospection\n cell is a multi-select over `epilogue_today_statuses`; each tap turns one on\n or off. Read "selected" from that list, **never from `epilogue_status`**:\n unticking removes the type from today\'s list and leaves `epilogue_status`\n where it was, so a board that pre-selects from it brings a removed action\n back. `epilogue_status` is only the last value ever set \u2014 show it as a\n "Last: Still chasing \xB7 24 Sep" line when nothing is on today. The field is\n absent on a lead nobody has worked: an empty set, not an error.\n- **Buttons, in the web app\'s colours.** Four `role="checkbox"` buttons with\n `aria-checked` (a select costs a rep two taps): Still chasing blue, Meeting\n planned green, Could not reach yellow, Not interested red \u2014 the\n `--color-<hue>-background` / `-foreground` pairs, all four in the skin. The\n foreground goes on a dot and the selected border, never on the label:\n yellow\'s is too light to read as text, and the web app uses it for icons.\n- **One control per axis.** The toggles own the prospecting action; the note\n form owns the note and offers no action of its own. Two controls writing one\n field race each other, and the later write silently wins.\n\n**Rendering helpers** \u2014 the only three things the library draws, and only\nbecause hand-rolling them goes wrong the same way every time: an SVG whose\npoints escape the viewBox, a series that draws empty axes when it is empty, a\nleaderboard whose digits do not line up. Each returns a DETACHED element you\nplace; none injects itself.\n\n| Call | Returns | For |\n|---|---|---|\n| `lb.sparkline(points, {label, emptyTitle, emptyHint})` | `<svg class="lb-chart">` or an `lb-empty` block | a trend from `[{date, count}]`, themed so it reads in light and dark |\n| `lb.tiles([{label, value}])` | `<div class="lb-tiles">` | headline figures \u2014 only the few that ARE the point |\n| `lb.leaderboard({rows, columns, sortKey, sortDir, cell})` | a scroll-wrapped `lb-table` | a sortable table; `aria-sort` + keyboard headers, `num` columns tabular and end-aligned |\n\n`sparkline` returns the empty block INSTEAD of a chart when the series is\nempty \u2014 an empty window is a real answer, and empty axes read as broken.\n`leaderboard` does the same for no rows. Sorting there is client-side by\ndesign: a roll-up arrives whole, unlike a lead list the backend pages.\n\n**Binding sugar** (optional; binds a view-model to YOUR native element, no style):\n`lb.bindSelect(selectEl, field)` (populates options + value), `lb.bindValue(inputEl, field)`,\n`lb.bindAction(buttonEl, action)`. They set `data-lb-state`\n(`ready|loading|error|success|unavailable`) + `data-lb-error` on your element as\nstyling hooks. For lists/resources, use `.subscribe()` and render yourself.\n\n`ask` is the user\'s request this artifact serves \u2014 it becomes `_triggered_by`.\n\n## The skin (optional) \u2014 `lb.styles()`\n\nCall it once and you get a small `lb-*` stylesheet, so every artifact you build\nshares one visual language instead of re-inventing padding and colours. It is\n**opt-in**: skip it and you get exactly the unstyled HTML you wrote. It injects\nno markup and never touches your `class` attributes.\n\n```js\nlb.styles(); // idempotent \u2014 safe to call per row\n```\n\n| Class | For |\n|---|---|\n| `lb-card` / `lb-card-head` / `lb-title` / `lb-sub` | a lead card + its header |\n| `lb-sections` / `lb-section` / `lb-sec-title` | the card\'s section stack (16px between, 8px within) and a section\'s uppercase title |\n| `lb-card-top` / `lb-lead-title` / `lb-card-foot` | header row (leading control \xB7 title \xB7 trailing controls), the bold underlined company name, and the footer\'s leading-action/spacer/trailing-link row |\n| `lb-tags-plain` / `lb-tags-intent` / `lb-tags-empty` | firmographic tags (grey: what the company IS) and intent tags (teal: what the qualifier FOUND), plus the honest empty state for either |\n| `lb-toolbar` / `lb-field` / `lb-field-label` / `lb-field-inline` | the bar above a deck: three groups (tally \xB7 narrow \xB7 act), and a labelled control whose caption stacks above it \u2014 `lb-field-inline` keeps a checkbox beside its words |\n| `lb-tally` / `lb-status` / `lb-status-dot` | the row count (tabular, so it does not jitter as filters change) and a connection indicator whose live/dead states differ in SHAPE, not only colour |\n| `lb-pager` / `lb-pager-range` | the pager below a deck \u2014 prev/next plus an honest "21\u201340 of 60" range; pair it with `data-lb-state="loading"` on the deck so a page flip dims the old rows instead of blanking them |\n| `lb-tiles` / `lb-tile` / `lb-tile-label` / `lb-tile-value` | a row of figures for a dashboard\'s headline numbers \u2014 only for the few that ARE the point |\n| `lb-chart` (+ `lb-chart-line` / `-area` / `-dot` / `-grid`) | an inline-SVG trend that takes its colours from the theme, so it reads in both; no CDN |\n| `lb-empty` / `lb-empty-title` / `lb-empty-hint` | the honest empty state \u2014 what it means and the way out, in place of a blank chart or table |\n| `lb-table th[aria-sort]` / `td[data-num]` | a sortable header (arrow + `aria-sort`) and a tabular numeric cell, so counts line up down a column |\n| `lb-row` / `lb-stack` / `lb-spacer` | control row / vertical spacing / flex filler that right-aligns what follows |\n| `lb-link-out` | quiet external link (icon inherits currentColor) \u2014 "Open in Leadbay" |\n| `lb-select` / `lb-input` / `lb-btn` | form controls (state-aware, see below) |\n| `lb-btn-submit` / `lb-btn-ai` / `lb-btn-lg` | the app\'s `primary` / `ai` variants, and its `large` size |\n| `lb-msg` (`data-tone="error\\|ok"`) | inline feedback |\n| `lb-chip` (`data-status="WON\\|LOST"`) | a status pill |\n| `lb-table` | leads table |\n| `lb-spinner` | inline busy indicator \u2014 decorative, mark it `aria-hidden` |\n| `lb-vh` | visually-hidden text \u2014 labels heard but not seen |\n\nControls react to the `data-lb-state` the bind helpers already set \u2014 a bound\n`lb-btn` dims while loading, goes green on success, red on error, all with no\nextra CSS from you.\n\nThe palette is the **product design system**, ported from\n`frontend/packages/style/color.css` \u2014 same `--color-gray-1\u20269` ramp, same\nsemantic `--color-{green,red,blue,gold}-{background,foreground}` pairs, same\n`1.5rem` / `0.625rem` radii (concentric: outer = inner + padding) and\n`corner-shape: squircle`, matching the app\'s components.\nAn artifact therefore looks like Leadbay, not like a generic page.\n\nUse the tokens rather than hardcoded colours \u2014 the same rule the style package\nenforces. Re-theme by overriding them; don\'t fight specificity:\n\n```css\n:root { --lb-surface: var(--color-gray-2); --lb-radius: 0.5rem; }\n```\n\nDark mode works two ways: `data-theme="dark"` on `<html>` (the frontend\'s own\nhook) **and** `prefers-color-scheme`, because an artifact renders inside a host\nwhose theme attribute it cannot set. Never hardcode a light background over the\nskin.\n\nThe product face is `Nikkei Maru`; the stack names it first and falls back to\nthe system UI font. Do **not** add an `@font-face` \u2014 artifacts are inline-only\nand a remote font URL will silently fail.\n\n## What every lead card MUST carry\n\nA card is the artifact form of the `pull_leads` table, and it inherits that\ntable\'s rules. A card with a name and a button is not enough: the rep cannot\ntell *why* this lead is on screen. Five lines, in this order.\n\n```html\n<div class="lb-card">\n <div class="lb-card-head">\n <span class="lb-title"></span> <!-- 1. company -->\n <span class="lb-chips"> <!-- 2. state -->\n <span class="lb-chip" data-taste hidden></span>\n <span class="lb-chip" data-status hidden></span>\n </span>\n </div>\n <div class="lb-facts"> <!-- one group, tight 4px gap -->\n <div class="lb-sub"></div> <!-- 3. firmographics -->\n <div class="lb-sub" data-who></div> <!-- 4. the person -->\n <div class="lb-sub" data-how></div> <!-- 5. company channels -->\n </div>\n <div class="lb-sub" data-why></div> <!-- 6. why it fits -->\n <div class="lb-row"><!-- actions --></div>\n <div class="lb-msg" role="status" aria-live="polite"></div>\n</div>\n```\n\n1. **Company** \u2014 `name`, linked to `website` (prefix `https://` on a bare host).\n Never render the numeric `score`; use the `\u25B0\u2756\u25B1` bar if you want the signal.\n\n Keep `.lb-msg` OUT of `.lb-row`. The result of a write \u2014 "Could not reach the\n host" \u2014 is the most important thing on the card at that moment; parked between\n two buttons it reads as a control, and as a wide flex item it forces the\n trailing link onto a line of its own. Give it its own row after the actions.\n\n Also give every card an **Open in Leadbay** link to the lead\'s panel in the\n product. Put it at the **trailing end of the card\'s last action row** \u2014\n same row as the buttons, pushed right by an `lb-spacer`, not on a line of\n its own. Style it `lb-link-out`: quiet text plus a plain arrow-up-right,\n never a filled button. It is an escape hatch, not a call to action.\n\n Group the controls by what they act on, so the row does not read as a set of\n peers: wrap each axis in an `lb-group`. Status is NOT one of those axes \u2014 it\n saves on change and carries no button at all (see "Save on change is the\n default for status" below). Reserve `lb-btn-submit` for a write that really\n does need a second step, such as the bulk apply across checked rows.\n\n Taste is the one pair worth reducing to icons: thumbs up/down are unambiguous,\n they repeat on every card, and dropping the words buys the width a narrow chat\n host needs. Use `lb-btn-icon` \u2014 and note the three attributes it REQUIRES,\n because with no text the glyph is the whole affordance:\n\n - `aria-label` naming the lead ("Like Acme Corp"), since the control repeats\n N times down the list;\n - `title` so a sighted user who does not know the glyph gets a tooltip;\n - `aria-pressed` reflecting the current taste \u2014 a toggle must say whether it\n is on, and `[aria-pressed=true]` is what the skin styles.\n\n Do **not** reduce "Set status" to an icon: no glyph says "commit the value in\n the select beside me". Icons work for a fixed, well-known action; they fail\n for one whose meaning comes from a neighbouring control. The status select\n sidesteps the question entirely by carrying no button at all \u2014 it saves on\n change \u2014 but the rule stands for any write whose meaning comes from the\n control beside it.\n\n ```html\n <div class="lb-row">\n <span class="lb-group"> <!-- taste: two toggles -->\n <button class="lb-btn lb-btn-icon" aria-label="Like Acme Corp"\n title="Like" aria-pressed="false">\n <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"\n stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">\n <path d="M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4.05 3 5.5l7 7Z"/>\n </svg>\n </button>\n <button class="lb-btn lb-btn-icon" data-taste="disliked"\n aria-label="Dislike Acme Corp" title="Dislike" aria-pressed="false">\n <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"\n stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">\n <path d="M17 14V2"/>\n <path d="M9 18.12 10 14H4.17a2 2 0 0 1-1.92-2.56l2.33-8A2 2 0 0 1 6.5 2H20a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2h-2.76a2 2 0 0 0-1.79 1.11L12 22a3.13 3.13 0 0 1-3-3.88Z"/>\n </svg>\n </button>\n </span>\n <select class="lb-select" aria-label="Lead status for Acme Corp"></select>\n <span class="lb-spacer"></span> <!-- pushes the link to the end -->\n <a class="lb-link-out" data-k="open" target="_blank" rel="noopener">\n Open in Leadbay\n <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"\n stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">\n <line x1="7" y1="17" x2="17" y2="7"/><polyline points="7 7 17 7 17 17"/>\n </svg>\n </a>\n </div>\n ```\n\n Keep the arrow a bare diagonal stroke \u2014 the text already says where the link\n goes, so the glyph only has to mark "leaves this page". Mark the `<svg>`\n `aria-hidden="true"`: it is decorative, and the link text is the accessible\n name.\n **Pick the view the lead actually lives in** \u2014 the URL is\n `/app/<view>?lead=<uuid>`, and the three views are `discover`, `monitor`,\n `campaign`. Landing a Monitor lead on Discover drops the rep into a list\n that does not contain it:\n\n ```js\n function leadUrl(lead, campaignId) {\n const id = encodeURIComponent(lead.id);\n // A campaign card carries TWO params \u2014 the campaign selects the list, the\n // lead opens the panel inside it. Campaign wins even when in_monitor is\n // also true, because that is the list the rep is looking at.\n if (campaignId) {\n return `https://leadbay.app/app/campaign?campaign=${encodeURIComponent(campaignId)}&lead=${id}`;\n }\n const view = lead.in_monitor ? "monitor" : "discover";\n return `https://leadbay.app/app/${view}?lead=${id}`;\n }\n openEl.href = leadUrl(lead, campaignId);\n ```\n\n `in_monitor` / `in_discover` are booleans on the `pull_followups` payload \u2014\n every follow-up carries `in_monitor: true`, so a call sheet must link to\n `monitor`. `pull_leads` omits both flags entirely; its leads are the Discover\n batch by definition, so `discover` is the default. A campaign card\n (`lb.callList({source:"campaign", campaignId})`) needs `?campaign=<id>&lead=<id>`\n \u2014 the param names are `CAMPAIGN_QUERY_PARAM` and `LEAD_QUERY_PARAM`, and the\n app\'s own `useLeadPanel` preserves whatever params are already set, so the\n two coexist by design. Omitting `campaign=` opens an empty campaign view.\n\n Inline the glyph as SVG rather than an emoji or `\u2197` \u2014 it inherits\n `currentColor` and scales with the text, so it stays legible in both themes.\n `?lead=<uuid>` is the real deep-link (`LEAD_QUERY_PARAM` in the web app, read\n on load; the panel is an overlay, so the view choice only decides what sits\n behind it). This is the ONE place a card may use `lead.id`: as a link target,\n never as visible text.\n2. **State chips** \u2014 taste (`data-taste`) and CRM status (`data-status`) are\n INDEPENDENT axes; render both, hide the empty one. Never collapse to one chip.\n3. **Firmographics** \u2014 sector of activity first, then city, then size, then the\n contact. `sector_id` is a RAW ID (`"5136"`), not a label: resolve it via\n `leadbay_list_sectors` (1346 rows \u2014 fetch once, cache, never inline the lot)\n or omit it. Never print the raw id.\n\n **Always show whether the lead is reachable \u2014 and never merge the person\n with the company\'s switchboard.** These are two separate lines:\n\n ```js\n // WHO \u2014 recommended_contact. Name, and job_title ONLY when present; on list\n // payloads it is usually null, and inventing one is worse than omitting it.\n const rc = lead.recommended_contact;\n const who = rc ? [rc.first_name, rc.last_name].filter(Boolean).join(" ") : null;\n const whoLine = who ? who + (rc.job_title ? " \xB7 " + rc.job_title : "") : "No contact yet \u2014 enrich to find one";\n\n // HOW \u2014 company-level channels. `phone_numbers` and `email` belong to the\n // COMPANY, not to `recommended_contact`. Rendering "Jean \xB7 \u260E 0123\u2026" claims a\n // direct line that does not exist; it is the switchboard.\n // The API returns the literal STRING "null" for a missing value \u2014 in\n // phone_numbers as well as email (a real lead ships phone_numbers:["null"]).\n // Guard BOTH or the card prints "\u260E null" as if it were a number.\n const real = (v) => (v && v !== "null" ? v : null);\n const phone = real((lead.phone_numbers || [])[0]);\n const email = real(lead.email);\n const howLine = [phone && "\u260E " + phone, email && "\u2709 " + email].filter(Boolean)\n .join(" \xB7 ") || "No phone or email \u2014 enrich to look for them";\n ```\n\n ```html\n <div class="lb-sub">Sector \xB7 City \xB7 Size</div>\n <div class="lb-sub"><span aria-hidden="true">\u{1F464}</span> Jean-Fran\xE7ois Froemer \xB7 G\xE9rant</div>\n <div class="lb-sub"><span class="lb-vh">Company switchboard: </span><span aria-hidden="true">\u{1F3E2} \u260E</span> 01 23 45 67 89 (company line)</div>\n ```\n\n Label the channel line as the **company\'s**, so a rep reading fast cannot\n mistake it for a direct line. A per-contact email or phone exists only after\n enrichment \u2014 `research_lead_by_id` exposes it as `contacts.reachable[]`, and\n `_meta.has_reachable_contact` is the authoritative flag. The list payloads\n carry neither, so a card built from `pull_leads` / `pull_followups` can only\n ever show company channels. Say "enrich to reveal" rather than implying the\n contact is callable.\n\n Two things that look like reachability and are not: a `linkedin_page` alone\n (the rep cannot message a URL without leaving the artifact \u2014 same rule\n `research_lead_by_id` applies), and `contacts_count > 0` (it counts known\n people, not people you can contact; a lead can show 2518 contacts and zero\n channels). `pull_followups` carries `has_phone` as a ready-made boolean;\n `pull_leads` omits it, so derive from `phone_numbers` there.\n4. **Why it fits** \u2014 one sentence, \u226420 words. Walk this chain and stop at the\n first hit:\n\n 1. `short_description`\n 2. `description` (longer; only on `research_lead_by_id` /\n `research_lead_by_name_fuzzy` \u2014 the trim payloads omit it)\n 3. top 2 `tags[].display_name`\n 4. `qualification_summary.best_response_excerpt`, trimmed to one sentence\n 5. `keywords`, first 3, joined with ` \xB7 `\n 6. the resolved sector label \u2014 better than nothing, and if step 3 already\n printed the sector on the firmographics line, skip to step 7\n 7. the literal *"No description yet \u2014 qualify to add one"* ("qualify" is the\n product\'s own verb; "run qualification to generate one" is a nominalisation)\n\n Never leave this line blank: a silent gap reads as a rendering bug, whereas\n the fallback tells the rep the data is missing and what fixes it.\n\n **The two list payloads are complementary, so the chain must span both.**\n `pull_leads` returns `short_description` on every lead but no `sector_id`;\n `pull_followups` returns `sector_id` but no `short_description` at all. A\n card fed by one will fall through to a different step than the same card fed\n by the other \u2014 that is expected, not a bug. Never call\n `research_lead_by_id` per row just to fill this line: it is one request per\n lead. Fetch it lazily when the rep expands a card.\n\n**Never show** on a card: `id`, `sector_id`, `location.pos`, `location.country`\n(unless city and state are both missing), `is_hq`, `*_in_progress`,\n`highlighted_fields`, `custom_fields`, `stale_at`, `deal_insights`,\n`need_attention*`, any count that is 0, any value that is the string `"null"`.\n\n**Minimum actions.** A card that only displays is a table row that costs more \u2014\nif you are not wiring an action, render the markdown table instead. Wire at\nleast one write, and prefer the set the rep actually needs:\n\n| Card is for | Wire |\n|---|---|\n| triage a discovery batch | `lb.like` / `lb.dislike` + `lb.setStatus` |\n| working a call list | `lb.note` (gated on a note) + the prospecting toggles + `lb.leadHistory` \u2014 see *Writing from a page* |\n| pipeline review | `lb.setStatus` + `lb.note` |\n\nAlways render the `.error` branch of every view-model \u2014 a control that cannot\nreach the host must say so, not sit silent.\n\n **Accessibility is the markup\'s job, not the skin\'s.** `data-lb-state` is a\n STYLING hook; it sets no ARIA. A card renders N times in a list, so every\n repeated control needs a name that says *which* lead it acts on:\n\n ```html\n <select class="lb-select" aria-label="Lead status for Acme Corp"></select>\n <button class="lb-btn" aria-label="Like Acme Corp">Like</button>\n <a class="lb-link-out" aria-label="Open Acme Corp in Leadbay" \u2026>\n <span class="lb-msg" role="status" aria-live="polite"></span>\n ```\n\n Without the `role="status"` node the rep hears nothing when a write fails \u2014\n `bindAction` puts the message in a `data-lb-error` attribute that nothing\n renders. Mark the `\u25B0\u2756\u25B1` bar `aria-hidden="true"` and follow it with\n `<span class="lb-vh">Fit: strong</span>`; the glyphs otherwise read aloud as\n "black parallelogram black parallelogram\u2026" before the company name. Use\n `lb-vh` for any label that should be heard but not seen.\n\n## Recipe: the pull-leads triage board (THE default board)\n\nWhen the rep accepts an interactive board after ANY tool that returns a batch\nof leads \u2014 `leadbay_pull_leads`, `leadbay_find_new_leads`,\n`leadbay_pull_followups`, `leadbay_campaign_call_sheet` \u2014 build THIS. It is a\nfixed recipe, not a starting point: the same board every time means a rep who\nlearned it once knows it everywhere. Deviate only when the rep asks for\nsomething specific.\n\nBuild it from the data ALREADY IN HAND \u2014 never re-call the tool that produced\nthe batch just to populate the board.\n\n**The board pins the light theme.** Set it before `lb.styles()`, as the first\nthing the artifact does:\n\n```js\ndocument.documentElement.setAttribute("data-lb-theme", "light");\nlb.styles();\n```\n\nEvery dark rule in the skin is guarded by `:not([data-lb-theme=light])`, so the\none attribute disables all of them \u2014 no token overrides, no specificity fight,\nand `lb.styles()` still declares `color-scheme:light` so native select popups\nfollow. A rep works a board beside the product\'s own light UI and reads the\ncards as the same surface; a board that flips with the HOST\'s theme puts a dark\ncard next to a light app for the same lead. This is the board\'s default, not\nthe skin\'s: `lb.styles()` keeps honouring `prefers-color-scheme` everywhere\nelse, and an artifact that WANTS the host theme simply omits the attribute.\n\nTwo things change with the source, and nothing else does:\n\n- **The deep link\'s view.** `pull_leads` omits `in_monitor`/`in_discover`, so\n its leads are the Discover batch by definition; `pull_followups` carries\n `in_monitor: true` on every row, so a call board links to `monitor`; a\n campaign sheet needs `?campaign=<id>&lead=<id>`. See the leadUrl helper above.\n- **Which write leads the card.** A discovery batch is triaged (taste + status);\n a follow-up list is worked (`lb.note` + the prospecting toggles). Order the\n Outreach and Status sections to match, but ship both either way.\n\n**Per card** \u2014 a header, then a stack of titled SECTIONS, then the write\nfeedback. The skin ships the whole geometry (`lb-sections` = 16px between\nsections, `lb-section` = 8px within), so build the structure and add no\nspacing CSS of your own:\n\n```html\n<article class="lb-card">\n <div class="lb-card-top"> <!-- checkbox \xB7 title \xB7 taste -->\n <input type="checkbox" aria-label="Select Acme Corp for bulk apply">\n <div class="lb-lead-title"><a href="https://acme.com">Acme Corp</a></div>\n <span class="lb-group"><!-- like / dislike, lb-btn-icon --></span>\n </div>\n <div class="lb-chips" hidden><!-- data-taste + data-status, see contract --></div>\n\n <div class="lb-sections">\n <div class="lb-section"> <!-- untitled: what it is -->\n <div class="lb-tags-plain"><span>Honfleur, Normandie</span><span>50\u201399</span></div>\n <div class="lb-sub">Why it fits, one sentence.</div>\n </div>\n <div class="lb-section"><div class="lb-sec-title">Fit</div>\u2026</div>\n <div class="lb-section">\n <div class="lb-sec-title">Intent tags</div>\n <div class="lb-tags-intent"><span>Vente terrain</span>\u2026</div>\n <!-- no tags? <div class="lb-tags-empty">None \u2014 the qualifier found no\n buying signal</div>, never an empty row -->\n </div>\n <div class="lb-section"><div class="lb-sec-title">Data</div>\u2026</div>\n <div class="lb-section">\n <div class="lb-sec-title">Status</div>\n <select class="lb-select" aria-label="Lead status for Acme Corp"></select>\n </div>\n <div class="lb-section">\n <div class="lb-sec-title">Outreach</div>\n <div class="lb-stack"> <!-- one control per line -->\n <div class="lb-actions" role="group"><!-- the 4 prospecting toggles, see *Writing from a page* --></div>\n <input class="lb-input" aria-label="Outreach note for Acme Corp">\n <button class="lb-btn lb-btn-submit">Log outreach</button> <!-- the note: lb.note, never lb.outreach -->\n </div>\n </div>\n <details class="lb-section"><!-- lazy full profile, see below --></details>\n <div class="lb-card-foot">\n <button class="lb-btn lb-btn-ai" data-k="qualify">Requalify</button>\n <!-- MANDATORY. Text from lb.qualifyLabel(lead): "Qualify" when the lead\n has no AI score yet, "Requalify" when it has one to replace. -->\n <span class="lb-spacer"></span>\n <a class="lb-link-out">Open in Leadbay</a>\n </div>\n </div>\n\n <div class="lb-msg" role="status" aria-live="polite"></div>\n</article>\n```\n\nWire them with `lb.like` / `lb.dislike`, `lb.leadStatus()` +\n`lb.setStatus({leadId, status, ask})`, `lb.note({leadId, note})` \u2014 the note\nfield gated by a `validate` so an empty note cannot log \u2014 and the four\nprospecting toggles from *Writing from a page*,\nplus `lb.qualify` in `lb-card-foot` \u2014 the Qualify/Requalify button every card\nMUST carry, labelled by `lb.qualifyLabel(lead)`.\n\nFive rules the structure encodes, each of which a hand-built card gets wrong:\n\n- **One section per titled block.** Two titles in one `lb-section` share its\n 8px gap instead of the 16px between sections, so the second title reads as\n part of the first block. Fit and Intent tags are the pair this catches.\n- **A section title sits at section level**, as a direct child. Nested inside a\n row (beside a verdict, say) it stops being a peer of the other titles and the\n card loses its scan order.\n- **No date input.** Status saves on change and the backend stamps the date as\n "now"; a picker is only for backdating, which this board does not do.\n- **Controls stack and span their section.** A select sizes to its longest\n option and a text input to a UA default, so side by side they come out\n different widths despite identical padding and height.\n- **Every card ships Qualify/Requalify.** Mandatory, and not conditional on\n the lead looking under-qualified: the rep reads "why it fits" and the intent\n tags, decides the qualifier got this one wrong, and re-runs it \u2014 a card that\n omits the button makes the verdict look final. It is also the one control\n that ACTS on the line the rep is doubting, which is why it sits in\n `lb-card-foot` next to Open in Leadbay rather than among the taste and\n status writes. Wire it with `lb.qualify` and label it with\n `lb.qualifyLabel(lead)`; both are below.\n\nOnly the bulk apply keeps a submit button, because it fans out across checked\nrows and takes a `confirm`.\n\n**Toolbar** \u2014 a tally, four filters (CRM status \xB7 taste \xB7 qualifier verdict \xB7\nsector), select-all + one `lb.setStatus({leadIds})` bulk write, and\n`lb.sortOrder()` bound to the list\'s `order`. Filters are CLIENT-side over the\nloaded page; sort is SERVER-side, so changing it must `loadPage(0)` (see the\nsorting rule above). The skin ships its geometry too:\n\n```html\n<div class="lb-toolbar">\n <span class="lb-tally">5 leads</span>\n\n <span class="lb-group"> <!-- narrow: sort + filters -->\n <label class="lb-field">\n <span class="lb-field-label">Sort</span>\n <select class="lb-select" aria-label="Sort order"></select>\n </label>\n <label class="lb-field">\n <span class="lb-field-label">Status</span>\n <select class="lb-select" aria-label="Filter by CRM status"></select>\n </label>\n <!-- taste, verdict \u2026 -->\n </span>\n\n <span class="lb-spacer"></span>\n\n <span class="lb-group"> <!-- act on the selection -->\n <label class="lb-field lb-field-inline">\n <input type="checkbox"><span class="lb-field-label">Select all</span>\n </label>\n <select class="lb-select" aria-label="Status for selected leads"></select>\n <button class="lb-btn lb-btn-submit">Apply to selected</button>\n </span>\n\n <span class="lb-status" data-live="yes"> <!-- shape, not just colour -->\n <span class="lb-status-dot"></span>live\n </span>\n\n <div class="lb-msg" role="status" aria-live="polite" style="flex:1 0 100%"></div>\n</div>\n```\n\nThree groups, because the bar does three things \u2014 report, narrow, act. Flat, the\nbulk commit reads as one more filter. Sort belongs in the narrow group but costs\na round trip the others do not, so mark that boundary (a divider, or its own\n`lb-group`) rather than letting four selects look interchangeable.\n\n**Pagination** \u2014 `lb.leadList` is already a paginated model: `.page`,\n`.pageSize`, `.total`, `.hasMore`, `.next()`, `.prev()`, `.loadPage(n)`. A\nboard that renders only page 0 silently hides the rest of the lens, so render\nthe pager whenever `total` exceeds one page:\n\n```html\n<div class="lb-pager">\n <button class="lb-btn" data-k="prev">Previous</button>\n <button class="lb-btn" data-k="next">Next</button>\n <span class="lb-spacer"></span>\n <span class="lb-pager-range">21\u201340 of 60</span>\n</div>\n```\n\n```js\nconst list = lb.leadList({ order: sort, ask: ASK, pageSize: 20 });\nlist.subscribe((l) => {\n deck.setAttribute("data-lb-state", l.loading ? "loading" : "ready");\n if (l.error) { renderError(l.error); return; } // .code says what to do\n if (!l.loading) renderCards(l.items); // keep old rows while loading\n const from = l.page * l.pageSize + 1;\n range.textContent = `${from}\u2013${Math.min(from + l.items.length - 1, l.total)} of ${l.total}`;\n prev.disabled = l.page === 0 || l.loading;\n next.disabled = !l.hasMore || l.loading;\n});\nprev.onclick = () => list.prev();\nnext.onclick = () => list.next();\n```\n\nFour things this gets right that a hand-rolled pager usually does not:\n\n- **A range, not a page number.** "21\u201340 of 60" says how much is left; "page 2"\n does not, and a rep cannot tell a short lens from a long one.\n- **Disable at the ends, and while loading.** `hasMore` is\n `(page + 1) * pageSize < total`, so it is false on the last page even when\n that page is full \u2014 a Next that fetches nothing reads as a broken button.\n- **Keep the old rows while the next page loads.** Blanking the deck loses the\n scroll position; `lb-deck[data-lb-state=loading]` dims and locks it instead.\n `loadPage` already drops a stale response if the rep flips pages quickly.\n- **Selection and page are independent.** A checked lead on page 1 stays in the\n bulk set after a flip, so either carry the selection across pages or clear it\n on the flip \u2014 silently dropping it means a bulk apply writes fewer leads than\n the rep ticked. Say which you chose in the UI.\n\nChanging the sort resets to page 0 (`loadPage(0)`), since the backend sorts the\nwhole lens and page 2 of the old order is not page 2 of the new one.\n\n**Rich profile data is LAZY.** The card renders from the list payload alone. A\n`lb.leadProfile(leadId, ask)` resource \u2014 sector label, `location.full`, real\n`linkedin_page`, the qualification Q&A \u2014 loads only when the rep EXPANDS that\ncard, one call for one lead they chose to open:\n\n```js\nconst profile = lb.leadProfile(lead.id, ASK); // autoLoad:false\nprofile.subscribe((p) => renderDetail(els.detail, p));\nels.expand.onclick = () => profile.load(); // one call, on demand\n```\n\nNever prefetch it for the batch: a 20-lead board would fire 20 requests to fill\nlines the rep may never read. The list payload already carries everything the\ncollapsed card shows.\n\n**Use `lb.qualify` \u2014 never hand-roll this action.** It is MANDATORY on every\nlead card, on every board, in every recipe. A card that renders a lead and no\nqualify control is incomplete: the rep can read the qualifier\'s verdict but\ncannot contest it without leaving the artifact.\n\n```js\nconst q = lb.qualify({ leadId: lead.id, ask: ASK, scored: lb.qualifyLabel(lead) === "Requalify" });\nels.qualify.textContent = lb.qualifyLabel(lead); // "Qualify" or "Requalify"\nlb.bindAction(els.qualify, q);\nq.subscribe((a) => {\n // QUEUED, not finished. Say so and leave the old tags alone \u2014 a card that\n // repaints as though a verdict arrived is lying about work that has not run.\n els.msg.textContent = a.loading ? "Queueing\u2026"\n : a.error ? a.error.message\n : a.lastResult ? "Qualifying \u2014 the verdict lands shortly." : "";\n els.msg.dataset.tone = a.error ? "error" : a.lastResult ? "ok" : "";\n if (a.lastResult) watch(a.lastResult); // optional, see below\n});\n```\n\n**Which word the button takes is not a style choice.** `lb.qualifyLabel(lead)`\nreads the lead\'s own data: a lead with an `ai_agent_lead_score`, or a\n`qualification_summary.answered > 0`, has a verdict to replace and gets\n**Requalify**; one without has never been run and gets **Qualify**. Labelling\nan unscored lead "Requalify" implies a previous run that never happened, and\nthe rep reads the empty tag row as a failure of the button they just pressed.\n\n**To show the verdict actually landing**, hand the launch result to\n`lb.qualifyStatus` \u2014 otherwise the card can only ever say "queued":\n\n```js\nfunction watch(launch) {\n const job = lb.qualifyStatus(launch, ASK); // polls every 15s\n job.subscribe((j) => {\n if (j.error) { els.msg.textContent = j.error.message; return; }\n if (j.done) profile.load(); // re-read the new tags\n });\n}\n```\n\nDeclare BOTH tools in the artifact\'s `mcp_tools`:\n`leadbay_bulk_qualify_leads` and `leadbay_qualify_status`.\n\nGive the button `class="lb-btn lb-btn-ai"` \u2014 purple is the product\'s AI\naffordance, and the app\'s own QualifyButton is `variant="ai"`.\n\n## Recipe: the ROUTE PLANNER (leads on a map, worked in person)\n\nWhen the rep is going somewhere \u2014 "I\'m in Lyon Thursday", "plan my tourn\xE9e",\n"who can I see on the way" \u2014 build THIS rather than the desk. Same writes,\ndifferent question: not *who do I call* but *what order do I drive*.\n\n**Half map, half panel.** The map is the left half and the lead list the\nright; clicking a marker opens that lead in the panel with its actions. A\nmap alone cannot be worked and a list alone is not a route.\n\nUse the kit\'s geo helpers \u2014 do NOT hand-roll them. `lb.leadPos`,\n`lb.distanceKm`, `lb.orderByProximity`, `lb.routeUrl` and\n`lb.routeDistanceKm` each encode one of the five rules below, and a page that\nreimplements them locally gets no benefit when a rule is fixed in the kit.\nThey landed after kit 0.6.0, so a page pinned to an older runtime has to be\nmoved forward rather than given private copies.\n\n```js\nconst stops = leads.map((l) => ({ lead: l, pos: lb.leadPos(l) })).filter((s) => s.pos);\nconst ordered = lb.orderByProximity(stops); // nearest-neighbour default\nconst link = lb.routeUrl(ordered); // null when nothing is placeable\nif (link?.truncated) note(`${link.truncated} stops past Google\'s limit are not in this link`);\n```\n\nFive rules, each one something a hand-built map gets wrong:\n\n- **Ungeocoded leads are NORMAL \u2014 list them, never drop them.** An imported\n book is mostly without coordinates. `lb.leadPos` returns null for those,\n and they belong in the panel under a "no location" heading. A map that\n silently shows 12 of 40 leads tells the rep their book is small.\n- **`0,0` is not a lead.** It is the API\'s missing-position sentinel, in the\n Gulf of Guinea. `lb.leadPos` rejects it; a raw `location.pos` read does not.\n- **Distance is `lb.distanceKm`, never Pythagoras.** At French latitudes a\n degree of longitude is ~73km against ~111km for latitude, so flat maths\n overstates east-west legs by half and mis-orders the day.\n- **Google Maps drops stops past 11.** `lb.routeUrl` reports `truncated`;\n say the number, because the alternative is a rep driving a route whose last\n calls silently vanished.\n- **The pin must carry the state.** Colour it by CRM status and ring the ones\n already worked today, so a glance at the map answers "where have I been".\n Status changes from the panel repaint the pin immediately \u2014 that feedback\n is the whole reason the two halves sit side by side.\n\n### The panel is FIXED \u2014 same sections, same order, every time\n\nA rep who learned the planner once must find the same controls in the same\nplace on every board any agent builds. "Clicking a marker opens that lead with\nits actions" is not a spec \u2014 it leaves the sections, their order and their\ncontrols to whoever builds the board, and a rep cannot build a habit on that.\nEmit the head and these four sections, in this order, and add nothing between\nthem:\n\n**The panel has TWO modes.** With no lead selected it shows the overview \u2014\n*Today\'s route* (numbered stops, per-leg km, move up / move down / remove, the\n"as the crow flies" total, and the Google Maps link) then the full lead list.\nSelecting a marker replaces both with the lead detail below; closing it\nreturns to the overview.\n\n```html\n<aside class="panel">\n <!-- HEAD \u2014 everything the rep needs before they get out of the car -->\n <div class="section">\n <div class="panel-head">\n <h2>{company}</h2>\n <button class="icon-btn" aria-label="Close {company}">\u2715</button>\n </div>\n <div class="chips"> <!-- status chip + "worked today" chip -->\n <div class="facts">\n <span class="address">{location.full, else city/state, else "No address on file"}</span>\n <span>{size}</span>\n <span>Contact: {name \xB7 job_title} \u2014 else "No contact yet \u2014 enrich to find one"</span>\n <span>Company line: \u260E {phone} \xB7 \u2709 {email} \u2014 else "No company phone or email \u2014 enrich to look for them"</span>\n </div>\n <div class="actions"> <!-- only when the lead has coordinates -->\n <button>Locate on map</button>\n <button>Add to route</button> <!-- toggles to "Remove from route" -->\n </div>\n <div class="links">\n <a>Google Maps \u2197</a> <!-- only when placeable -->\n <a>Open in Leadbay \u2197</a>\n </div>\n </div>\n\n <div class="section"> <!-- 1. STATUS \u2014 lb.setStatus, saves on change -->\n <h3 class="section-title">Status</h3>\n <select class="lb-select">\u2026</select>\n </div>\n\n <div class="section"> <!-- 2. PROSPECTING ACTION \u2014 toggles, leadbay_set_prospecting_action -->\n <h3 class="section-title">Prospecting action</h3>\n <div class="choices">\u2026</div> <!-- the 4 lb.EPILOGUE_STATUSES, each on/off, several at once -->\n <div class="summary">\u2026</div> <!-- "Last: Still chasing \xB7 24 Sep" when none is on today -->\n </div>\n\n <form class="section"> <!-- 3. LOG OUTREACH \u2014 channel + note, NO status, via lb.note -->\n <h3 class="section-title">Log outreach</h3>\n </form>\n\n <div class="section"> <!-- 4. NEARBY FOLLOW-UPS \u2014 lb.distanceKm, nearest first -->\n <h3 class="section-title">Nearby follow-ups</h3>\n </div>\n</aside>\n```\n\nThe head is not decoration. A rep standing outside the building needs the\naddress, who to ask for, and a number to call if the door is locked \u2014 and the\ntwo channel lines are separate because `phone_numbers` / `email` on a lead are\nthe COMPANY switchboard, not the contact\'s direct line. Say so, as\n"Company line:", or the rep dials it expecting the person.\n\nWhy this order and not another: **status first** because it is one click and\nthe rep usually knows it before they park; **prospecting action second**\nbecause on a doorstep "nobody in" is the whole report and typing a note is not\nworth it; **log outreach third** because it is the long form, for the visit\nthat actually went somewhere; **nearby last** because it is the question you\nask once the current stop is done.\n\nThe rules the sections carry:\n\n- **Status and prospecting action are DIFFERENT AXES.** Status is the\n commercial outcome the org sees (`lb.setStatus`); the prospecting action is\n how this visit went and drives when the lead resurfaces\n (`leadbay_set_prospecting_action`).\n Setting one never sets the other \u2014 a rep who books a meeting sets both.\n- **The prospecting action is four buttons, not a dropdown.** One tap on a\n phone, in a car park. A select costs two.\n- **They are TOGGLES over today\'s list, exactly as in the web app.** The web\n app\'s Prospection cell is a multi-select over `epilogue_today_statuses`:\n several actions can be on for one day, and each tap turns one on or off.\n Mirror it, so a rep sees the same state in both places:\n\n ```js\n const today = new Set((lead.epilogue_today_statuses ?? []).map((e) => e.type.replace(/^EPILOGUE_/, "")));\n const selected = !today.has(value); // the tap flips it\n await lb.call("leadbay_set_prospecting_action", { lead_id: lead.id, action: value, selected, _triggered_by: ASK });\n ```\n\n Read "selected" from `epilogue_today_statuses`, **never from\n `epilogue_status`**. Unticking removes the type from today\'s list and leaves\n `epilogue_status` where it was, so a board that pre-selects from it brings a\n removed action back on the next open. `epilogue_status` is only the last\n value ever set: show it as a "Last: Still chasing \xB7 24 Sep" line when nothing\n is on today, as the web app does. The field is absent on a lead nobody has\n worked \u2014 that is an empty set, not an error. Use `role="checkbox"` and\n `aria-checked`, not `aria-pressed`.\n- **In the web app\'s colours.** Still chasing blue, Meeting planned green,\n Could not reach yellow, Not interested red \u2014 the `--color-<hue>-background`\n / `-foreground` pairs, all four in the skin. The foreground goes on a dot\n and the selected border, never on the label: yellow\'s is too light to read\n as text on white, and the web app only uses it for icons.\n- **Log outreach writes a NOTE. It must not offer the epilogue too.** The\n first build of this panel put the same four `lb.EPILOGUE_STATUSES` in a\n select inside the form, so a rep who tapped a button and then submitted the\n form issued two writes of one field and the second silently won. Worse, the\n form\'s "No outcome yet" omitted `epilogue_status` entirely, leaving the\n button\'s status standing under a note that said otherwise, with nothing on\n screen showing the mismatch. One control per axis: the buttons own the\n epilogue, the form owns the note. Say so under the submit \u2014 "Sets no status\n \u2014 use Prospecting action above for that."\n- **A page never calls `lb.outreach`** (see *Writing from a page*). Write the\n note with `lb.note` and the action with `leadbay_set_prospecting_action` \u2014\n the web app\'s own two paths.\n `lb.outreach` goes to `report_outreach`, which asks a human to type a\n confirmation for every `user_confirmed` call, and a page has nowhere to\n show that prompt: the button waited 60 seconds, the write landed anyway, and\n the page showed "took too long". Reps retried and logged one visit twice.\n The prompt cannot be skipped for pages, because the server cannot tell a\n page from an agent that claims to be one \u2014 so the page takes the path that\n never asks.\n- **Every write repaints the pin before the panel says "saved".** The map is\n the record the rep reads; a panel that confirms while the pin still shows\n the old status is lying about what they can see.\n\n`lb.relanceRow` may be added for contacts and enrichment, as a FIFTH section\nafter Nearby \u2014 never inserted among the four.\n\n### Colours come from the skin, not from a second palette\n\n`lb.styles()` already defines the product\'s greys, so a page that also\ndeclares its own ends up with two near-identical neutrals on one screen \u2014 a\nblue-tinted `#f4f5f7` page behind a `#f0f0f0` toolbar, borders at `#e2e5ea`\nbeside the kit\'s `#e0e0e0`. Close enough that nobody can name the problem,\nwrong enough to look unfinished. Alias the skin instead:\n\n```css\n:root {\n --bg: var(--color-gray-2); /* the page behind map and panel */\n --panel: var(--color-white); /* the panel and any card on it */\n --line: var(--color-gray-3); /* every border */\n --ink: var(--lb-fg);\n --muted: var(--lb-muted);\n}\n```\n\nStatus pins take the semantic tokens the chips already use \u2014\n`--color-blue-foreground` for Wanted, green for Won, red for Lost \u2014 so a pin\nand its chip are the same colour by construction rather than by two people\npicking the same blue.\n\nKeep exactly three colours of your own, because the kit has none for them \u2014\n`--land`, `--land-border` and `--water` are drawing a country, not UI chrome.\n\n**Then pin the board to light, or aliasing makes it worse than the palette\ndid.** A page with its own hardcoded greys is accidentally immune to dark\nmode; the moment it aliases the skin it inherits the skin\'s dark block, which\nfires on `prefers-color-scheme: dark`. The board a rep opens on a dark-mode\nlaptop then paints `--lb-fg` white over a light `--color-gray-2` ground \u2014\nwhite text on a white page, borders jumped from `#e0e0e0` to mid grey. Same\nrule as the triage board: these boards are light for everyone.\n\n```css\n/* Tripled on purpose. lb.styles() APPENDS the skin to <head> at runtime, i.e.\n after this sheet, so a plain :root here loses to the skin\'s\n :root[data-theme=dark] on source order. :root:root:root outranks it on\n specificity instead, which source order cannot undo. */\n:root:root:root {\n --lb-surface: var(--color-gray-1); --lb-border: var(--color-gray-3);\n --lb-fg: var(--color-black); --lb-muted: var(--color-gray-8);\n --lb-field: var(--color-white); --lb-chip-bg: var(--color-gray-2);\n color-scheme: light;\n /* \u2026and the semantic pairs the pins and chips read, at their light values. */\n}\n```\n\nA single `data-lb-theme="light"` on `<html>` does the same job and is simpler\n\u2014 use it when the page owns its `<html>` element. An artifact that is a\nfragment the host wraps does not, which is why the pin is a stylesheet rule.\n\n### There is no tile layer. The basemap ships as files\n\nThe first instinct on any Leaflet map is the one every tutorial opens with:\n\n```js\nL.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png").addTo(map) // \u2190 blocked\n```\n\n**It does not work in an artifact, and it fails silently.** The artifact\nviewer\'s content security policy admits the page\'s own files, Google Fonts\nand a few script CDNs \u2014 nothing else. Every tile provider is off that list:\nOpenStreetMap, Carto, Stadia, MapTiler, all of them. The browser refuses the\nrequest before it leaves the machine, no error surfaces in the UI, and the rep\ngets an empty grey rectangle with pins floating on it. Probed with a real\nLeaflet map against two providers: `0 loaded`, every tile failed.\n\nThis is a property of WHERE the page runs, not of the code. The same two lines\nwork in the Leadbay frontend and in a local `.html` file. They only fail in a\npublished artifact \u2014 which is the only thing this recipe builds.\n\nSo the basemap is a **published file**: `france-departements.json`, the 96\nd\xE9partement outlines, fetched and drawn as one `L.geoJSON` layer behind the\npins. Take the ~570 KB build \u2014 coordinates rounded to five decimals, which is\nsub-metre, rather than the sixteen-decimal version of the same outlines that\ncosts the same bytes for an eighth of the geometry and omits the islands\n(R\xE9, Ol\xE9ron, Belle-\xCEle, the Corsican islets) entirely.\n\n**Stop there.** Roads and town labels were built, shipped and then removed:\n~475 KB of Natural Earth roads and 800 `geo.api.gouv.fr` town labels, tiered\nby zoom. They worked, they cost a layer-ordering bug, and a rep looking at\nthem still could not see a drive time, a local street or whether a river ran\nbetween two stops. They are a partial substitute for a thing an artifact\ncannot have, and the plain outline reads better. If someone asks for "more\ndetail on the map", the honest answer is that the panel\'s *Open in Google\nMaps* link is where navigation lives, and this map is for seeing WHICH leads\nsit near each other \u2014 which the outline already does.\n\nDo not try to solve it with a bigger file either. A full OSM extract for\nFrance is 4.7 GB and a single region ~500 MB, against a 16 MB ceiling per\nartifact file.\n\n### The board opens on France, and stays where the rep put it\n\n```js\nconst FRANCE_BOUNDS = L.latLngBounds([41.3, -5.2], [51.1, 9.6])\nmap.fitBounds(FRANCE_BOUNDS) // Corsen\u2192Italy, Bonifacio\u2192Dunkerque\n```\n\n- **`fitBounds`, never `setView([46.6, 2.4], 6)`.** The map pane is half the\n window \u2014 a tall narrow box \u2014 so one fixed zoom frames the country differently\n on a laptop and a wide monitor. Bounds adapt to the pane; a zoom number\n guesses at it.\n- **Do NOT fit to the leads on load.** The obvious move is to frame the pins as\n soon as they arrive. It means the board opens on whichever region the rep\'s\n book happens to cluster in, with no sense of the country around it \u2014 and if\n it re-fires on every city change it yanks the view while they are reading.\n Give them a **Fit to leads** button in the toolbar instead, beside *All\n areas*, and let the opening view be France every time. With no geocoded\n leads the button falls back to France rather than doing nothing.\n\n\n### The lead list: static dividers, one moving thing\n\nBoth lists in the panel \u2014 *Follow-ups on the map* and *Nearby follow-ups* \u2014\nuse the same row. Copy it; it is three rules, and each one is a thing the\nobvious version gets wrong.\n\n```css\n.rows { display: flex; flex-direction: column; gap: 2px; }\n.row-btn {\n position: relative; padding: 9px 10px; margin-inline: -6px;\n border: 0; border-radius: 10px; background: transparent;\n transition: background-color .12s ease;\n}\n.row-btn + .row-btn::before { /* between rows only \u2014 never first or last */\n content: ""; position: absolute; inset-inline: 10px; top: -1px; height: 1px;\n background: var(--line);\n}\n.row-btn:hover, .row-btn:focus-visible { background: color-mix(in srgb, var(--ink) 4%, transparent); }\n.row-btn:active { background: color-mix(in srgb, var(--ink) 8%, transparent); }\n@media (prefers-reduced-motion: reduce) { .row-btn { transition: none; } }\n```\n\n- **The divider never reacts to hover, and never animates.** The tempting\n version fades the line above and below the hovered row so the highlight has\n clean edges. Do not: two rules then fight over one line \u2014 the hovered row\n hides the divider below it while the next row hides the one above it \u2014 so\n the line flickers as the pointer crosses between rows, and scanning the list\n makes lines ripple in and out around the cursor. The animation ends up\n louder than the row it is highlighting. **Only the background moves.**\n- **Space the rows instead of hiding the line.** `gap: 2px` with the hairline\n centred in the gap means the rounded hover block floats in its own space and\n never shares an edge with a divider. There is nothing to hide, so nothing\n has to animate. This is the whole trick.\n- **The divider is inset to the text (`inset-inline: 10px`), not the row.** A\n line that runs wider than the content it separates stops reading as a\n divider and starts reading as a rule across the panel. Pair it with\n `margin-inline: -6px` on the row so the hover block bleeds slightly past\n the text \u2014 inside the panel\'s own 16px padding, which leaves room for the\n focus ring.\n\nUse `.row-btn + .row-btn::before`, not `::after` with a `:last-child`\nexception: a selector that only ever matches BETWEEN rows cannot put a line\nunder the last one, so there is no special case to forget.\n\nStraight-line totals from `lb.routeDistanceKm` are "as the crow flies" \u2014 label\nthem that way rather than implying a drive time.\n\n## Recipe: the LEAD DESK (the default board for working leads)\n\nWhen the rep accepts the board after `leadbay_pull_followups` or\n`leadbay_campaign_call_sheet`, build THIS. It is the relance table below plus\na source picker, and it is the canonical answer to "give me somewhere to work\nthese leads".\n\n```js\nconst source = lb.field({ value: "followups" }); // or "discover" | "campaign"\nconst campaign = lb.campaigns(ASK); // only for the campaign source\nconst sort = lb.sortOrder();\n\nconst list = lb.leadSource({\n kind: source, campaignId: campaign, order: sort, ask: ASK, pageSize: 15,\n});\nlist.subscribe((l) => renderRows(l.items));\n```\n\n`lb.leadSource` owns the two things that differ by source, and only those:\n\n- **The deep link\'s view.** `list.leadUrl(lead)` answers per row \u2014 a Monitor\n lead opened on Discover drops the rep into a list that does not contain it,\n and a campaign row needs `?campaign=<id>&lead=<id>` or it opens an empty\n campaign view.\n- **Sorting.** `leadbay_campaign_call_sheet` has NO `order` param, so the\n order is DROPPED for that source rather than sent and rejected. Hide the\n sort control there too: offering it promises something the tool cannot do.\n\nChanging the source or the sort resets to page 0 \u2014 page 2 of the old list is\nnot page 2 of the new one. Every row is a `lb.relanceRow`, which now carries\ntaste and qualify alongside contacts, status and outreach, so the row is\ncomplete whichever source it came from.\n\n## Recipe: relance table (the follow-up board)\n\n"Give me a table where I can see who to call, reach them, and record what\nhappened." One row per lead, everything in one place. `lb.relanceRow` bundles\nthe row so you wire it once instead of five times:\n\n```js\nconst list = lb.callList({ source: "followups", ask: ASK });\nlist.subscribe((l) => renderRows(l.items));\n\nfunction wireRow(lead, els) {\n const row = lb.relanceRow({\n leadId: lead.id,\n ask: ASK,\n currentStatus: lead.state?.status, // the select opens on it\n });\n\n // CHANNELS \u2014 lazy. The row renders from the list; this fires on the gesture.\n row.contacts.subscribe((c) => renderContacts(els.contacts, c));\n els.reveal.onclick = () => row.contacts.load();\n\n // STATUS \u2014 saves on change, no button.\n lb.bindSelect(els.status, row.status);\n els.status.onchange = () => row.saveStatus.run();\n\n // OUTREACH \u2014 epilogue + note, note required.\n lb.bindSelect(els.epilogue, row.epilogue);\n lb.bindValue(els.note, row.note);\n lb.bindAction(els.log, row.logOutreach);\n}\n```\n\n**Every row\'s lead cell carries its context \u2014 this is not optional.** The row\nresolves it for you when you pass the lead:\n\n```js\nconst row = lb.relanceRow({ leadId: lead.id, ask: ASK, lead });\nrow.context.subscribe((c) => {\n if (!c.data) return;\n els.summary.textContent = c.data.summary ?? ""; // what they do\n if (c.data.phone) els.coPhone.href = "tel:" + c.data.phone;\n if (c.data.email) els.coEmail.href = "mailto:" + c.data.email;\n});\n```\n\nThree rules the component enforces so a row cannot get them wrong:\n\n- **Never print `sector_id`.** It is a raw id (`"5134"`); `lb.sectorLabels()`\n resolves it, fetching the ~1,091-row taxonomy once per page and caching it.\n Unresolvable means the line is OMITTED, never shown raw.\n- **Show the company\'s phone and email when they exist.** A rep who can dial\n the switchboard today should not have to open Leadbay to find out.\n- **Label them as the COMPANY\'s.** `phone_numbers` and `email` on a lead are\n the switchboard, not `recommended_contact`\'s direct line. Rendering\n "Jean \xB7 \u260E 01 23\u2026" claims a line that does not exist.\n\n**A contact\'s LinkedIn is a route, show it.** `lb.relanceRow` flattens\n`linkedin_page` onto each contact, so a contact with no email or phone may\nstill be reachable \u2014 render it beside the other channels. It is deliberately\nNOT counted as reachability by `lb.leadReach`: the two rules answer different\nquestions ("can I contact this person now" vs "how much of the book is\ncallable"), and a URL cannot be dialled.\n\n**Email and phone are NOT on the list payload.** `pull_followups` and\n`campaign_call_sheet` carry `recommended_contact` as a NAME and nothing else;\nthe channels live on `research_lead_by_id` as `contacts.reachable[]`. So a\ntable that renders `\u260E` straight from the list renders nothing, and one that\nprefetches fires a request per row to fill cells the rep may never read.\n`row.contacts` is a lazy Resource for exactly that reason: render the row\ninstantly, load the channels when the rep opens it. Show a **Reveal contact**\ncontrol rather than an empty cell, so the rep knows the data exists.\n\n**Two write systems, both on the row.** `row.saveStatus` writes the org-wide\nCRM outcome (Wanted/Won/Lost/Unwanted); `row.logOutreach` records how THIS\nattempt went (still chasing / could not reach / interested / lost) and drives\nfollow-up ranking. Setting one never sets the other, so when the rep says\n"she\'s interested, meeting booked" both fire. Use `lb.EPILOGUE_LABELS` for the\nselect \u2014 the raw enum values are shouted constants and a rep should not have\nto translate `INTEREST_VALIDATED_OR_MEETING_PLANED` before answering.\n\n**The note is required and the component enforces it.** `report_outreach`\nwithout a note records that something happened and not what; the next rep\nreads an empty follow-up and calls blind.\n\n**A contact with no channel gets an enrich offer, not a dead end.** That is\nthe whole point of showing candidates: they are one purchase away from being\ncallable.\n\n```js\n// wherever a channel is MISSING \u2014 never gate on `enriched`, see below\nif (!c.phone || !c.email) {\n lb.bindAction(els.enrich, lb.enrichContact({\n leadId: lead.id,\n contactId: c.contactId,\n email: () => emailBox.checked, // both default true, as the tool does\n phone: () => phoneBox.checked,\n ask: ASK,\n onDone: () => row.contacts.load(), // \u2190 see below\n }));\n}\n```\n\n`lb.enrichContact` SPENDS QUOTA, so it confirms by default and names the\nspend.\n\n**`window.confirm` does not work in a published artifact.** The iframe is\nsandboxed and the call returns false without showing a dialog, so an action\ncarrying a `confirm` returns early with no error and no message \u2014 a control\nthat silently does nothing. `Action` now reports that case as an error rather\nthan swallowing it, but the fix for a real page is to supply your OWN\nconfirmation: pass `confirm: ""` and arm the button in-page (first click\narms and relabels, second click spends, with a timeout that disarms). Apply\nthe same to the triage board\'s bulk apply, which has the same footgun. Offer\nemail and phone as separate choices rather than always buying both \u2014 the tool\ndefaults both to true and rejects a call with both false, which the component\ncatches before the confirm so nobody approves a spend that cannot happen.\n\n**Never gate the offer on `enriched`.** `enrichment_done` flips true once ANY\nchannel resolves, so a contact enriched for email earlier reads done while\nstill having no phone \u2014 and an org contact can read done carrying nothing at\nall. Hiding the button there hides it on exactly the contacts that need it.\nGate on the CHANNEL being missing, and default each checkbox to the missing\none so a rep cannot re-buy what is already on file.\n\n**The call launches an async job; it does not return a channel.** The real\nresponse is `{triggered: true, email_requested, phone_requested, hint}` \u2014 no\n`ok`, no contact. The reveal lands minutes later, so a UI that expects the\nemail in this result shows the rep nothing.\n\n**Re-read the contacts afterwards; do not patch the row.** For a\n`source:"paid"` candidate the channel lands on a NEW `source:"org"` contact\nwith a DIFFERENT id \u2014 the candidate row itself only flips `enrichment_done`.\nThat is what `onDone` is for.\n\n**Layout.** It is a table, so `lb-table` with `data-num` on any count, and the\nper-row controls stacked in their cell (`lb-stack`) so a select and an input\nshare one width. Keep `lb-msg` out of the control row \u2014 a failed write is the\nmost important thing on that row at that moment.\n\n## Recipe: cold-call sheet (one row per lead)\n\n```js\nconst lb = window.LeadbayArtifacts; lb.configure();\nconst ASK = "<the user\'s request>";\n\nconst list = lb.callList({ source: "campaign", campaignId: CID, ask: ASK });\nlist.subscribe((l) => renderRows(l.items, l.loading)); // your render\n\n// per lead row (call when you build a row):\nfunction wireRow(lead, els) {\n // Note + prospecting toggles, never lb.outreach \u2014 see *Writing from a page*.\n const note = lb.field({ validate: (v) => (v && v.trim() ? null : "Add a note") });\n lb.bindValue(els.note, note);\n lb.bindAction(els.log, lb.note({ leadId: lead.id, note }));\n for (const btn of els.actions) btn.onclick = () => toggle(lead, btn.dataset.value);\n lb.bindAction(els.like, lb.like(lead.id));\n\n // MANDATORY here too \u2014 a rep on the phone is exactly who discovers the\n // qualifier was wrong. The label comes from the lead, never hardcoded.\n els.qualify.textContent = lb.qualifyLabel(lead);\n lb.bindAction(els.qualify, lb.qualify({ leadId: lead.id, ask: ASK }));\n\n const history = lb.leadHistory(lead.id, ASK); // lazy\n history.subscribe((h) => renderHistory(els.history, h));\n els.expand.onclick = () => history.load(); // load on click\n}\n```\n\n## Recipe: lead-status dropdown (Wanted / Won / Lost)\n\nThe org-wide CRM status, as a `<select>` that writes on change \u2014 no Apply\nbutton. You write the markup; `lb.leadStatus` fills the options and holds the\nvalue, `lb.setStatus` does the write.\n\n```html\n<div class="lb-card">\n <div class="lb-card-head">\n <span class="lb-title">Acme Corp</span>\n <span class="lb-chips">\n <span id="taste" class="lb-chip" data-taste="liked">Liked</span>\n <span id="crm" class="lb-chip" data-status="WANTED">Wanted</span>\n </span>\n </div>\n <div class="lb-row">\n <select id="st" class="lb-select" aria-label="Lead status for Acme Corp"></select>\n </div>\n <span id="msg" class="lb-msg" role="status" aria-live="polite"></span>\n</div>\n```\n\n**Two badges, never one.** Taste (`liked`/`disliked`, from `lb.like`/`lb.dislike`)\nand CRM status (`WANTED`/`WON`/`LOST`/`UNWANTED`, from `lb.setStatus`) are\nindependent axes \u2014 a lead can be liked *and* lost. Collapsing them into a single\nchip destroys information: the rep can no longer see that a lead they liked went\nnowhere. Render `data-taste` and `data-status` as separate chips inside\n`lb-chips`, and hide the one that has no value rather than reusing it.\n\n```js\nlb.styles(); // once per artifact \u2014 see below\n\nconst status = lb.leadStatus(lead.org_lead_status); // seed with the CURRENT value\nconst save = lb.setStatus({ leadId: lead.id, status, ask: ASK });\n\nconst sel = document.getElementById("st");\nlb.bindSelect(sel, status); // populates the 4 options\nsel.addEventListener("change", () => save.run()); // change \u2192 write, no button\n\nsave.subscribe((a) => { // render your own feedback\n sel.setAttribute("data-lb-state", // the select IS the surface\n a.loading ? "loading" : a.error ? "error" : a.lastResult ? "success" : "ready");\n msg.textContent = a.loading ? "Saving\u2026"\n : a.error ? a.error.message // includes partial failures\n : a.lastResult ? `Set to ${a.lastResult.status}` : "";\n msg.dataset.tone = a.error ? "error" : a.lastResult ? "ok" : "";\n});\n```\n\nLoading / success / error styling comes free: `bindAction` and `bindSelect` set\n`data-lb-state` (`ready|loading|error|success|unavailable`) and the skin already\ntargets those attributes. No extra wiring.\n\n**Save on change is the default for status.** The rep picks a value and it\nwrites \u2014 one interaction, no second button, matching the web app\'s own status\nselector. Drop `bindAction` and run the action from the change event:\n\n```js\nconst sel = document.getElementById("st");\nsel.addEventListener("change", () => save.run());\nsave.subscribe((a) => { // the select IS the feedback surface\n sel.setAttribute("data-lb-state",\n a.loading ? "loading" : a.error ? "error" : a.lastResult ? "success" : "ready");\n msg.textContent = a.loading ? "Saving\u2026" : a.error ? a.error.message\n : a.lastResult ? "Saved" : "";\n msg.dataset.tone = a.error ? "error" : a.lastResult ? "ok" : "";\n});\n```\n\nWithout a submit button the select becomes the only affordance, so it MUST show\nthe write: mirror `data-lb-state` onto it (the skin already styles loading /\nsuccess / error on `.lb-select`) and put the outcome in the `role="status"`\nline. A silent select leaves the rep unsure whether anything happened.\n\nKeep a submit button ONLY where a mis-click is expensive and the value is not\nself-evident \u2014 a bulk apply across checked rows, for instance, which already\ntakes a `confirm`. For one lead, the extra step buys nothing: the value is\nvisible in the select, and the rep can simply pick again.\n\n**Bulk apply** across checked rows \u2014 pass `leadIds` and a `confirm`, since one\nclick rewrites a field every rep in the org sees:\n\n```js\nconst bulk = lb.setStatus({\n leadIds: () => checkedIds, // \u2190 read at run() time, not at build time\n status, ask: ASK,\n confirm: "Set this status on every selected lead?",\n});\n```\n\n`leadIds` is read when the action runs, so a live selection works \u2014 but pass the\narray itself if your selection is fixed. A partial write (some leads rejected)\nsurfaces as `.error`, never as a green button: `setStatus` checks the `failed[]`\nthe tool returns.\n\nThe backend stamps the status date as "now" on every write, which is what a rep\nclicking a dropdown means. Don\'t add a date picker unless the user asks to\nbackdate \u2014 then pass an optional `date` field holding `YYYY-MM-DD`:\n`lb.setStatus({ leadId, status, date, ask })`.\n\n## Recipe: segment coverage (any dimension)\n\n"How many leads do we have in sector X" is one cheap call: the Monitor filter\nplus `count: 1`, read off `pagination.total`. `lb.segmentCount` wraps a single\nsector/city count; **`lb.coverage` sweeps a whole dimension**.\n\n### One skeleton, many dimensions\n\nSector is not special. Every `FilterCriterion` type the Monitor accepts is a\ncoverage dimension, and one board renders any of them \u2014 the bars, the\ndenominator and the trusted check are all dimension-agnostic:\n\n| Dimension | Criterion | Answers |\n|---|---|---|\n| sector | `{type:"sector_ids", sectors:[id]}` | where the book concentrates |\n| size | `{type:"size", min, max}` | am I an SMB or an enterprise shop |\n| recency | `{type:"last_action_date", \u2026}` | how much has gone cold |\n| liked | `{type:"liked"}` | what the team actually wants |\n| custom field | `{type:"custom_field", key, value}` | the org\'s own taxonomy |\n\n```js\nconst buckets = await lb.coverageBuckets({ // derive, never hardcode\n field: "sector_id",\n labels: SECTORS, // id \u2192 name, embedded\n criterion: (id) => ({ type: "sector_ids", sectors: [id], is_excluded: false }),\n limit: 12,\n ask: ASK,\n});\n\nconst book = await lb.coverageTotal({ ask: ASK }); // unfiltered denominator\nconst rows = await lb.coverage({\n buckets,\n ask: ASK,\n onProgress: (done, total) => (tally.textContent = `measuring ${done}/${total}\u2026`),\n});\n\nfor (const r of rows) {\n if (!r.trusted) renderUnmeasured(r); // never chart it\n else renderBar(r.label, r.total, r.total / book.total);\n}\n```\n\nThree things `lb.coverage` owns, each of which a hand-rolled sweep gets wrong:\n\n- **Sequential, not a parallel burst.** A filtered count is 1\u20132s, but a\n `last_action_date` criterion was observed at **54s** on a 3.6k segment.\n Twelve of those at once is a hung page; `onProgress` is there so the sweep\n is visible instead.\n- **The complete criteria set per call.** The stored filter is ONE\n server-side slot and cumulative, so a delta leaves the previous bucket\'s\n criterion in force and every bar becomes a subset of the one before it.\n- **Per-bucket verification.** One rejected criterion must not poison a\n neighbour\'s number, and a bucket that throws becomes an unmeasured row\n rather than aborting the other eleven.\n\n### Reachability \u2014 the segment that says what to BUY\n\nEvery dimension above slices a book the rep may not be able to call. This one\nsays whether they can call it at all, and it is usually the first board worth\nbuilding on an imported book:\n\n```js\nconst r = await lb.reachCoverage({ ask: ASK }); // one call\nrenderBars(r.rows);\nnote.textContent = `sampled ${r.sampled} of ${r.bookTotal}`;\n```\n\nThree buckets, and the middle one is the answer:\n\n| Bucket | Means | Next move |\n|---|---|---|\n| Callable now | a company phone or email exists | work it |\n| **Contacts, no channel** | people known, nothing dialable | **enrich \u2014 this is the spend** |\n| No contacts | neither | discovery first |\n\n**`contacts_count > 0` is NOT reachability**, and `lb.leadReach` exists so no\nboard gets this wrong. It counts known PEOPLE, not people you can dial \u2014 a\nlead can show 2,518 contacts and zero channels. Charting `contacts_count`\ntells a rep they have a pipeline when they have a phone book with no numbers.\nA `linkedin_page` is not reachability either. And the API returns the literal\nstring `"null"` in `phone_numbers` and `email`, which counts as a channel\nunless guarded \u2014 the one error that would make this board actively harmful,\nby overstating the callable book.\n\n**This one is SAMPLED, unlike every other dimension.** Reachability is not a\n`FilterCriterion`, so there is no cheap `pagination.total` for it: the counts\ncome from classifying real leads on one page. `bookTotal` is the real\ndenominator (it rides along on the same call, so this costs ONE request), and\n`sampled` says over how many. Label it as an estimate \u2014 "\u224862% of 7,078,\nsampled over 200" \u2014 never as an exact count.\n\n### What you cannot count this way\n\nA dimension is countable only if the Monitor can FILTER by it. Anything\nneeding per-lead values \u2014 a score histogram, a density map \u2014 has no\naggregation endpoint and means paging the whole book (732 pages for 3,656\nleads). Sample the tails instead (`order: "SCORE:ASC"` / `"SCORE:DESC"`, a few\nhundred each) and **label the chart as a sample with its n**, or do not draw\nit.\n\n### Never hardcode the sector list\n\nA board that offers sectors must offer the ones the user HOLDS. Typing a few\nids into the markup gets this wrong in both directions \u2014 one real portfolio\'s\nhand-written list offered a sector holding 3 leads while omitting the\nthird-largest at 555.\n\nThere is no group-by on the Monitor, but every lead carries `sector_id`, so one\npage of followups names the sectors that matter:\n\n```js\n// SECTORS is { id: label }, embedded at BUILD time from leadbay_list_sectors.\n// The visible taxonomy is ~1,346 entries and does not change between runs, so\n// fetching it per page load buys nothing.\nconst held = await lb.portfolioSectors({ sectors: SECTORS, ask: ASK });\nfor (const s of held) {\n const opt = document.createElement(\'option\');\n opt.value = s.id;\n opt.textContent = s.label; // falls back to `Sector <id>` when unresolved\n sel.appendChild(opt);\n}\n```\n\n`sampled` is a SAMPLE count, for ordering the list. It is not the user\'s total\n\u2014 call `lb.segmentCount` for the exact figure once a sector is picked.\n\n**The taxonomy\'s `number_of_leads` is the WHOLE MARKET, not this user\'s book.**\nSupermarch\xE9s reads 11,460 nationally against 3,656 in one real portfolio, and\nSup\xE9rettes 5,710 against 143. Wiring a dropdown to that field ranks sectors the\nuser barely holds above ones they live in. Use it as a denominator if you are\nexplicitly showing market coverage, and label it as such \u2014 never present it as\nthe user\'s number.\n\n```js\nconst supermarkets = await lb.segmentCount({ sectorIds: ["5134"], ask: ASK });\n// \u2192 { total: 3656, applied: [...], trusted: true }\n```\n\n**Two traps, both observed against the live API \u2014 a hand-rolled version hits\nboth:**\n\n1. **The filter is server-side and STATEFUL.** `set_filter` overwrites ONE\n stored FilterItem per user, so consecutive calls are not independent: the\n next one inherits what the last one stored. Always send the complete\n criteria set, never a delta, and never assume a fresh call starts clean.\n2. **A rejected criterion fails SILENTLY.** Send a malformed criterion and the\n call returns **200 with the PREVIOUS filter still applied** \u2014 a plausible\n number answering a different question. `segmentCount` compares the echoed\n `active_filters` against what it sent and returns `trusted: false` on a\n mismatch. Never chart an untrusted count; say the segment could not be\n measured instead.\n\n The echo must match the request in BOTH directions. Every criterion asked\n for has to come back \u2014 a `city` / `cityId` as a `location_ids` criterion,\n since the composite resolves the free text through `/geo/search` into a type\n you never sent \u2014 and **nothing may come back that was not asked for**. The\n stored filter is cumulative, so narrowing a segment (sector+city \u2192 sector\n alone) leaves the dropped criterion in force: the count stays fenced to a\n city nobody asked about while every requested type is dutifully present. An\n unrequested criterion narrows a count exactly as a dropped one widens it.\n That rule also covers the unfiltered call on its own terms \u2014 with nothing\n wanted, "nothing extra" is "the echo is empty", which is what makes a\n whole-book denominator trustworthy.\n\n Sector VALUES are compared too, since a stale sector filter is still a sector\n filter. Locations are checked for presence only: the server picks the\n `admin_area_id` from free text, so the caller has nothing to compare against.\n\n**What you cannot build this way.** A score histogram needs every lead\'s\n`ai_agent_lead_score`, and there is no aggregation endpoint \u2014 bucketing 3,656\nleads means 732 pages. Sample the tails instead (`order: "SCORE:ASC"` and\n`"SCORE:DESC"`, a few hundred each) and **label the chart as a sample with its\nn**, or do not draw it. The same applies to a density map: coordinates are on\nevery lead, but aggregating thousands of points client-side is a batch job, not\na dashboard. Both want a backend stats endpoint.\n\n**Budget the calls.** A filtered count is normally 1\u20132 s, but adding a\n`last_action_date` criterion was observed at **54 s** on a 3.6k segment. Fire\nsegments in sequence with a visible progress cue, not a parallel burst, and\nnever block the first paint on them.\n\n## Recipe: manager dashboard\n\n`lb.teamActivity` returns `{range, reps, trend}`. A manager reads before they\nact, so the page is three bands in that order: the figures, the trend behind\nthem, then the per-rep table.\n\n```html\n<div class="lb-tiles"> <!-- only figures that ARE the point -->\n <div class="lb-tile"><span class="lb-tile-label">Activities</span>\n <span class="lb-tile-value">0</span></div>\n <!-- meetings / notes / contacts added \u2026 -->\n</div>\n\n<svg class="lb-chart" viewBox="0 0 640 160" role="img"\n aria-label="Weekly activity, 24 Jun to 16 Sep">\u2026</svg>\n\n<table class="lb-table">\n <thead><tr>\n <th aria-sort="none">Rep</th>\n <th data-num aria-sort="descending">Activities</th>\n <th data-num aria-sort="none">Meetings</th>\n </tr></thead>\n <tbody><tr aria-selected="false">\u2026</tr></tbody>\n</table>\n```\n\n```js\nconst team = lb.teamActivity({ weeks: 4, ask: ASK });\nteam.subscribe((t) => {\n if (t.error) { renderError(t.error); return; } // .code says what to do\n if (!t.data) return; // first load\n const { reps, trend, range } = t.data;\n const sum = (k) => reps.reduce((a, r) => a + (r[k] || 0), 0);\n\n stage.replaceChildren(\n lb.tiles([\n { label: "Activities", value: sum("total_activities") },\n { label: "Meetings", value: sum("meetings_or_interest") },\n { label: "Notes", value: sum("notes") },\n ]),\n lb.sparkline(trend, { // [] \u2192 the empty block\n label: `Activity from ${range.from} to ${range.to}`,\n emptyTitle: "No activity in this window",\n emptyHint: `Nothing logged between ${range.from} and ${range.to}. Widen the window.`,\n }),\n lb.leaderboard({\n rows: reps,\n sortKey: "total_activities",\n columns: [\n // reps[] carries email and nothing else \u2014 a mailto is the honest\n // affordance, since no MCP tool messages a rep.\n { key: "name", label: "Rep", cell: (r) => {\n const a = document.createElement("a");\n a.href = `mailto:${r.email}`; a.textContent = r.name || r.email;\n return a;\n } },\n { key: "total_activities", label: "Activities", num: true },\n { key: "meetings_or_interest", label: "Meetings", num: true },\n { key: "lost", label: "Lost", num: true },\n ],\n emptyTitle: "No reps in this window",\n emptyHint: "The backend scopes non-admins to themselves.",\n }),\n );\n});\nrefreshBtn.onclick = () => team.refresh();\n```\n\nFive things this gets right that a hand-built dashboard usually does not:\n\n- **An empty window is a real answer.** A quiet team returns `total_activities:\n 0` for every rep and `trend: []` \u2014 the common case on a new account. Draw\n `lb-empty` with what it means ("no activity logged in this window") and a way\n out (widen the range), never empty axes, which read as a broken chart.\n- **Chart from the tokens, not a CDN.** `lb-chart` styles an inline SVG from\n the theme, so it reads in light and dark. A hardcoded stroke disappears in\n one of the two, and a CDN chart that fails to load shows nothing at all \u2014 a\n sparse weekly series does not earn the dependency.\n- **Sorting is CLIENT-side here**, unlike a lead list: `reps` is the whole team\n in one response, so re-sorting reorders data you already hold. Reflect it in\n `aria-sort` on the header, not only with an arrow.\n- **Tabular numerals on every count** (`data-num`), or the columns will not\n line up and the leaderboard cannot be scanned down.\n- **Name the window.** `range.from`/`range.to` are resolved server-side and may\n not match what was asked for; printing them is what makes the figures\n auditable.\n\n**Writing to a rep is not in this payload.** `reps[]` carries `user_id`, `name`\nand `email` \u2014 enough to open a mail client with `mailto:`, and nothing more.\nThere is no MCP tool that messages a rep, so do not render a "message" button\nthat silently does nothing; a `mailto:` link is the honest affordance until\none exists.\n\n## Recipe: live enrichment\n\n```js\nconst job = lb.enrichment({ leadIds: [LEAD], titles: ["CEO", "VP Sales"], ask: ASK });\njob.subscribe((j) => {\n const p = j.data && j.data.overall_progress; // {done,total,done_ratio}\n renderBar(p);\n if (j.done) renderContacts(j.data.leads); // enriched contacts\n});\nrefreshBtn.onclick = () => job.refresh();\n```\n\n## Write-call rules\n\nThe domain factories handle these for you. A page\'s buttons never call\n`leadbay_report_outreach` \u2014 see *Writing from a page*; the rule below is for\nan agent logging an outreach itself. If you hand-roll an action:\n`leadbay_report_outreach` args MUST include `verification:{source:"user_confirmed", ref}`\nAND `_triggered_by`; `leadbay_add_leads_to_campaign` needs `_triggered_by`;\n`add_note`/`like_lead`/`dislike_lead` take only their own args. `epilogue_status` is\none of `lb.EPILOGUE_STATUSES`. Snoozing (pushback) is advanced-gated \u2014 not\ncallable from a default artifact. Org lead status IS on the default surface:\nuse `lb.setStatus`, which owns the arg shape AND the partial-write check \u2014\n`leadbay_set_lead_status` writes each lead separately, so it can resolve 200\nwith a non-empty `failed[]`. Hand-rolling that action will report a green\nbutton over a write that never landed.\n\n## Degradation + live updates\n\nIf the host bridge is absent, a view-model\'s `.error` is set with `.error.unavailable\n=== true` (bind helpers set `data-lb-state="unavailable"`) \u2014 nothing throws. Every\ncall also has a **30s timeout** (configurable via `lb.configure({ timeoutMs })`): a\nhost call that never settles becomes `.error` with `code:"timeout"`, so a control is\nnever stuck loading forever \u2014 always render the `.error` branch so the user can retry.\nAuto-poll (`pollEvery`) depends on the cowork host serving FRESH reads; `.refresh()`\nis the guaranteed manual path \u2014 always wire a Refresh control for polling resources.\n\n\n## Runtime diagnostics (automatic \u2014 you do not wire this)\n\nThe runtime reports its own failures to the Leadbay team so a broken artifact\ndoes not stay invisible: a picker that loads zero options, a button blocked by\nvalidation, a call that timed out, failed, came back as an error envelope, or\nanswered with unparseable text. It sends these itself, over the same bridge,\ndeduped and capped \u2014 **you do not need to add anything**, and you should not\ncall `leadbay_report_artifact_error` yourself.\n\nOnly bounded values travel: the failure kind, which view-model surfaced it, the\ntool name, an error code, and the kit version. Never a message, never user text,\nnever anything the user typed. A user who has turned telemetry off with\n`leadbay_set_telemetry` sends nothing at all.\n\nTwo knobs, both optional. `lb.setTelemetry(false)` opts a page out entirely.\n`lb.report({kind, surface, tool?, code?})` reports a failure the library cannot\nsee \u2014 a render that threw, or a control you wired by hand rather than through a\nview-model. Everything else is automatic.\n\nThis is NOT the way to report a problem the USER raised. If the rep tells you\nthe artifact is wrong and asks you to pass it on, that is\n`leadbay_report_friction`, which carries their own words and needs their\nconsent.';
29065
29535
 
29066
29536
  // ../core/dist/tools/get-artifact-runtime.js
29067
29537
  var artifactKit = {
@@ -29388,6 +29858,7 @@ var compositeWriteTools = [
29388
29858
  refinePrompt,
29389
29859
  answerClarification,
29390
29860
  reportOutreach,
29861
+ setProspectingAction,
29391
29862
  // sendFeedback is granular-shaped (a single call to the telemetry seam →
29392
29863
  // Sentry.captureFeedback, same inbox as the web app's feedback form), so it
29393
29864
  // lives in tools/, NOT composite/. Registered here (not advanced-gated) so
@@ -31335,7 +31806,7 @@ function parseWriteEnv(env = process.env) {
31335
31806
  }
31336
31807
 
31337
31808
  // src/http-server.ts
31338
- var VERSION = true ? "0.40.0" : "0.0.0-dev";
31809
+ var VERSION = true ? "0.41.1" : "0.0.0-dev";
31339
31810
  var PORT = Number(process.env.PORT ?? 8080);
31340
31811
  var HOST = process.env.HOST ?? "0.0.0.0";
31341
31812
  var logger = {