@foldspace_npm/harness 0.1.27 → 0.1.28

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.
Files changed (118) hide show
  1. package/CLAUDE.md +301 -188
  2. package/README.md +54 -36
  3. package/bin/ask.mjs +9 -7
  4. package/bin/attach.mjs +80 -51
  5. package/bin/badge.mjs +28 -8
  6. package/bin/cli.mjs +23 -6
  7. package/bin/inject.mjs +3 -4
  8. package/bin/observe.mjs +22 -6
  9. package/bin/published.mjs +1 -1
  10. package/package.json +6 -2
  11. package/recipes/INDEX.md +13 -8
  12. package/recipes/README.md +30 -7
  13. package/recipes/account-overview/README.md +6 -6
  14. package/recipes/account-overview/agent/actions/show_account_overview.ts +11 -16
  15. package/recipes/account-overview/agent/views/brand.ts +23 -14
  16. package/recipes/account-overview/agent/views/overview.ts +13 -17
  17. package/recipes/bottom-bar/README.md +12 -9
  18. package/recipes/bottom-bar/agent/bottomBar.ts +14 -12
  19. package/recipes/create-a-record/README.md +97 -0
  20. package/recipes/create-a-record/agent/actions/create_project.ts +26 -0
  21. package/recipes/create-a-record/agent/api/projects.ts +62 -0
  22. package/recipes/create-a-record/agent/projects.ts +145 -0
  23. package/recipes/create-a-record/agent/views/approval.ts +181 -0
  24. package/recipes/create-a-record/agent/views/brand.ts +35 -0
  25. package/recipes/create-a-record/agent/views/fields.ts +252 -0
  26. package/recipes/create-a-record/agent/views/project-card.ts +253 -0
  27. package/recipes/create-a-record/agent/views/steps.ts +92 -0
  28. package/recipes/create-a-record/fixtures/owners.ok.json +9 -0
  29. package/recipes/create-a-record/fixtures/project.created.json +1 -0
  30. package/recipes/create-a-record/fixtures/project.rejected.json +1 -0
  31. package/recipes/create-a-record/recipe.json +13 -0
  32. package/recipes/create-a-record/variants/actions/create_project.steps.ts +160 -0
  33. package/recipes/find-by-name/README.md +3 -1
  34. package/recipes/find-by-name/agent/actions/find_project_id.ts +1 -1
  35. package/recipes/lookup-then-view/README.md +73 -0
  36. package/recipes/lookup-then-view/agent/accounts.ts +174 -0
  37. package/recipes/lookup-then-view/agent/actions/find_account.ts +108 -0
  38. package/recipes/lookup-then-view/agent/actions/show_account_overview.ts +42 -0
  39. package/recipes/lookup-then-view/agent/api/accounts.ts +68 -0
  40. package/recipes/lookup-then-view/agent/views/account.ts +183 -0
  41. package/recipes/lookup-then-view/agent/views/brand.ts +35 -0
  42. package/recipes/lookup-then-view/agent/views/format.ts +73 -0
  43. package/recipes/lookup-then-view/agent/views/pick-one.ts +240 -0
  44. package/recipes/lookup-then-view/fixtures/accounts.json +319 -0
  45. package/recipes/lookup-then-view/fixtures/overview.ok.json +122 -0
  46. package/recipes/lookup-then-view/fixtures/overview.unsigned.json +14 -0
  47. package/recipes/lookup-then-view/recipe.json +11 -0
  48. package/recipes/lookup-then-view/studio/find_account.json +10 -0
  49. package/recipes/lookup-then-view/studio/show_account_overview.json +15 -0
  50. package/recipes/opportunities-at-risk/README.md +5 -5
  51. package/recipes/opportunities-at-risk/agent/actions/show_opportunities_at_risk.ts +9 -7
  52. package/recipes/opportunities-at-risk/agent/opportunities.ts +11 -10
  53. package/recipes/opportunities-at-risk/agent/views/at-risk.ts +1 -1
  54. package/recipes/opportunities-at-risk/agent/views/brand.ts +26 -15
  55. package/recipes/pick-from-a-list/README.md +7 -6
  56. package/recipes/pick-from-a-list/agent/actions/choose_project.ts +27 -12
  57. package/recipes/pick-from-a-list/agent/views/brand.ts +30 -9
  58. package/recipes/pick-from-a-list/agent/views/picker.ts +3 -3
  59. package/recipes/prepare-for-a-meeting/README.md +4 -4
  60. package/recipes/prepare-for-a-meeting/agent/actions/prepare_for_meeting.ts +3 -3
  61. package/recipes/records-list/README.md +6 -6
  62. package/recipes/records-list/agent/accounts.ts +3 -1
  63. package/recipes/records-list/agent/actions/list_accounts.ts +7 -7
  64. package/recipes/records-list/agent/views/brand.ts +26 -14
  65. package/recipes/server-rendered-forms/README.md +35 -0
  66. package/recipes/server-rendered-forms/agent/actions/create_record_via_form.ts +62 -0
  67. package/recipes/server-rendered-forms/agent/api/pages.ts +57 -0
  68. package/recipes/server-rendered-forms/agent/forms.ts +192 -0
  69. package/recipes/server-rendered-forms/fixtures/form.errors.html +9 -0
  70. package/recipes/server-rendered-forms/fixtures/form.html +17 -0
  71. package/recipes/server-rendered-forms/fixtures/list.html +9 -0
  72. package/recipes/server-rendered-forms/fixtures/record.html +4 -0
  73. package/recipes/server-rendered-forms/recipe.json +10 -0
  74. package/recipes/several-hosts/README.md +10 -13
  75. package/recipes/several-hosts/agent/hosts.ts +19 -136
  76. package/recipes/several-hosts/fixtures/wrong-page.json +6 -0
  77. package/recipes/several-hosts/recipe.json +3 -3
  78. package/recipes/swap-the-login-method/README.md +16 -5
  79. package/recipes/swap-the-login-method/agent/utils.ts +52 -3
  80. package/recipes/swap-the-login-method/variants/utils.cookies.ts +59 -6
  81. package/recipes/update-meeting-notes/README.md +10 -4
  82. package/recipes/update-meeting-notes/agent/actions/update_meeting_notes.ts +16 -5
  83. package/recipes/upload-contacts/README.md +5 -5
  84. package/recipes/upload-contacts/agent/actions/upload_contacts.ts +7 -7
  85. package/recipes/upload-contacts/agent/views/brand.ts +24 -14
  86. package/recipes/upload-contacts/agent/views/uploader.ts +28 -17
  87. package/recipes/who-is-the-user/README.md +7 -3
  88. package/src/ask-core.mjs +3 -3
  89. package/src/attach-daemon.mjs +9 -2
  90. package/src/attach-helpers.mjs +105 -0
  91. package/src/attach-preflight.mjs +34 -11
  92. package/src/badge-core.mjs +1 -1
  93. package/src/cdp-client.mjs +14 -19
  94. package/src/cdp-ownership.mjs +41 -20
  95. package/src/cli-help.mjs +7 -5
  96. package/src/cli-registry.mjs +65 -46
  97. package/src/diagnostics.mjs +1 -1
  98. package/src/init.mjs +5 -3
  99. package/src/lint/index.mjs +8 -1
  100. package/src/lint/rules.mjs +240 -43
  101. package/src/observe-core.mjs +163 -34
  102. package/src/region.mjs +1 -1
  103. package/src/runtime/agent.ts +16 -12
  104. package/src/runtime/http.ts +25 -15
  105. package/src/runtime/match.ts +1 -1
  106. package/src/runtime/render.ts +4 -4
  107. package/src/transports/README.md +2 -2
  108. package/src/upgrade.mjs +33 -10
  109. package/templates/agent-starter/CLAUDE.md +8 -5
  110. package/templates/agent-starter/README.md +32 -33
  111. package/templates/agent-starter/agent/actions/_example.ts +4 -4
  112. package/templates/agent-starter/agent/actions/index.ts +2 -2
  113. package/templates/agent-starter/agent/constants.ts +4 -3
  114. package/templates/agent-starter/agent/utils.ts +15 -18
  115. package/templates/agent-starter/claude-settings.json +1 -1
  116. package/templates/agent-starter/docs/app-profile.md +19 -14
  117. package/templates/agent-starter/foldspace.dev.json +1 -1
  118. package/recipes/several-hosts/fixtures/nav-record.json +0 -5
@@ -0,0 +1,183 @@
1
+ // The account card, a view card. Once it is drawn, the agent gets what
2
+ // show_account_overview's execute returned - the facts this card draws from:
3
+ // every open deal, person and open case, where the card lists three of each.
4
+ // Laid out to be read in five seconds at chat width:
5
+ //
6
+ // who the account is -> name, industry, size, place, website, owner
7
+ // the money -> four tiles: open pipeline, weighted, deals, cases
8
+ // what is moving -> the biggest open deals, each with stage and close date
9
+ // who to talk to -> the primary contact first, title under the name
10
+ // what is wrong -> open cases, critical ones marked
11
+ //
12
+ // textContent only - never innerHTML with API data. Styles go in `header`.
13
+
14
+ import { isCritical, type Overview } from "../accounts";
15
+ import { brand } from "./brand";
16
+ import { count, DASH, domain, initials, money, moneyShort, plural, relativeDay, titleCase } from "./format";
17
+
18
+ const SHOWN = 3;
19
+
20
+ const CSS = `
21
+ .fs-ac{background:${brand.surface};border:1px solid ${brand.border};border-radius:${brand.radius};font-family:${brand.font};
22
+ color:${brand.text};width:100%;box-sizing:border-box;font-size:13px;line-height:1.35;overflow:hidden}
23
+ .fs-ac *{box-sizing:border-box;margin:0}
24
+ .fs-ac-head{display:flex;gap:12px;align-items:flex-start;padding:14px}
25
+ .fs-ac-logo{width:40px;height:40px;border-radius:10px;background:${brand.accent};color:${brand.accentText};font-weight:600;font-size:14px;
26
+ display:flex;align-items:center;justify-content:center;flex-shrink:0}
27
+ .fs-ac-who{min-width:0;flex:1}
28
+ .fs-ac-name{font-size:16px;font-weight:600;color:${brand.textStrong};line-height:1.25}
29
+ .fs-ac-sub{font-size:12px;color:${brand.muted};margin-top:2px}
30
+ .fs-ac-owner{font-size:11px;color:${brand.faint};margin-top:4px}
31
+ .fs-ac-tiles{display:grid;grid-template-columns:repeat(4,1fr);border-top:1px solid ${brand.border};border-bottom:1px solid ${brand.border};background:${brand.surface2}}
32
+ .fs-ac-tile{padding:10px 8px;text-align:center;border-left:1px solid ${brand.border}}
33
+ .fs-ac-tile:first-child{border-left:0}
34
+ .fs-ac-num{display:block;font-size:17px;font-weight:600;color:${brand.textStrong};font-variant-numeric:tabular-nums}
35
+ .fs-ac-num.is-money{color:${brand.good}}
36
+ .fs-ac-num.is-bad{color:${brand.bad}}
37
+ .fs-ac-cap{display:block;font-size:10px;color:${brand.muted};text-transform:uppercase;letter-spacing:.4px;margin-top:2px}
38
+ .fs-ac-hint{display:block;font-size:10px;color:${brand.bad};margin-top:1px}
39
+ .fs-ac-sec{padding:10px 14px 4px}
40
+ .fs-ac-sec-title{display:flex;justify-content:space-between;font-size:11px;font-weight:600;color:${brand.muted};text-transform:uppercase;letter-spacing:.4px;margin-bottom:4px}
41
+ .fs-ac-row{display:flex;align-items:center;gap:10px;padding:7px 0;border-bottom:1px solid ${brand.border}}
42
+ .fs-ac-row:last-child{border-bottom:0}
43
+ .fs-ac-main{flex:1;min-width:0}
44
+ .fs-ac-title{font-weight:600;color:${brand.textStrong};overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
45
+ .fs-ac-detail{font-size:12px;color:${brand.muted};overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
46
+ .fs-ac-detail .is-late{color:${brand.warn};font-weight:500}
47
+ .fs-ac-amount{text-align:right;font-weight:600;color:${brand.textStrong};font-variant-numeric:tabular-nums;white-space:nowrap}
48
+ .fs-ac-prob{display:block;font-size:11px;font-weight:400;color:${brand.muted}}
49
+ .fs-ac-chip{display:inline-block;padding:1px 7px;border-radius:999px;background:${brand.accentSoft};color:${brand.accent};font-size:11px;font-weight:500}
50
+ .fs-ac-avatar{width:28px;height:28px;border-radius:50%;background:${brand.surface2};color:${brand.text};border:1px solid ${brand.border};
51
+ font-size:11px;font-weight:600;display:flex;align-items:center;justify-content:center;flex-shrink:0}
52
+ .fs-ac-tag{font-size:10px;color:${brand.accent};font-weight:600;margin-left:6px}
53
+ .fs-ac-dot{width:8px;height:8px;border-radius:50%;flex-shrink:0;background:${brand.faint}}
54
+ .fs-ac-dot.is-critical{background:${brand.bad}}
55
+ .fs-ac-dot.is-high{background:${brand.warn}}
56
+ .fs-ac-empty{padding:6px 0 8px;color:${brand.faint};font-size:12px}
57
+ .fs-ac-more{font-size:11px;color:${brand.muted};padding:4px 0 8px}
58
+ `;
59
+
60
+ function el(tag: string, className: string, text?: string): HTMLElement {
61
+ const node = document.createElement(tag);
62
+ node.className = className;
63
+ if (text !== undefined) node.textContent = text;
64
+ return node;
65
+ }
66
+ function tip(node: HTMLElement, text: string): HTMLElement {
67
+ node.title = text;
68
+ return node;
69
+ }
70
+
71
+ function section(title: string, total: number): { box: HTMLElement; list: HTMLElement } {
72
+ const box = el("div", "fs-ac-sec");
73
+ const head = el("div", "fs-ac-sec-title");
74
+ head.append(el("span", "", title), el("span", "", total ? String(total) : ""));
75
+ const list = el("div", "");
76
+ box.append(head, list);
77
+ return { box, list };
78
+ }
79
+
80
+ function more(list: HTMLElement, total: number): void {
81
+ if (total > SHOWN) list.append(el("div", "fs-ac-more", `+ ${total - SHOWN} more`));
82
+ }
83
+
84
+ export function mountAccount(host: HTMLElement, header: HTMLElement, view: Overview, now = Date.now()): void {
85
+ const style = el("style", "");
86
+ style.textContent = CSS;
87
+ header.append(style);
88
+ const { account, metrics } = view;
89
+ const cur = account.currency;
90
+ const card = el("div", "fs-ac");
91
+
92
+ // ---- who the account is
93
+ const head = el("div", "fs-ac-head");
94
+ head.append(el("div", "fs-ac-logo", initials(account.name)));
95
+ const who = el("div", "fs-ac-who");
96
+ who.append(el("div", "fs-ac-name", account.name));
97
+ const facts = [
98
+ account.industry,
99
+ account.employeeCount === null ? null : plural(account.employeeCount, "employee"),
100
+ account.place,
101
+ domain(account.website),
102
+ ].filter(Boolean);
103
+ who.append(el("div", "fs-ac-sub", facts.length ? facts.join(" · ") : "No details on record."));
104
+ if (account.owner) who.append(el("div", "fs-ac-owner", `Account owner: ${account.owner}`));
105
+ head.append(who);
106
+ card.append(head);
107
+
108
+ // ---- the money: short in the tile, exact in the tooltip
109
+ const tiles = el("div", "fs-ac-tiles");
110
+ const tile = (value: string, caption: string, kind = "", exact?: string, hint?: string) => {
111
+ const box = el("div", "fs-ac-tile");
112
+ const num = el("span", `fs-ac-num${kind ? ` is-${kind}` : ""}`, value);
113
+ if (exact) tip(num, exact);
114
+ box.append(num, el("span", "fs-ac-cap", caption));
115
+ if (hint) box.append(el("span", "fs-ac-hint", hint));
116
+ return box;
117
+ };
118
+ tiles.append(
119
+ tile(moneyShort(metrics.openPipeline, cur), "Open pipeline", "money", money(metrics.openPipeline, cur)),
120
+ tile(moneyShort(metrics.weightedPipeline, cur), "Weighted", "", `${money(metrics.weightedPipeline, cur)} - each deal times its probability`),
121
+ tile(count(metrics.openDeals), "Open deals"),
122
+ tile(count(metrics.openCases), "Open cases", metrics.criticalCases ? "bad" : "", undefined, metrics.criticalCases ? `${metrics.criticalCases} critical` : undefined),
123
+ );
124
+ card.append(tiles);
125
+
126
+ // ---- what is moving: biggest open deals first
127
+ const deals = section("Open deals", view.deals.length);
128
+ if (!view.deals.length) deals.list.append(el("div", "fs-ac-empty", "No open deals."));
129
+ for (const deal of view.deals.slice(0, SHOWN)) {
130
+ const row = el("div", "fs-ac-row");
131
+ const main = el("div", "fs-ac-main");
132
+ main.append(el("div", "fs-ac-title", deal.name));
133
+ const detail = el("div", "fs-ac-detail");
134
+ if (deal.stage) detail.append(el("span", "fs-ac-chip", titleCase(deal.stage)), el("span", "", " "));
135
+ const close = relativeDay(deal.closeDate, now);
136
+ if (close) detail.append(tip(el("span", close.past ? "is-late" : "", close.past ? `Close date passed ${close.label}` : `Closes ${close.label}`), close.full));
137
+ main.append(detail);
138
+ const amount = el("div", "fs-ac-amount", money(deal.amount, cur));
139
+ if (deal.probability !== null) amount.append(el("span", "fs-ac-prob", `${Math.round(deal.probability)}%`));
140
+ row.append(main, amount);
141
+ deals.list.append(row);
142
+ }
143
+ more(deals.list, view.deals.length);
144
+ card.append(deals.box);
145
+
146
+ // ---- who to talk to: primary contact first
147
+ const people = section("People", view.people.length);
148
+ if (!view.people.length) people.list.append(el("div", "fs-ac-empty", "No contacts on record."));
149
+ for (const person of view.people.slice(0, SHOWN)) {
150
+ const row = el("div", "fs-ac-row");
151
+ row.append(el("div", "fs-ac-avatar", initials(person.name)));
152
+ const main = el("div", "fs-ac-main");
153
+ const name = el("div", "fs-ac-title", person.name);
154
+ if (person.primary) name.append(el("span", "fs-ac-tag", "PRIMARY"));
155
+ main.append(name);
156
+ main.append(el("div", "fs-ac-detail", [person.title, person.email, person.phone].filter(Boolean).join(" · ") || DASH));
157
+ row.append(main);
158
+ people.list.append(row);
159
+ }
160
+ more(people.list, view.people.length);
161
+ card.append(people.box);
162
+
163
+ // ---- what is wrong: open cases, critical ones marked
164
+ if (view.cases.length) {
165
+ const cases = section("Open cases", view.cases.length);
166
+ for (const item of view.cases.slice(0, SHOWN)) {
167
+ const row = el("div", "fs-ac-row");
168
+ const level = isCritical(item.priority) ? " is-critical" : /^high$/i.test(item.priority ?? "") ? " is-high" : "";
169
+ row.append(el("div", `fs-ac-dot${level}`));
170
+ const main = el("div", "fs-ac-main");
171
+ main.append(el("div", "fs-ac-title", item.title));
172
+ const opened = relativeDay(item.createdAt, now);
173
+ main.append(el("div", "fs-ac-detail", [item.priority ? titleCase(item.priority) : null, opened ? `opened ${opened.label}` : null].filter(Boolean).join(" · ") || DASH));
174
+ row.append(main);
175
+ cases.list.append(row);
176
+ }
177
+ more(cases.list, view.cases.length);
178
+ card.append(cases.box);
179
+ }
180
+
181
+ host.textContent = "";
182
+ host.append(card);
183
+ }
@@ -0,0 +1,35 @@
1
+ // Placeholder with the keys of the agent/views/brand.ts that
2
+ // `npx foldspace observe styles --write` writes. When you copy this recipe,
3
+ // keep the project's own brand.ts; if it has none yet, run that command.
4
+
5
+ export const brand = {
6
+ surface: "#FFFFFF",
7
+ surface2: "#F6F7FB",
8
+ text: "#374151",
9
+ muted: "#6B7280",
10
+ border: "#E5E7EB",
11
+ accent: "#3248F2",
12
+ accentText: "#FFFFFF",
13
+ font: 'Roboto, -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif',
14
+ radius: "10px",
15
+ size: { base: "14px", small: "12px" },
16
+ textStrong: "#111827",
17
+ faint: "#9CA3AF",
18
+ row: "#F9FAFB",
19
+ tableHead: "#F3F4F6",
20
+ accentDeep: "#2538D6",
21
+ accentSoft: "#EEF0FE",
22
+ headerStart: "#F0F1FE",
23
+ good: "#047857",
24
+ warn: "#B45309",
25
+ warnInk: "#FEF3C7",
26
+ bad: "#B91C1C",
27
+ primary: "#3248F2",
28
+ tint: "#F3F4F6",
29
+ disabled: "#9CA3AF",
30
+ money: "#047857",
31
+ ok: "#047857",
32
+ deals: "#3248F2",
33
+ contacts: "#2538D6",
34
+ cases: "#B45309",
35
+ } as const;
@@ -0,0 +1,73 @@
1
+ // How people, accounts and money read on a card. One place, so every card in
2
+ // the project says "$1.2M", "in 12 days" and "-" the same way.
3
+ //
4
+ // - Money: the account's own currency, no cents. Short in a headline tile
5
+ // ($1.2M) with the full amount in the tooltip; full in a row ($125,000).
6
+ // - Missing is "-", never 0: a deal amount nobody entered is not $0.
7
+ // - Dates: relative ("in 12 days", "3 days ago") with the date in the tooltip.
8
+ // - People: the name first, then the title; contact details as plain text.
9
+
10
+ function locale(): string {
11
+ const doc = typeof document !== "undefined" ? (document as Document & { documentElement?: { lang?: string } }) : null;
12
+ const lang = doc?.documentElement?.lang;
13
+ return lang || (typeof navigator !== "undefined" && navigator.language) || "en-US";
14
+ }
15
+
16
+ export const DASH = "-";
17
+
18
+ export function money(value: number | null, currency = "USD"): string {
19
+ if (value === null) return DASH;
20
+ return new Intl.NumberFormat(locale(), { style: "currency", currency, maximumFractionDigits: 0 }).format(value);
21
+ }
22
+
23
+ export function moneyShort(value: number | null, currency = "USD"): string {
24
+ if (value === null) return DASH;
25
+ return new Intl.NumberFormat(locale(), {
26
+ style: "currency",
27
+ currency,
28
+ notation: "compact",
29
+ maximumFractionDigits: Math.abs(value) >= 1_000_000 ? 1 : 0,
30
+ }).format(value);
31
+ }
32
+
33
+ export function count(value: number | null): string {
34
+ return value === null ? DASH : new Intl.NumberFormat(locale()).format(value);
35
+ }
36
+
37
+ export function plural(n: number, one: string, many = `${one}s`): string {
38
+ return `${count(n)} ${n === 1 ? one : many}`;
39
+ }
40
+
41
+ const DAY = 86_400_000;
42
+
43
+ /** "today", "in 12 days", "3 days ago" - and whether it is in the past. */
44
+ export function relativeDay(iso: string | null, now = Date.now()): { label: string; past: boolean; full: string } | null {
45
+ if (!iso) return null;
46
+ const at = new Date(iso);
47
+ if (Number.isNaN(at.getTime())) return null;
48
+ const days = Math.round((Date.UTC(at.getUTCFullYear(), at.getUTCMonth(), at.getUTCDate()) - Date.UTC(new Date(now).getUTCFullYear(), new Date(now).getUTCMonth(), new Date(now).getUTCDate())) / DAY);
49
+ const label = new Intl.RelativeTimeFormat(locale(), { numeric: "auto" }).format(days, "day");
50
+ const full = new Intl.DateTimeFormat(locale(), { year: "numeric", month: "short", day: "numeric" }).format(at);
51
+ return { label, past: days < 0, full };
52
+ }
53
+
54
+ /** Letters only: "Contoso Branch 01" is CB, not C0. */
55
+ export function initials(name: string): string {
56
+ const words = name.split(/\s+/).filter((word) => /^\p{L}/u.test(word));
57
+ if (!words.length) return "-";
58
+ return (words.length > 1 ? words[0][0] + words[1][0] : words[0].slice(0, 2)).toUpperCase();
59
+ }
60
+
61
+ /** "https://www.northwind.example/about" -> "northwind.example": shown as text, never as a link out of the app. */
62
+ export function domain(website: string | null): string | null {
63
+ if (!website) return null;
64
+ try {
65
+ return new URL(/^https?:\/\//.test(website) ? website : `https://${website}`).hostname.replace(/^www\./, "");
66
+ } catch {
67
+ return website;
68
+ }
69
+ }
70
+
71
+ export function titleCase(value: string): string {
72
+ return value.replace(/[_-]+/g, " ").replace(/\b\p{L}/gu, (c) => c.toUpperCase());
73
+ }
@@ -0,0 +1,240 @@
1
+ // Pick one: the card for "which one did you mean?" - any entity, any list
2
+ // length. Search, pages, one click. Use it wherever the agent has to ask the
3
+ // user to choose a record; it is not specific to accounts.
4
+ //
5
+ // - Long lists: only one page is ever on screen; the next comes from the app.
6
+ // - Search: typing asks the app again (after a short pause); the newest answer wins.
7
+ // - One click chooses, by default. `confirm: true` adds a button instead, for
8
+ // choices that should be deliberate.
9
+ // - "None of these" and "Cancel" are answers too. Exactly one answer, ever.
10
+ // - After the answer the card shrinks to one line saying what was chosen, so
11
+ // the conversation history reads right.
12
+ // textContent only - never innerHTML with API data. Styles go in `header`.
13
+
14
+ import { brand } from "./brand";
15
+ import { initials } from "./format";
16
+
17
+ export type PickRow = { id: string; primary: string; secondary?: string | null; meta?: string | null };
18
+ export type PickPage = { rows: PickRow[]; total: number };
19
+
20
+ export type PickOptions = {
21
+ title: string;
22
+ note?: string | null;
23
+ query?: string;
24
+ placeholder?: string;
25
+ initial: PickPage;
26
+ /** Shown first when the search found nothing: the closest names. */
27
+ suggested?: PickRow[];
28
+ pageSize: number;
29
+ fetchPage: (query: string, offset: number) => Promise<PickPage | { error: string }>;
30
+ confirm?: boolean;
31
+ confirmLabel?: string;
32
+ debounceMs?: number;
33
+ onChoose: (row: PickRow) => void;
34
+ onNone: () => void;
35
+ onCancel: () => void;
36
+ };
37
+
38
+ const CSS = `
39
+ .fs-p1{background:${brand.surface};border:1px solid ${brand.border};border-radius:${brand.radius};font-family:${brand.font};
40
+ color:${brand.text};width:100%;box-sizing:border-box;font-size:13px;line-height:1.35;overflow:hidden}
41
+ .fs-p1 *{box-sizing:border-box;margin:0}
42
+ .fs-p1-head{padding:12px 14px 8px}
43
+ .fs-p1-title{font-size:14px;font-weight:600;color:${brand.textStrong}}
44
+ .fs-p1-note{font-size:12px;color:${brand.muted};margin-top:2px}
45
+ .fs-p1-search{display:block;width:calc(100% - 28px);margin:4px 14px 8px;padding:8px 10px;border:1px solid ${brand.border};
46
+ border-radius:8px;font:inherit;color:${brand.textStrong};background:${brand.surface}}
47
+ .fs-p1-search:focus{outline:2px solid ${brand.accentSoft};border-color:${brand.accent}}
48
+ .fs-p1-label{padding:6px 14px 2px;font-size:10px;font-weight:600;letter-spacing:.5px;text-transform:uppercase;color:${brand.faint}}
49
+ .fs-p1-list{display:flex;flex-direction:column;border-top:1px solid ${brand.border}}
50
+ .fs-p1-row{display:flex;align-items:center;gap:10px;width:100%;text-align:left;padding:9px 14px;border:0;border-bottom:1px solid ${brand.border};
51
+ background:${brand.surface};font:inherit;color:inherit;cursor:pointer}
52
+ .fs-p1-row:hover{background:${brand.surface2}}
53
+ .fs-p1-row:focus{outline:none}
54
+ .fs-p1-row:focus-visible{background:${brand.accentSoft};box-shadow:inset 3px 0 0 ${brand.accent}}
55
+ .fs-p1-row.is-selected{background:${brand.accentSoft};box-shadow:inset 3px 0 0 ${brand.accent}}
56
+ .fs-p1-mark{width:28px;height:28px;border-radius:8px;background:${brand.accentSoft};color:${brand.accent};font-weight:600;font-size:11px;
57
+ display:flex;align-items:center;justify-content:center;flex-shrink:0}
58
+ .fs-p1-who{flex:1;min-width:0}
59
+ .fs-p1-primary{font-weight:600;color:${brand.textStrong};overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
60
+ .fs-p1-secondary{font-size:12px;color:${brand.muted};overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
61
+ .fs-p1-meta{font-size:12px;color:${brand.muted};white-space:nowrap}
62
+ .fs-p1-state{padding:14px;color:${brand.muted};font-size:12px}
63
+ .fs-p1-error{padding:10px 14px;color:${brand.bad};font-size:12px}
64
+ .fs-p1-label:empty,.fs-p1-error:empty{display:none}
65
+ .fs-p1-foot{display:flex;align-items:center;gap:8px;padding:8px 14px}
66
+ .fs-p1-count{flex:1;font-size:12px;color:${brand.muted};font-variant-numeric:tabular-nums}
67
+ .fs-p1-btn{font:inherit;font-size:12px;padding:5px 10px;border-radius:7px;border:1px solid ${brand.border};background:${brand.surface};color:${brand.text};cursor:pointer}
68
+ .fs-p1-btn:disabled{opacity:.45;cursor:default}
69
+ .fs-p1-primary-btn{background:${brand.accent};border-color:${brand.accent};color:${brand.accentText}}
70
+ .fs-p1-link{font:inherit;font-size:12px;border:0;background:none;color:${brand.muted};cursor:pointer;padding:5px 4px}
71
+ .fs-p1-link:hover{color:${brand.textStrong}}
72
+ .fs-p1-done{display:flex;align-items:center;gap:8px;padding:9px 12px;font-size:12px;color:${brand.muted}}
73
+ .fs-p1-done b{color:${brand.textStrong};font-weight:600}
74
+ `;
75
+
76
+ function el(tag: string, className: string, text?: string): HTMLElement {
77
+ const node = document.createElement(tag);
78
+ node.className = className;
79
+ if (text !== undefined) node.textContent = text;
80
+ return node;
81
+ }
82
+ function button(className: string, text: string): HTMLButtonElement {
83
+ const node = el("button", className, text) as HTMLButtonElement;
84
+ node.type = "button";
85
+ return node;
86
+ }
87
+ function attr(node: HTMLElement, name: string, value: string): void {
88
+ if (typeof node.setAttribute === "function") node.setAttribute(name, value);
89
+ }
90
+ function focus(node: HTMLElement | undefined): void {
91
+ if (node && typeof node.focus === "function") node.focus();
92
+ }
93
+
94
+ export function mountPickOne(host: HTMLElement, header: HTMLElement, options: PickOptions): void {
95
+ const style = el("style", "");
96
+ style.textContent = CSS;
97
+ header.append(style);
98
+
99
+ const pageSize = Math.max(1, options.pageSize);
100
+ let settled = false;
101
+ let query = options.query ?? "";
102
+ let offset = 0;
103
+ let page: PickPage = options.initial;
104
+ let showingSuggested = Boolean(options.suggested?.length);
105
+ let selected: PickRow | null = null;
106
+ let request = 0;
107
+ let timer: ReturnType<typeof setTimeout> | null = null;
108
+
109
+ const card = el("div", "fs-p1");
110
+ const head = el("div", "fs-p1-head");
111
+ head.append(el("div", "fs-p1-title", options.title));
112
+ const note = el("div", "fs-p1-note", options.note ?? "");
113
+ if (options.note) head.append(note);
114
+ const search = el("input", "fs-p1-search") as HTMLInputElement;
115
+ search.type = "search";
116
+ search.value = query;
117
+ search.placeholder = options.placeholder ?? "Search";
118
+ attr(search, "aria-label", options.placeholder ?? "Search");
119
+ const label = el("div", "fs-p1-label");
120
+ const list = el("div", "fs-p1-list");
121
+ attr(list, "role", "listbox");
122
+ const error = el("div", "fs-p1-error");
123
+ const foot = el("div", "fs-p1-foot");
124
+ const counter = el("div", "fs-p1-count");
125
+ const prev = button("fs-p1-btn", "Previous");
126
+ const next = button("fs-p1-btn", "Next");
127
+ const go = button("fs-p1-btn fs-p1-primary-btn", options.confirmLabel ?? "Open");
128
+ go.disabled = true;
129
+ const all = button("fs-p1-link", "Browse all");
130
+ const none = button("fs-p1-link", "None of these");
131
+ const cancel = button("fs-p1-link", "Cancel");
132
+
133
+ const finish = (line: HTMLElement, answer: () => void) => {
134
+ if (settled) return;
135
+ settled = true;
136
+ if (timer) clearTimeout(timer);
137
+ host.textContent = "";
138
+ host.append(line);
139
+ answer();
140
+ };
141
+ const choose = (row: PickRow) => {
142
+ const line = el("div", "fs-p1-done");
143
+ line.append(el("span", "", "Chosen:"), el("b", "", row.primary));
144
+ if (row.secondary) line.append(el("span", "", row.secondary));
145
+ finish(line, () => options.onChoose(row));
146
+ };
147
+
148
+ const draw = () => {
149
+ list.textContent = "";
150
+ error.textContent = "";
151
+ const rows = showingSuggested ? options.suggested ?? [] : page.rows;
152
+ label.textContent = showingSuggested ? "Closest names" : "";
153
+ if (all.style) all.style.display = showingSuggested ? "" : "none";
154
+ if (!rows.length) {
155
+ list.append(el("div", "fs-p1-state", query ? `Nothing matches "${query}".` : "Nothing to choose from."));
156
+ }
157
+ const buttons: HTMLButtonElement[] = [];
158
+ rows.forEach((row, index) => {
159
+ const item = button(`fs-p1-row${selected?.id === row.id ? " is-selected" : ""}`, "");
160
+ attr(item, "role", "option");
161
+ item.append(el("div", "fs-p1-mark", initials(row.primary)));
162
+ const who = el("div", "fs-p1-who");
163
+ who.append(el("div", "fs-p1-primary", row.primary));
164
+ if (row.secondary) who.append(el("div", "fs-p1-secondary", row.secondary));
165
+ item.append(who);
166
+ if (row.meta) item.append(el("div", "fs-p1-meta", row.meta));
167
+ item.addEventListener("click", () => {
168
+ if (settled) return;
169
+ if (!options.confirm) return choose(row);
170
+ selected = row;
171
+ go.disabled = false;
172
+ draw();
173
+ });
174
+ item.addEventListener("keydown", (event: Event) => {
175
+ const key = (event as KeyboardEvent).key;
176
+ if (key === "ArrowDown") focus(buttons[index + 1]);
177
+ if (key === "ArrowUp") focus(index === 0 ? search : buttons[index - 1]);
178
+ });
179
+ buttons.push(item);
180
+ list.append(item);
181
+ });
182
+ const paged = !showingSuggested && page.total > pageSize;
183
+ // A count only when there is more than one page: "1-2 of 2" is noise.
184
+ counter.textContent = paged ? `${offset + 1}-${Math.min(offset + page.rows.length, page.total)} of ${page.total}` : "";
185
+ if (foot.style) foot.style.display = paged || options.confirm ? "" : "none";
186
+ prev.disabled = !paged || offset === 0;
187
+ next.disabled = !paged || offset + pageSize >= page.total;
188
+ if (prev.style) prev.style.display = paged ? "" : "none";
189
+ if (next.style) next.style.display = paged ? "" : "none";
190
+ };
191
+
192
+ const load = async (nextQuery: string, nextOffset: number) => {
193
+ const mine = ++request;
194
+ const answer = await options.fetchPage(nextQuery, nextOffset);
195
+ if (mine !== request || settled) return; // a newer search, or already answered
196
+ if ("error" in answer) {
197
+ error.textContent = answer.error;
198
+ return;
199
+ }
200
+ query = nextQuery;
201
+ offset = nextOffset;
202
+ page = answer;
203
+ // The note follows what is on screen, not the first question.
204
+ note.textContent = query ? `${page.total} ${page.total === 1 ? "match" : "matches"} for "${query}"` : "";
205
+ if (!note.parentNode && note.textContent) head.append(note);
206
+ showingSuggested = false;
207
+ selected = null;
208
+ go.disabled = true;
209
+ draw();
210
+ };
211
+
212
+ search.addEventListener("input", () => {
213
+ if (settled) return;
214
+ if (timer) clearTimeout(timer);
215
+ const typed = search.value.trim();
216
+ timer = setTimeout(() => void load(typed, 0), options.debounceMs ?? 250);
217
+ });
218
+ search.addEventListener("keydown", (event: Event) => {
219
+ if ((event as KeyboardEvent).key === "ArrowDown") focus(list.querySelector?.("button") as HTMLElement | undefined);
220
+ });
221
+ all.addEventListener("click", () => {
222
+ if (settled) return;
223
+ showingSuggested = false;
224
+ draw();
225
+ });
226
+ prev.addEventListener("click", () => load(query, Math.max(0, offset - pageSize)));
227
+ next.addEventListener("click", () => load(query, offset + pageSize));
228
+ go.addEventListener("click", () => selected && choose(selected));
229
+ none.addEventListener("click", () => finish(el("div", "fs-p1-done", "None of these."), options.onNone));
230
+ cancel.addEventListener("click", () => finish(el("div", "fs-p1-done", "Cancelled."), options.onCancel));
231
+
232
+ foot.append(counter, prev, next);
233
+ if (options.confirm) foot.append(go);
234
+ const actions = el("div", "fs-p1-foot");
235
+ actions.append(all, none, cancel);
236
+ card.append(head, search, label, list, error, foot, actions);
237
+ draw();
238
+ host.textContent = "";
239
+ host.append(card);
240
+ }