@foldspace_npm/harness 0.1.19 → 0.1.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +16 -3
- package/bin/attach.mjs +8 -23
- package/bin/badge.mjs +7 -7
- package/package.json +1 -1
- package/recipes/INDEX.md +1 -0
- package/recipes/account-overview/README.md +2 -2
- package/recipes/account-overview/agent/views/brand.ts +22 -18
- package/recipes/account-overview/agent/views/overview.ts +12 -11
- package/recipes/opportunities-at-risk/README.md +1 -1
- package/recipes/opportunities-at-risk/agent/views/at-risk.ts +1 -1
- package/recipes/opportunities-at-risk/agent/views/brand.ts +20 -16
- package/recipes/records-list/README.md +27 -0
- package/recipes/records-list/agent/accounts.ts +67 -0
- package/recipes/records-list/agent/actions/list_accounts.ts +52 -0
- package/recipes/records-list/agent/api/accounts.ts +21 -0
- package/recipes/records-list/agent/views/brand.ts +23 -0
- package/recipes/records-list/agent/views/list.ts +93 -0
- package/recipes/records-list/fixtures/accounts.empty.json +1 -0
- package/recipes/records-list/fixtures/accounts.ok.json +5 -0
- package/recipes/records-list/fixtures/accounts.unsigned.json +1 -0
- package/recipes/records-list/recipe.json +10 -0
- package/recipes/upload-contacts/README.md +1 -1
- package/recipes/upload-contacts/agent/views/brand.ts +21 -17
- package/recipes/upload-contacts/agent/views/uploader.ts +12 -12
- package/src/badge-core.mjs +2 -2
- package/src/cli-registry.mjs +5 -5
- package/src/lint/rules.mjs +28 -0
package/CLAUDE.md
CHANGED
|
@@ -77,8 +77,9 @@ at the app or testing in it, run `npx foldspace badge working` (a bar across
|
|
|
77
77
|
the top of the window says Foldspace is building here, leave it alone) and say
|
|
78
78
|
so in chat: *"I'm going to work in that Chrome window for a few minutes -
|
|
79
79
|
please leave it alone until I tell you it's ready."* When it is their turn,
|
|
80
|
-
`npx foldspace badge ready
|
|
81
|
-
|
|
80
|
+
`npx foldspace badge ready` takes the pill away (the small corner tag comes
|
|
81
|
+
back) and in chat you say what to try. No bar for their turn: they may type
|
|
82
|
+
whatever, whenever; the pill only ever asks them to wait. `foldspace badge label` puts the small
|
|
82
83
|
corner tag back; `off` removes it, for a screenshot they will share.
|
|
83
84
|
|
|
84
85
|
Navigation, uploads, extraction and anything that writes are a later session.
|
|
@@ -409,6 +410,18 @@ Each of these cost a real build.
|
|
|
409
410
|
in the component. The action's instructions tell the model not to restate or
|
|
410
411
|
recompute them: a model that adds up line items will eventually add them up
|
|
411
412
|
wrong.
|
|
413
|
+
- **A card matches the panel, not the page.** The card sits in the agent's
|
|
414
|
+
panel - white by default (the agent's theme: `backgroundColor #FFFFFF`,
|
|
415
|
+
`primaryColor` for accents; a `darkTheme` when the panel is dark, and the
|
|
416
|
+
panel follows the OS colour scheme, not the app's own toggle). `observe
|
|
417
|
+
styles` samples the page: a dark app gives a dark card in a white panel
|
|
418
|
+
(seen 2026-09-22). Start from the recipe palettes, put the agent's
|
|
419
|
+
`primaryColor` in `accent`, and take only font and radius from the page.
|
|
420
|
+
- **Plain hex or rgba only.** The panel saves every card to the conversation
|
|
421
|
+
history as an image drawn with html2canvas, which cannot read `oklch()`,
|
|
422
|
+
`lab()` or `color-mix()`: one such value and the saved card is blank
|
|
423
|
+
(`data:,` where the card was). Lint flags them. Do not read the app's CSS
|
|
424
|
+
variables either - on modern apps they resolve to oklch.
|
|
412
425
|
|
|
413
426
|
## Layout
|
|
414
427
|
|
|
@@ -438,7 +451,7 @@ placeholder path and fields with what you observed. They match the harness
|
|
|
438
451
|
version you have installed, and every one is built and run on each release.
|
|
439
452
|
|
|
440
453
|
L0 → `who-is-the-user`. L2 → `find-by-name`, and `pick-from-a-list` when the
|
|
441
|
-
user has to choose. Show one account on a card → `account-overview`. Upload a
|
|
454
|
+
user has to choose. The user's records on one card → `records-list`. Show one account on a card → `account-overview`. Upload a
|
|
442
455
|
CSV of contacts → `upload-contacts`. Save a note on a meeting →
|
|
443
456
|
`update-meeting-notes`. Prepare for a meeting → `prepare-for-a-meeting`. The records that need
|
|
444
457
|
attention, with reasons → `opportunities-at-risk`.
|
package/bin/attach.mjs
CHANGED
|
@@ -225,7 +225,8 @@ const SDK_PATTERNS = [
|
|
|
225
225
|
// label - the small corner tag (default): who is attached, which mode
|
|
226
226
|
// working - a pill at the foot of the window: the agent is building in this
|
|
227
227
|
// window, please leave it alone
|
|
228
|
-
// ready - the
|
|
228
|
+
// ready - the pill goes away and the small tag comes back: their turn.
|
|
229
|
+
// No bar for that - they may type whatever, whenever
|
|
229
230
|
// The pill sits bottom-left, never across the top: a bar there covered the
|
|
230
231
|
// app's own header and menu (seen on a CRM, 2026-09-22). The agent's panel
|
|
231
232
|
// is on the right, so bottom-left is the corner nothing else uses.
|
|
@@ -252,34 +253,18 @@ const badgeSrc = `(() => {
|
|
|
252
253
|
if (s.state === "working") {
|
|
253
254
|
// Attention, not decoration: a pulsing marker, the ask in bold, and an
|
|
254
255
|
// animated line underneath so it reads as "in progress" at a glance.
|
|
255
|
-
b.style.cssText = base + "top:auto;bottom:16px;left:16px;right:auto;max-width:min(560px,calc(100vw - 32px));border-radius:8px;overflow:hidden;background:#
|
|
256
|
+
b.style.cssText = base + "top:auto;bottom:16px;left:16px;right:auto;max-width:min(560px,calc(100vw - 32px));border-radius:8px;overflow:hidden;background:#3247F2;padding:10px 14px 13px;font-size:13px;letter-spacing:.02em;text-align:left;line-height:1.3;";
|
|
256
257
|
b.innerHTML =
|
|
257
258
|
'<style>@keyframes fsPulse{0%,100%{opacity:1}50%{opacity:.25}}@keyframes fsSweep{0%{left:-30%}100%{left:100%}}' +
|
|
258
|
-
'#foldspace-dev-badge .fs-dot{display:inline-block;width:10px;height:10px;border-radius:50%;background:#
|
|
259
|
+
'#foldspace-dev-badge .fs-dot{display:inline-block;width:10px;height:10px;border-radius:50%;background:#FFD314;margin-right:10px;vertical-align:middle;animation:fsPulse 1s ease-in-out infinite}' +
|
|
259
260
|
'#foldspace-dev-badge .fs-line{position:absolute;left:0;right:0;bottom:0;height:3px;background:rgba(255,255,255,.15);overflow:hidden}' +
|
|
260
|
-
'#foldspace-dev-badge .fs-line::after{content:"";position:absolute;top:0;width:30%;height:100%;background:#
|
|
261
|
-
'<span class="fs-dot"></span>Foldspace is building your agent on this page. <b style="color:#
|
|
261
|
+
'#foldspace-dev-badge .fs-line::after{content:"";position:absolute;top:0;width:30%;height:100%;background:#FFD314;animation:fsSweep 1.4s linear infinite}</style>' +
|
|
262
|
+
'<span class="fs-dot"></span>Foldspace is building your agent on this page. <b style="color:#FFD314">Please leave this window alone</b> until the agent says it is ready.' +
|
|
262
263
|
(s.text ? " " + s.text.replace(/[<>&]/g, (c) => ({ "<": "<", ">": ">", "&": "&" })[c]) : "") +
|
|
263
264
|
'<span class="fs-line"></span>';
|
|
264
|
-
} else if (s.state === "ready") {
|
|
265
|
-
// Calm, and useful: the question to try, with a Copy button so nobody
|
|
266
|
-
// retypes it into the panel. The bar accepts clicks only on that button.
|
|
267
|
-
const esc = (t) => String(t).replace(/[<>&"]/g, (c) => ({ "<": "<", ">": ">", "&": "&", '"': """ })[c]);
|
|
268
|
-
const question = s.text ? esc(s.text) : "";
|
|
269
|
-
b.style.cssText = base + "top:auto;bottom:16px;left:16px;right:auto;max-width:min(560px,calc(100vw - 32px));border-radius:8px;overflow:hidden;background:#0F9D58;padding:10px 14px;font-size:13px;letter-spacing:.02em;text-align:left;line-height:1.3;pointer-events:none;";
|
|
270
|
-
b.innerHTML =
|
|
271
|
-
'<style>#foldspace-dev-badge .fs-copy{pointer-events:auto;margin-left:12px;padding:4px 10px;border:1px solid rgba(255,255,255,.7);border-radius:4px;background:transparent;color:#fff;font:inherit;font-size:12px;cursor:pointer;letter-spacing:0}' +
|
|
272
|
-
'#foldspace-dev-badge .fs-copy:hover{background:rgba(255,255,255,.15)}</style>' +
|
|
273
|
-
'<b>Ready - your turn.</b> ' +
|
|
274
|
-
(question ? 'Try asking: <span class="fs-q">' + question + '</span><button class="fs-copy" type="button">Copy</button>' : "Open your agent and try it.");
|
|
275
|
-
const btn = b.querySelector(".fs-copy");
|
|
276
|
-
if (btn) btn.addEventListener("click", () => {
|
|
277
|
-
const q = b.querySelector(".fs-q")?.textContent || "";
|
|
278
|
-
const done = () => { btn.textContent = "Copied"; setTimeout(() => { btn.textContent = "Copy"; }, 1500); };
|
|
279
|
-
if (navigator.clipboard?.writeText) navigator.clipboard.writeText(q).then(done, done);
|
|
280
|
-
else { const ta = document.createElement("textarea"); ta.value = q; document.body.appendChild(ta); ta.select(); try { document.execCommand("copy"); } catch (e) {} ta.remove(); done(); }
|
|
281
|
-
});
|
|
282
265
|
} else {
|
|
266
|
+
// "ready" and "label" both draw the small tag: their turn needs no bar -
|
|
267
|
+
// they may type whatever, whenever. Only the wait ever gets a pill.
|
|
283
268
|
b.style.cssText = base + "background:#3247F2;padding:5px 10px;border-bottom-right-radius:4px;";
|
|
284
269
|
b.textContent = LABEL;
|
|
285
270
|
}
|
package/bin/badge.mjs
CHANGED
|
@@ -2,14 +2,14 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* foldspace badge <working|ready|label|off> [--text "<what to show>"]
|
|
4
4
|
*
|
|
5
|
-
* Sets the
|
|
6
|
-
* The agent and the human share that one Chrome window, so the
|
|
7
|
-
*
|
|
5
|
+
* Sets the pill the attach badge draws at the foot of the test window.
|
|
6
|
+
* The agent and the human share that one Chrome window, so the pill says
|
|
7
|
+
* when to wait:
|
|
8
8
|
* working - "Foldspace is building your agent on this page. Please leave
|
|
9
|
-
* this window alone until
|
|
10
|
-
* ready -
|
|
11
|
-
*
|
|
12
|
-
* label -
|
|
9
|
+
* this window alone until the agent says it is ready."
|
|
10
|
+
* ready - the pill goes away, the small corner tag comes back: their
|
|
11
|
+
* turn. No bar for that - they may type whatever, whenever.
|
|
12
|
+
* label - the small corner tag
|
|
13
13
|
* off - remove it
|
|
14
14
|
* The state is kept in the page's sessionStorage, so it survives navigation;
|
|
15
15
|
* attach's badge script redraws within a second. Nothing else on the page is
|
package/package.json
CHANGED
package/recipes/INDEX.md
CHANGED
|
@@ -13,6 +13,7 @@ Read this whole table, then open the recipe closest to the outcome.
|
|
|
13
13
|
| [`upload-contacts`](upload-contacts/) | **L4** | A CSV is previewed, then written only after the user chooses Import | An action with a card that waits for the user | 1 production build |
|
|
14
14
|
| [`update-meeting-notes`](update-meeting-notes/) | **L4** | A note is saved on a meeting and the new note id comes back | A write. **No card** | 1 production build |
|
|
15
15
|
| [`prepare-for-a-meeting`](prepare-for-a-meeting/) | **L2** | Meeting facts are gathered in code, then a task agent writes the summary | A data action plus a task agent. **No card** | 1 production build |
|
|
16
|
+
| [`records-list`](records-list/) | **L2** | The user's records on one card: a row each with name, two facts and a number, matched to the agent's white panel. The model does not see the card | A view-only card. **No** `awaitUserInput` | 1 production build |
|
|
16
17
|
| [`opportunities-at-risk`](opportunities-at-risk/) | **L2** | Every open opportunity the app flags as at risk, on one card: count, total, and why each one is at risk. The model does not see the card | A view-only card. **No** `awaitUserInput` | 1 production build |
|
|
17
18
|
|
|
18
19
|
**Not here yet — no production build proves it:** the user's own plan or
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
**Proven by 1 production build.**
|
|
6
6
|
|
|
7
|
-
This card is the base layout. Copy it, then replace the `/__observe_me` routes with the app's own paths
|
|
7
|
+
This card is the base layout. Copy it, then replace the `/__observe_me` routes with the app's own paths. The palette is light because the card sits in the agent's white panel; put the agent's own primary colour in `brand.ts` (`accent`), and re-sample only font and radius from the page.
|
|
8
8
|
|
|
9
9
|
The card is view-only. `awaitUserInput` stays off. For a component that only shows something, the model is told that the information was displayed — nothing `execute` returned and nothing the card drew. One tab is on screen at a time; an empty tab says so. A signed-out or failed call is drawn with `renderFailure`, never as zeros. A 200 full of nulls is signed out when the account has no id and no name.
|
|
10
10
|
|
|
@@ -16,7 +16,7 @@ In Agent Studio: an action with key `show_account_overview` and one string param
|
|
|
16
16
|
|---|---|
|
|
17
17
|
| `agent/api/accounts.ts` | Mock path and envelope. Find a real API that returns a similar shape |
|
|
18
18
|
| `agent/accounts.ts` | Which fields a summary carries. The pipeline total is summed here |
|
|
19
|
-
| `agent/views/overview.ts` | The card. Routes under `/__observe_me` are placeholders for the app's own paths.
|
|
19
|
+
| `agent/views/overview.ts` | The card. Routes under `/__observe_me` are placeholders for the app's own paths. `brand.ts` matches the panel, not the page |
|
|
20
20
|
|
|
21
21
|
## What that build learned the hard way
|
|
22
22
|
|
|
@@ -1,22 +1,26 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
1
|
+
// The card lives in the agent's panel, which is white by default (the agent's
|
|
2
|
+
// theme: backgroundColor #FFFFFF, primaryColor for accents) - so the palette
|
|
3
|
+
// matches the panel, not the page. Plain hex/rgba only: the panel saves every
|
|
4
|
+
// card to history as an image drawn with html2canvas, which cannot read
|
|
5
|
+
// oklch()/lab()/color-mix(). Take `accent` from the agent's own primaryColor
|
|
6
|
+
// (get_agent_settings); re-sample font and radius from the page if you want
|
|
7
|
+
// them to match; swap for a dark set only when the panel itself is dark.
|
|
4
8
|
|
|
5
9
|
export const brand = {
|
|
6
|
-
surface: "#
|
|
7
|
-
headerStart: "#
|
|
8
|
-
border: "#
|
|
9
|
-
text: "#
|
|
10
|
-
textStrong: "#
|
|
11
|
-
muted: "#
|
|
12
|
-
faint: "#
|
|
13
|
-
accent: "#
|
|
14
|
-
accentDeep: "#
|
|
15
|
-
money: "#
|
|
16
|
-
deals: "#
|
|
17
|
-
contacts: "#
|
|
18
|
-
cases: "#
|
|
19
|
-
row: "
|
|
20
|
-
font: '-apple-system, BlinkMacSystemFont, "Segoe UI",
|
|
10
|
+
surface: "#FFFFFF",
|
|
11
|
+
headerStart: "#F0F1FE",
|
|
12
|
+
border: "#E5E7EB",
|
|
13
|
+
text: "#374151",
|
|
14
|
+
textStrong: "#111827",
|
|
15
|
+
muted: "#6B7280",
|
|
16
|
+
faint: "#9CA3AF",
|
|
17
|
+
accent: "#3248F2",
|
|
18
|
+
accentDeep: "#2538D6",
|
|
19
|
+
money: "#047857",
|
|
20
|
+
deals: "#2563EB",
|
|
21
|
+
contacts: "#7C3AED",
|
|
22
|
+
cases: "#B45309",
|
|
23
|
+
row: "#F9FAFB",
|
|
24
|
+
font: 'Roboto, -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif',
|
|
21
25
|
radius: "8px",
|
|
22
26
|
} as const;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// The account card from the production build. textContent only — never innerHTML
|
|
2
2
|
// with API data. Styles go in `header`. Routes under `/__observe_me` are
|
|
3
|
-
// placeholders: replace them with the app's own paths.
|
|
3
|
+
// placeholders: replace them with the app's own paths. Light palette: the
|
|
4
|
+
// card sits in the agent's white panel (see brand.ts).
|
|
4
5
|
|
|
5
6
|
import {
|
|
6
7
|
formatMoney,
|
|
@@ -32,7 +33,7 @@ const CSS = `
|
|
|
32
33
|
.fs-ov-sub{font-size:12px;color:${brand.muted};margin-bottom:12px;line-height:1.3}
|
|
33
34
|
.fs-ov-grid{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-top:12px}
|
|
34
35
|
.fs-ov-label{font-size:10px;color:${brand.muted};text-transform:uppercase;letter-spacing:.5px;margin-bottom:2px}
|
|
35
|
-
.fs-ov-value{font-size:12px;color
|
|
36
|
+
.fs-ov-value{font-size:12px;color:${brand.textStrong};font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
36
37
|
.fs-ov-value a{color:${brand.accent};text-decoration:none}
|
|
37
38
|
.fs-ov-metrics{display:grid;grid-template-columns:repeat(4, 1fr);gap:1px;background:${brand.border}}
|
|
38
39
|
.fs-ov-metric{padding:12px;background:${brand.surface};display:flex;flex-direction:column;gap:2px;text-align:center;align-items:center}
|
|
@@ -53,15 +54,15 @@ const CSS = `
|
|
|
53
54
|
.fs-ov-detail{font-size:11px;color:${brand.muted};line-height:1.3}
|
|
54
55
|
.fs-ov-money{font-weight:600;color:${brand.money};font-size:12px}
|
|
55
56
|
.fs-ov-badge{display:inline-block;padding:2px 8px;border-radius:8px;font-size:10px;font-weight:500}
|
|
56
|
-
.fs-ov-stage-proposal{background:#
|
|
57
|
-
.fs-ov-stage-qualified,.fs-ov-stage-qualification,.fs-ov-stage-discovery{background:#
|
|
58
|
-
.fs-ov-stage-negotiation{background:#
|
|
59
|
-
.fs-ov-stage-closed-won,.fs-ov-stage-closed_won{background:#
|
|
60
|
-
.fs-ov-stage-closed-lost,.fs-ov-stage-closed_lost{background:#
|
|
61
|
-
.fs-ov-priority-critical{background:#
|
|
62
|
-
.fs-ov-priority-high{background:#
|
|
63
|
-
.fs-ov-priority-medium{background:#
|
|
64
|
-
.fs-ov-priority-low{background:#
|
|
57
|
+
.fs-ov-stage-proposal{background:#F3E8FF;color:#6B21A8}
|
|
58
|
+
.fs-ov-stage-qualified,.fs-ov-stage-qualification,.fs-ov-stage-discovery{background:#DBEAFE;color:#1E40AF}
|
|
59
|
+
.fs-ov-stage-negotiation{background:#FFEDD5;color:#9A3412}
|
|
60
|
+
.fs-ov-stage-closed-won,.fs-ov-stage-closed_won{background:#DCFCE7;color:#166534}
|
|
61
|
+
.fs-ov-stage-closed-lost,.fs-ov-stage-closed_lost{background:#F3F4F6;color:#4B5563}
|
|
62
|
+
.fs-ov-priority-critical{background:#FEE2E2;color:#B91C1C}
|
|
63
|
+
.fs-ov-priority-high{background:#FFEDD5;color:#C2410C}
|
|
64
|
+
.fs-ov-priority-medium{background:#FEF9C3;color:#A16207}
|
|
65
|
+
.fs-ov-priority-low{background:#DBEAFE;color:#1D4ED8}
|
|
65
66
|
.fs-ov-empty{text-align:center;padding:20px 16px;color:${brand.faint};font-style:italic;font-size:12px}
|
|
66
67
|
.fs-ov-more{display:flex;justify-content:flex-end;margin-bottom:12px}
|
|
67
68
|
.fs-ov-more a{font-size:11px;color:${brand.accent};text-decoration:none}
|
|
@@ -16,7 +16,7 @@ In Agent Studio: an action with key `show_opportunities_at_risk` and no paramete
|
|
|
16
16
|
|---|---|
|
|
17
17
|
| `agent/api/opportunities.ts` | Mock path and envelope. Find the app's own "needs attention" list — the one with reasons per row |
|
|
18
18
|
| `agent/opportunities.ts` | Which fields a row carries. Totals stay computed here |
|
|
19
|
-
| `agent/views/at-risk.ts` | The card.
|
|
19
|
+
| `agent/views/at-risk.ts` | The card. `brand.ts` matches the agent's white panel; put the agent's primary colour in `accent` |
|
|
20
20
|
|
|
21
21
|
## What that build learned the hard way
|
|
22
22
|
|
|
@@ -31,7 +31,7 @@ const CSS = `
|
|
|
31
31
|
.fs-risk-stage{display:inline-block;padding:2px 8px;border-radius:8px;font-size:10px;font-weight:500;background:${brand.headerStart};
|
|
32
32
|
color:${brand.accent};text-transform:capitalize;white-space:nowrap;margin-left:6px}
|
|
33
33
|
.fs-risk-tags{display:flex;flex-wrap:wrap;gap:4px}
|
|
34
|
-
.fs-risk-tag{display:inline-block;padding:2px 8px;border-radius:8px;font-size:10px;font-weight:500;background:${brand.warnInk};color
|
|
34
|
+
.fs-risk-tag{display:inline-block;padding:2px 8px;border-radius:8px;font-size:10px;font-weight:500;background:${brand.warnInk};color:#92400E}
|
|
35
35
|
.fs-risk-empty{text-align:center;padding:24px 16px;color:${brand.faint};font-style:italic;font-size:12px}
|
|
36
36
|
`;
|
|
37
37
|
|
|
@@ -1,20 +1,24 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
1
|
+
// The card lives in the agent's panel, which is white by default (the agent's
|
|
2
|
+
// theme: backgroundColor #FFFFFF, primaryColor for accents) - so the palette
|
|
3
|
+
// matches the panel, not the page. Plain hex/rgba only: the panel saves every
|
|
4
|
+
// card to history as an image drawn with html2canvas, which cannot read
|
|
5
|
+
// oklch()/lab()/color-mix(). Take `accent` from the agent's own primaryColor
|
|
6
|
+
// (get_agent_settings); re-sample font and radius from the page if you want
|
|
7
|
+
// them to match; swap for a dark set only when the panel itself is dark.
|
|
4
8
|
|
|
5
9
|
export const brand = {
|
|
6
|
-
surface: "#
|
|
7
|
-
headerStart: "#
|
|
8
|
-
border: "#
|
|
9
|
-
text: "#
|
|
10
|
-
textStrong: "#
|
|
11
|
-
muted: "#
|
|
12
|
-
faint: "#
|
|
13
|
-
accent: "#
|
|
14
|
-
money: "#
|
|
15
|
-
warn: "#
|
|
16
|
-
warnInk: "#
|
|
17
|
-
row: "
|
|
18
|
-
font: '-apple-system, BlinkMacSystemFont, "Segoe UI",
|
|
10
|
+
surface: "#FFFFFF",
|
|
11
|
+
headerStart: "#F0F1FE",
|
|
12
|
+
border: "#E5E7EB",
|
|
13
|
+
text: "#374151",
|
|
14
|
+
textStrong: "#111827",
|
|
15
|
+
muted: "#6B7280",
|
|
16
|
+
faint: "#9CA3AF",
|
|
17
|
+
accent: "#3248F2",
|
|
18
|
+
money: "#047857",
|
|
19
|
+
warn: "#B45309",
|
|
20
|
+
warnInk: "#FEF3C7",
|
|
21
|
+
row: "#F9FAFB",
|
|
22
|
+
font: 'Roboto, -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif',
|
|
19
23
|
radius: "8px",
|
|
20
24
|
} as const;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# Records list — L2
|
|
2
|
+
|
|
3
|
+
**The user says** "show me my accounts". **The user sees** one card: how many, the total, then a row per account — name, two facts underneath, and the money on the right.
|
|
4
|
+
|
|
5
|
+
**Proven by 1 production build**, where the first version was a five-column table: at chat width (~400px) the columns collapsed and it looked wrong. Rows fixed it. This is the base layout for any list of records — contacts, projects, tickets — not just accounts.
|
|
6
|
+
|
|
7
|
+
The card is view-only. `awaitUserInput` stays off. For a component that only shows something, the model is told that the information was displayed — nothing `execute` returned and nothing the card drew. So "No accounts yet." is on the card, not in a message. The count and the total are computed in code from the rows.
|
|
8
|
+
|
|
9
|
+
The palette matches the agent's panel (white, with the agent's primary colour for the marks), not the page: a card sampled from a dark app sits badly in a white panel. Put the agent's own `primaryColor` in `brand.ts`.
|
|
10
|
+
|
|
11
|
+
In Agent Studio: an action with key `list_accounts` and no parameters.
|
|
12
|
+
|
|
13
|
+
## Adapt it
|
|
14
|
+
|
|
15
|
+
| File | Change |
|
|
16
|
+
|---|---|
|
|
17
|
+
| `agent/api/accounts.ts` | Mock path and envelope. Find the call the app's own list screen makes — a bare array or a wrapped one both work |
|
|
18
|
+
| `agent/accounts.ts` | The two facts and the number a row carries |
|
|
19
|
+
| `agent/views/list.ts` | The card. `brand.ts`: the agent's primary colour in `accent` |
|
|
20
|
+
|
|
21
|
+
## What that build learned the hard way
|
|
22
|
+
|
|
23
|
+
- **Rows, not a table.** Five columns in a 400px panel is a table nobody can read. Name, two facts, one number.
|
|
24
|
+
- **A list has nothing to prove whose it is.** An account has an id and a name; a list does not. The build answered signed-out calls with a 401 (which `apiFetch` maps to `signed_out`); what the code still checks is that a 200 is the list at all. ⚠️ If the app answers a signed-out call with a 200 and an empty array, find something in the response only a signed-in user gets.
|
|
25
|
+
- **Empty is an answer.** Zero rows draws a card that says so — never a failure.
|
|
26
|
+
- **Match the panel.** The first card copied the app's dark theme (sampled with `observe styles` while the app was in dark mode) and landed in a white panel.
|
|
27
|
+
- **Rows are not links.** Open a record with a navigation route.
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// Row shapes, and the total the header shows - computed here from the rows,
|
|
2
|
+
// never by the model.
|
|
3
|
+
|
|
4
|
+
import type { AccountsPayload } from "./api/accounts";
|
|
5
|
+
|
|
6
|
+
export type AccountRow = {
|
|
7
|
+
id: string;
|
|
8
|
+
name: string;
|
|
9
|
+
industry: string | null;
|
|
10
|
+
employeeCount: number | null;
|
|
11
|
+
openOpportunities: number | null;
|
|
12
|
+
pipelineValue: number;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export type AccountsList = {
|
|
16
|
+
count: number;
|
|
17
|
+
pipelineValue: number;
|
|
18
|
+
accounts: AccountRow[];
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/** The rows array, wherever the app puts it - or null when the 200 is not a list at all. */
|
|
22
|
+
export function rowsOf(payload: AccountsPayload): unknown[] | null {
|
|
23
|
+
if (Array.isArray(payload)) return payload;
|
|
24
|
+
if (payload && typeof payload === "object" && Array.isArray(payload.accounts)) return payload.accounts;
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function formatMoney(value: number): string {
|
|
29
|
+
const rounded = Math.round(value);
|
|
30
|
+
const sign = rounded < 0 ? "-" : "";
|
|
31
|
+
const digits = String(Math.abs(rounded));
|
|
32
|
+
return `${sign}$${digits.replace(/\B(?=(\d{3})+(?!\d))/g, ",")}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The header shortens money: $18K, $2.4M. Rows stay full. */
|
|
36
|
+
export function formatShort(value: number): string {
|
|
37
|
+
const amount = Math.abs(value);
|
|
38
|
+
const sign = value < 0 ? "-" : "";
|
|
39
|
+
if (amount >= 1_000_000) return `${sign}$${(amount / 1_000_000).toFixed(1)}M`;
|
|
40
|
+
if (amount >= 1000) return `${sign}$${(amount / 1000).toFixed(0)}K`;
|
|
41
|
+
return `${sign}$${amount.toFixed(0)}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function toNumber(value: number | string | null | undefined): number | null {
|
|
45
|
+
const parsed = typeof value === "string" ? Number(value) : value;
|
|
46
|
+
return typeof parsed === "number" && Number.isFinite(parsed) ? parsed : null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function toList(payload: AccountsPayload): AccountsList {
|
|
50
|
+
const accounts = (rowsOf(payload) ?? []).map((raw) => {
|
|
51
|
+
const row = (raw ?? {}) as Record<string, unknown>;
|
|
52
|
+
const name = typeof row.name === "string" ? row.name.trim() : "";
|
|
53
|
+
return {
|
|
54
|
+
id: typeof row.id === "string" ? row.id.trim() : "",
|
|
55
|
+
name: name || "Untitled",
|
|
56
|
+
industry: typeof row.industry === "string" && row.industry.trim() ? row.industry.trim() : null,
|
|
57
|
+
employeeCount: toNumber(row.employeeCount as number | null),
|
|
58
|
+
openOpportunities: toNumber(row.openOpportunities as number | null),
|
|
59
|
+
pipelineValue: toNumber(row.pipelineValue as number | string | null) ?? 0,
|
|
60
|
+
};
|
|
61
|
+
});
|
|
62
|
+
return {
|
|
63
|
+
count: accounts.length,
|
|
64
|
+
pipelineValue: accounts.reduce((sum, row) => sum + row.pipelineValue, 0),
|
|
65
|
+
accounts,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// View-only card. Do not set awaitUserInput: the model is told only that the
|
|
2
|
+
// information was displayed, so the card carries every fact - including "no
|
|
3
|
+
// accounts yet". A failed or signed-out call is drawn with renderFailure,
|
|
4
|
+
// never as an empty list.
|
|
5
|
+
|
|
6
|
+
import { getAccounts } from "../api/accounts";
|
|
7
|
+
import { rowsOf, toList, type AccountsList } from "../accounts";
|
|
8
|
+
import { renderFailure, type FailureReason } from "../utils";
|
|
9
|
+
import { mountAccountsList } from "../views/list";
|
|
10
|
+
|
|
11
|
+
type ListResult = {
|
|
12
|
+
success: boolean;
|
|
13
|
+
message?: string;
|
|
14
|
+
error?: string;
|
|
15
|
+
reason?: FailureReason;
|
|
16
|
+
data?: AccountsList;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export const list_accounts = {
|
|
20
|
+
execute: async (): Promise<ListResult> => {
|
|
21
|
+
const res = await getAccounts();
|
|
22
|
+
if (!res.ok) {
|
|
23
|
+
console.warn("[list_accounts]", res.status, res.reason, res.detail);
|
|
24
|
+
return { success: false, error: res.error, reason: res.reason };
|
|
25
|
+
}
|
|
26
|
+
// A 200 that is not the list at all (a login page, an empty object) is not
|
|
27
|
+
// "no accounts". The build answered signed-out calls with a 401 - this is
|
|
28
|
+
// the second guard. A list has nothing else to prove whose it is.
|
|
29
|
+
if (rowsOf(res.data) === null) {
|
|
30
|
+
return { success: false, error: "Not signed in.", reason: "signed_out" };
|
|
31
|
+
}
|
|
32
|
+
// `message` never reaches the model for a view-only action; it is for the console.
|
|
33
|
+
return { success: true, message: "Displayed.", data: toList(res.data) };
|
|
34
|
+
},
|
|
35
|
+
|
|
36
|
+
render: (result: ListResult | undefined, host: HTMLElement, header: HTMLElement) => {
|
|
37
|
+
if (!result?.success || !result.data) {
|
|
38
|
+
renderFailure(
|
|
39
|
+
host,
|
|
40
|
+
{
|
|
41
|
+
ok: false,
|
|
42
|
+
status: 0,
|
|
43
|
+
error: result?.error ?? "The accounts could not be loaded.",
|
|
44
|
+
reason: result?.reason ?? "server",
|
|
45
|
+
},
|
|
46
|
+
{ notFound: "The accounts list could not be found." },
|
|
47
|
+
);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
mountAccountsList(host, header, result.data);
|
|
51
|
+
},
|
|
52
|
+
};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// Mock API. This path is not a real product. Find the app's own list call -
|
|
2
|
+
// the one its list screen loads - and replace the path and the envelope with
|
|
3
|
+
// what you observed. `__observe_me` cannot succeed until you do.
|
|
4
|
+
|
|
5
|
+
import { apiFetch, type ApiResult } from "../utils";
|
|
6
|
+
|
|
7
|
+
export type AccountRecord = {
|
|
8
|
+
id?: string | null;
|
|
9
|
+
name?: string | null;
|
|
10
|
+
industry?: string | null;
|
|
11
|
+
employeeCount?: number | null;
|
|
12
|
+
openOpportunities?: number | null;
|
|
13
|
+
pipelineValue?: number | string | null;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/** The production build answered with a bare array; other apps wrap it. */
|
|
17
|
+
export type AccountsPayload = AccountRecord[] | { accounts?: AccountRecord[] | null } | null;
|
|
18
|
+
|
|
19
|
+
export async function getAccounts(): Promise<ApiResult<AccountsPayload>> {
|
|
20
|
+
return apiFetch<AccountsPayload>("/__observe_me/accounts");
|
|
21
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// The card lives in the agent's panel, which is white by default (the agent's
|
|
2
|
+
// theme: backgroundColor #FFFFFF, primaryColor for accents) - so the palette
|
|
3
|
+
// matches the panel, not the page. Plain hex/rgba only: the panel saves every
|
|
4
|
+
// card to history as an image drawn with html2canvas, which cannot read
|
|
5
|
+
// oklch()/lab()/color-mix(). Take `accent` from the agent's own primaryColor
|
|
6
|
+
// (get_agent_settings); re-sample font and radius from the page if you want
|
|
7
|
+
// them to match; swap for a dark set only when the panel itself is dark.
|
|
8
|
+
|
|
9
|
+
export const brand = {
|
|
10
|
+
surface: "#FFFFFF",
|
|
11
|
+
headerStart: "#F0F1FE",
|
|
12
|
+
border: "#E5E7EB",
|
|
13
|
+
text: "#374151",
|
|
14
|
+
textStrong: "#111827",
|
|
15
|
+
muted: "#6B7280",
|
|
16
|
+
faint: "#9CA3AF",
|
|
17
|
+
accent: "#3248F2",
|
|
18
|
+
money: "#047857",
|
|
19
|
+
row: "#F9FAFB",
|
|
20
|
+
rowHover: "#F3F4F6",
|
|
21
|
+
font: 'Roboto, -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif',
|
|
22
|
+
radius: "8px",
|
|
23
|
+
} as const;
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// A list of records at chat width (~400px): a header with the count and the
|
|
2
|
+
// total, then one row per record - name, two facts, and one number on the
|
|
3
|
+
// right. Not a table: five columns do not fit, and a row reads better.
|
|
4
|
+
// textContent only - never innerHTML with API data. Styles go in `header`.
|
|
5
|
+
//
|
|
6
|
+
// Rows are not links. To open a record from here, use a navigation route
|
|
7
|
+
// (CLAUDE.md, "Product defaults").
|
|
8
|
+
|
|
9
|
+
import { formatMoney, formatShort, type AccountsList } from "../accounts";
|
|
10
|
+
import { brand } from "./brand";
|
|
11
|
+
|
|
12
|
+
const CSS = `
|
|
13
|
+
.fs-list{background:${brand.surface};border-radius:${brand.radius};font-family:${brand.font};color:${brand.text};
|
|
14
|
+
border:1px solid ${brand.border};overflow:hidden;width:100%;box-sizing:border-box;font-size:13px;line-height:1.35}
|
|
15
|
+
.fs-list *{box-sizing:border-box;margin:0}
|
|
16
|
+
.fs-list-head{display:flex;align-items:baseline;justify-content:space-between;gap:12px;padding:12px 14px;
|
|
17
|
+
background:${brand.headerStart};border-bottom:1px solid ${brand.border}}
|
|
18
|
+
.fs-list-title{font-size:15px;font-weight:600;color:${brand.textStrong}}
|
|
19
|
+
.fs-list-total{font-size:12px;color:${brand.muted};white-space:nowrap}
|
|
20
|
+
.fs-list-total b{color:${brand.money};font-weight:600}
|
|
21
|
+
.fs-list-rows{display:flex;flex-direction:column}
|
|
22
|
+
.fs-list-row{display:flex;align-items:center;gap:12px;padding:10px 14px;border-bottom:1px solid ${brand.border}}
|
|
23
|
+
.fs-list-row:last-child{border-bottom:none}
|
|
24
|
+
.fs-list-row:nth-child(even){background:${brand.row}}
|
|
25
|
+
.fs-list-mark{width:30px;height:30px;border-radius:8px;background:${brand.accent};color:#FFFFFF;font-weight:600;font-size:11px;
|
|
26
|
+
display:flex;align-items:center;justify-content:center;flex-shrink:0}
|
|
27
|
+
.fs-list-who{flex:1;min-width:0}
|
|
28
|
+
.fs-list-name{font-weight:600;color:${brand.textStrong};overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
29
|
+
.fs-list-facts{font-size:11px;color:${brand.muted};overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
30
|
+
.fs-list-num{text-align:right;flex-shrink:0}
|
|
31
|
+
.fs-list-money{font-weight:600;color:${brand.money};white-space:nowrap}
|
|
32
|
+
.fs-list-sub{font-size:11px;color:${brand.faint};white-space:nowrap}
|
|
33
|
+
.fs-list-empty{text-align:center;padding:24px 16px;color:${brand.faint};font-style:italic;font-size:12px}
|
|
34
|
+
`;
|
|
35
|
+
|
|
36
|
+
function el(tag: string, className: string, text?: string): HTMLElement {
|
|
37
|
+
const node = document.createElement(tag);
|
|
38
|
+
node.className = className;
|
|
39
|
+
if (text !== undefined) node.textContent = text;
|
|
40
|
+
return node;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function initials(name: string): string {
|
|
44
|
+
const letters = name
|
|
45
|
+
.split(/\s+/)
|
|
46
|
+
.map((word) => word[0] ?? "")
|
|
47
|
+
.join("")
|
|
48
|
+
.slice(0, 2)
|
|
49
|
+
.toUpperCase();
|
|
50
|
+
return letters || "-";
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function mountAccountsList(host: HTMLElement, header: HTMLElement, list: AccountsList): void {
|
|
54
|
+
const style = el("style", "");
|
|
55
|
+
style.textContent = CSS;
|
|
56
|
+
header.append(style);
|
|
57
|
+
|
|
58
|
+
const card = el("div", "fs-list");
|
|
59
|
+
const head = el("div", "fs-list-head");
|
|
60
|
+
head.append(el("div", "fs-list-title", `${list.count} account${list.count === 1 ? "" : "s"}`));
|
|
61
|
+
const total = el("div", "fs-list-total", "Pipeline ");
|
|
62
|
+
total.append(el("b", "", formatShort(list.pipelineValue)));
|
|
63
|
+
head.append(total);
|
|
64
|
+
card.append(head);
|
|
65
|
+
|
|
66
|
+
if (list.accounts.length === 0) {
|
|
67
|
+
card.append(el("div", "fs-list-empty", "No accounts yet."));
|
|
68
|
+
} else {
|
|
69
|
+
const rows = el("div", "fs-list-rows");
|
|
70
|
+
for (const account of list.accounts) {
|
|
71
|
+
const row = el("div", "fs-list-row");
|
|
72
|
+
row.append(el("div", "fs-list-mark", initials(account.name)));
|
|
73
|
+
const who = el("div", "fs-list-who");
|
|
74
|
+
who.append(el("div", "fs-list-name", account.name));
|
|
75
|
+
const facts = [
|
|
76
|
+
account.industry,
|
|
77
|
+
account.employeeCount === null ? null : `${account.employeeCount.toLocaleString()} employees`,
|
|
78
|
+
].filter((fact): fact is string => fact !== null);
|
|
79
|
+
who.append(el("div", "fs-list-facts", facts.length ? facts.join(" · ") : "No details"));
|
|
80
|
+
row.append(who);
|
|
81
|
+
const num = el("div", "fs-list-num");
|
|
82
|
+
num.append(el("div", "fs-list-money", formatMoney(account.pipelineValue)));
|
|
83
|
+
const deals = account.openOpportunities;
|
|
84
|
+
num.append(el("div", "fs-list-sub", deals === null ? "" : `${deals} open deal${deals === 1 ? "" : "s"}`));
|
|
85
|
+
row.append(num);
|
|
86
|
+
rows.append(row);
|
|
87
|
+
}
|
|
88
|
+
card.append(rows);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
host.textContent = "";
|
|
92
|
+
host.append(card);
|
|
93
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
[]
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
[
|
|
2
|
+
{ "id": "acct_1", "name": "Northwind", "industry": "Logistics", "employeeCount": 500, "openOpportunities": 3, "pipelineValue": 210000 },
|
|
3
|
+
{ "id": "acct_2", "name": "Contoso", "industry": "Software", "employeeCount": 1200, "openOpportunities": 1, "pipelineValue": "45000" },
|
|
4
|
+
{ "id": "acct_3", "name": "Fabrikam", "industry": null, "employeeCount": null, "openOpportunities": 0, "pipelineValue": 0 }
|
|
5
|
+
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{ "redirect": "/login" }
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
{
|
|
2
|
+
"title": "Records list",
|
|
3
|
+
"level": "L2",
|
|
4
|
+
"family": "show-a-list",
|
|
5
|
+
"kind": "action",
|
|
6
|
+
"action": "list_accounts",
|
|
7
|
+
"entry": "agent/actions/list_accounts.ts",
|
|
8
|
+
"outcome": "The user's records on one card - a row each with name, two facts and a number - matched to the agent's panel",
|
|
9
|
+
"provenBy": 1
|
|
10
|
+
}
|
|
@@ -14,7 +14,7 @@ In Agent Studio: an action with key `upload_contacts` and no parameters. The fil
|
|
|
14
14
|
|---|---|
|
|
15
15
|
| `agent/api/contacts.ts` | Mock import path. Find a real API that accepts a list of contacts and returns created and skipped rows |
|
|
16
16
|
| `agent/contacts.ts` | The columns the file actually has |
|
|
17
|
-
| `agent/views/uploader.ts` | The card. `brand.ts`
|
|
17
|
+
| `agent/views/uploader.ts` | The card. `brand.ts` matches the agent's white panel; put the agent's primary colour in `accent` |
|
|
18
18
|
|
|
19
19
|
## What that build learned the hard way
|
|
20
20
|
|
|
@@ -1,21 +1,25 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
1
|
+
// The card lives in the agent's panel, which is white by default (the agent's
|
|
2
|
+
// theme: backgroundColor #FFFFFF, primaryColor for accents) - so the palette
|
|
3
|
+
// matches the panel, not the page. Plain hex/rgba only: the panel saves every
|
|
4
|
+
// card to history as an image drawn with html2canvas, which cannot read
|
|
5
|
+
// oklch()/lab()/color-mix(). Take `accent` from the agent's own primaryColor
|
|
6
|
+
// (get_agent_settings); re-sample font and radius from the page if you want
|
|
7
|
+
// them to match; swap for a dark set only when the panel itself is dark.
|
|
4
8
|
|
|
5
9
|
export const brand = {
|
|
6
|
-
surface: "#
|
|
7
|
-
border: "#
|
|
8
|
-
text: "#
|
|
9
|
-
textStrong: "#
|
|
10
|
-
muted: "#
|
|
11
|
-
faint: "#
|
|
12
|
-
accent: "#
|
|
13
|
-
accentDeep: "#
|
|
14
|
-
ok: "#
|
|
15
|
-
warn: "#
|
|
16
|
-
bad: "#
|
|
17
|
-
tableHead: "#
|
|
18
|
-
disabled: "#
|
|
19
|
-
font: '-apple-system, BlinkMacSystemFont, "Segoe UI",
|
|
10
|
+
surface: "#FFFFFF",
|
|
11
|
+
border: "#E5E7EB",
|
|
12
|
+
text: "#374151",
|
|
13
|
+
textStrong: "#111827",
|
|
14
|
+
muted: "#6B7280",
|
|
15
|
+
faint: "#9CA3AF",
|
|
16
|
+
accent: "#3248F2",
|
|
17
|
+
accentDeep: "#2538D6",
|
|
18
|
+
ok: "#047857",
|
|
19
|
+
warn: "#B45309",
|
|
20
|
+
bad: "#B91C1C",
|
|
21
|
+
tableHead: "#F3F4F6",
|
|
22
|
+
disabled: "#9CA3AF",
|
|
23
|
+
font: 'Roboto, -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif',
|
|
20
24
|
radius: "8px",
|
|
21
25
|
} as const;
|
|
@@ -17,13 +17,13 @@ const CSS = `
|
|
|
17
17
|
.fs-up-icon{width:20px;height:20px;color:${brand.accent};flex-shrink:0}
|
|
18
18
|
.fs-up-icon.is-ok{color:${brand.ok}}
|
|
19
19
|
.fs-up-zone{border:2px dashed ${brand.border};border-radius:6px;padding:24px 16px;text-align:center;cursor:pointer;
|
|
20
|
-
background:rgba(
|
|
21
|
-
.fs-up-zone:hover,.fs-up-zone.is-drag{border-color:${brand.accent};background:rgba(
|
|
20
|
+
background:rgba(50,72,242,.04)}
|
|
21
|
+
.fs-up-zone:hover,.fs-up-zone.is-drag{border-color:${brand.accent};background:rgba(50,72,242,.08)}
|
|
22
22
|
.fs-up-zone svg{width:40px;height:40px;margin:0 auto 12px;color:${brand.accent};display:block}
|
|
23
23
|
.fs-up-zone-text{font-size:13px;color:${brand.text};margin-bottom:6px}
|
|
24
24
|
.fs-up-hint{font-size:11px;color:${brand.faint}}
|
|
25
25
|
.fs-up-file{display:none}
|
|
26
|
-
.fs-up-sum{display:flex;gap:12px;margin-bottom:12px;padding:12px;background
|
|
26
|
+
.fs-up-sum{display:flex;gap:12px;margin-bottom:12px;padding:12px;background:${brand.tableHead};border-radius:6px;
|
|
27
27
|
flex-wrap:wrap;justify-content:center}
|
|
28
28
|
.fs-up-stat{display:flex;flex-direction:column;gap:2px;flex:1;min-width:80px;align-items:center;text-align:center}
|
|
29
29
|
.fs-up-num{font-size:24px;font-weight:600;color:${brand.textStrong}}
|
|
@@ -34,13 +34,13 @@ const CSS = `
|
|
|
34
34
|
.fs-up-table{width:100%;border-collapse:collapse;font-size:12px}
|
|
35
35
|
.fs-up-table th{background:${brand.tableHead};padding:8px 10px;text-align:left;font-weight:600;color:${brand.muted};
|
|
36
36
|
text-transform:uppercase;font-size:10px;letter-spacing:.5px;border-bottom:1px solid ${brand.border}}
|
|
37
|
-
.fs-up-table td{padding:8px 10px;border-bottom:1px solid ${brand.border};color
|
|
37
|
+
.fs-up-table td{padding:8px 10px;border-bottom:1px solid ${brand.border};color:${brand.text}}
|
|
38
38
|
.fs-up-ready,.fs-up-invalid{display:inline-flex;align-items:center;padding:4px 8px;border-radius:12px;font-size:11px;font-weight:500}
|
|
39
|
-
.fs-up-ready{background
|
|
40
|
-
.fs-up-invalid{background
|
|
39
|
+
.fs-up-ready{background:#ECFDF5;color:${brand.ok}}
|
|
40
|
+
.fs-up-invalid{background:#FEF2F2;color:${brand.bad}}
|
|
41
41
|
.fs-up-actions{display:flex;gap:8px;justify-content:flex-end;margin-top:12px;flex-wrap:wrap}
|
|
42
42
|
.fs-up-btn{padding:8px 16px;border-radius:6px;font-size:13px;font-weight:500;cursor:pointer;white-space:nowrap;font-family:inherit}
|
|
43
|
-
.fs-up-primary{background:${brand.accent};color
|
|
43
|
+
.fs-up-primary{background:${brand.accent};color:#FFFFFF;border:none}
|
|
44
44
|
.fs-up-primary:hover{background:${brand.accentDeep}}
|
|
45
45
|
.fs-up-primary:disabled{background:${brand.disabled};cursor:not-allowed}
|
|
46
46
|
.fs-up-secondary{background:transparent;border:1px solid ${brand.border};color:${brand.text}}
|
|
@@ -52,14 +52,14 @@ const CSS = `
|
|
|
52
52
|
.fs-up-wait-text{margin-top:16px;color:${brand.muted};font-size:14px}
|
|
53
53
|
.fs-up-section{margin-top:12px}
|
|
54
54
|
.fs-up-section-title{font-size:13px;font-weight:600;color:${brand.text};margin-bottom:8px}
|
|
55
|
-
.fs-up-list{max-height:200px;overflow:auto;font-size:12px;line-height:1.5;padding:8px;background
|
|
56
|
-
.fs-up-item{padding:6px 0;border-bottom:1px solid
|
|
57
|
-
.fs-up-name{color
|
|
55
|
+
.fs-up-list{max-height:200px;overflow:auto;font-size:12px;line-height:1.5;padding:8px;background:${brand.tableHead};border-radius:6px}
|
|
56
|
+
.fs-up-item{padding:6px 0;border-bottom:1px solid ${brand.border};display:flex;flex-direction:column;gap:4px}
|
|
57
|
+
.fs-up-name{color:${brand.textStrong};font-weight:500}
|
|
58
58
|
.fs-up-email{color:${brand.faint};font-size:11px}
|
|
59
59
|
.fs-up-reason{color:${brand.warn};font-size:10px;font-style:italic}
|
|
60
|
-
.fs-up-tag{background
|
|
60
|
+
.fs-up-tag{background:#EEF0FE;color:${brand.accent};padding:3px 8px;border-radius:10px;font-size:10px;font-weight:500}
|
|
61
61
|
.fs-up-rule{height:1px;background:${brand.border};margin:12px 0}
|
|
62
|
-
.fs-up-error{padding:10px;background
|
|
62
|
+
.fs-up-error{padding:10px;background:#FEF2F2;border:1px solid #FECACA;border-radius:6px;
|
|
63
63
|
color:${brand.bad};font-size:12px;line-height:1.4}
|
|
64
64
|
`;
|
|
65
65
|
|
package/src/badge-core.mjs
CHANGED
|
@@ -10,8 +10,8 @@ export function badgePayload(state, text) {
|
|
|
10
10
|
}
|
|
11
11
|
const payload = { state: wanted };
|
|
12
12
|
if (text !== undefined) {
|
|
13
|
-
if (wanted !== "working"
|
|
14
|
-
throw new Error(`--text only applies to working
|
|
13
|
+
if (wanted !== "working") {
|
|
14
|
+
throw new Error(`--text only applies to working (got ${wanted}); their turn needs no bar.`);
|
|
15
15
|
}
|
|
16
16
|
const clean = String(text).replace(/\s+/g, " ").trim();
|
|
17
17
|
if (!clean) throw new Error("--text is empty.");
|
package/src/cli-registry.mjs
CHANGED
|
@@ -321,7 +321,7 @@ export const CLI_COMMANDS = Object.freeze([
|
|
|
321
321
|
name: "badge",
|
|
322
322
|
entry: "badge.mjs",
|
|
323
323
|
group: "verify",
|
|
324
|
-
summary: "Set the
|
|
324
|
+
summary: "Set the pill at the foot of the test window: working (leave it alone), ready (pill away), label or off",
|
|
325
325
|
usage: 'foldspace badge <working|ready|label|off> [--text "<what to show>"]',
|
|
326
326
|
risk: "browser-session",
|
|
327
327
|
environment: "local-chrome",
|
|
@@ -332,18 +332,18 @@ export const CLI_COMMANDS = Object.freeze([
|
|
|
332
332
|
name: "state",
|
|
333
333
|
required: true,
|
|
334
334
|
description:
|
|
335
|
-
"working: the agent is building here, leave the window alone | ready: the
|
|
335
|
+
"working: the agent is building here, leave the window alone | ready: the pill goes away, their turn | label: the small corner tag | off: remove it",
|
|
336
336
|
}),
|
|
337
337
|
],
|
|
338
338
|
options: [
|
|
339
|
-
value("--text", "text", "working
|
|
339
|
+
value("--text", "text", "working only: one extra line under the ask (up to 160 characters)"),
|
|
340
340
|
],
|
|
341
341
|
prerequisites: ["foldspace attach is running (it draws the badge)"],
|
|
342
342
|
effects: [
|
|
343
343
|
"Writes one key to the test window's sessionStorage; touches nothing else on the page",
|
|
344
|
-
"The
|
|
344
|
+
"The pill survives navigation inside the test window",
|
|
345
345
|
],
|
|
346
|
-
next: ["
|
|
346
|
+
next: ["Say in chat whose turn it is and what to try"],
|
|
347
347
|
}),
|
|
348
348
|
Object.freeze({
|
|
349
349
|
name: "deploy",
|
package/src/lint/rules.mjs
CHANGED
|
@@ -450,15 +450,43 @@ export function ruleRegistryIntegrity({ indexFile, indexSource, actionFiles, pro
|
|
|
450
450
|
}
|
|
451
451
|
}
|
|
452
452
|
|
|
453
|
+
// The panel saves every rendered card to the conversation history as an image
|
|
454
|
+
// drawn with html2canvas, which cannot parse modern colour functions: one such
|
|
455
|
+
// value and the saved card is blank ("data:," where the card was - seen on a
|
|
456
|
+
// CRM, 2026-09-22). Hex and rgba survive.
|
|
457
|
+
const UNSNAPSHOTTABLE_COLOR = /\b(oklch|oklab|lch|lab|color-mix|light-dark)\s*\(/i;
|
|
458
|
+
|
|
459
|
+
export function ruleNoUnsnapshottableColor(sourceFile, relative, findings) {
|
|
460
|
+
forEachStringLiteral(sourceFile, (node) => {
|
|
461
|
+
if (ts.isImportDeclaration(node.parent) || ts.isExportDeclaration(node.parent)) return;
|
|
462
|
+
const text = node.getText(sourceFile);
|
|
463
|
+
const match = UNSNAPSHOTTABLE_COLOR.exec(text);
|
|
464
|
+
if (!match) return;
|
|
465
|
+
findings.push(
|
|
466
|
+
finding({
|
|
467
|
+
rule: "no-unsnapshottable-color",
|
|
468
|
+
severity: "error",
|
|
469
|
+
sourceFile,
|
|
470
|
+
node,
|
|
471
|
+
file: relative,
|
|
472
|
+
message: `${match[1]}() in a card: the panel saves cards to history as an image drawn with html2canvas, which cannot read it - the saved card comes out blank.`,
|
|
473
|
+
fix: "Use plain hex or rgba. Do not read the app's CSS variables either: on modern apps they resolve to oklch.",
|
|
474
|
+
}),
|
|
475
|
+
);
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
|
|
453
479
|
export function lintActionFile(filePath, relative, findings) {
|
|
454
480
|
if (shouldSkipActionFile(filePath)) return;
|
|
455
481
|
const sourceFile = parseFile(filePath);
|
|
456
482
|
ruleNoStaticAgentPrompt(sourceFile, relative, findings);
|
|
457
483
|
ruleNoRunTaskPrompt(sourceFile, relative, findings);
|
|
458
484
|
ruleNoEmoji(sourceFile, relative, findings);
|
|
485
|
+
ruleNoUnsnapshottableColor(sourceFile, relative, findings);
|
|
459
486
|
}
|
|
460
487
|
|
|
461
488
|
export function lintViewsFile(filePath, relative, findings) {
|
|
462
489
|
const sourceFile = parseFile(filePath);
|
|
463
490
|
ruleNoEmoji(sourceFile, relative, findings, { wholeFile: true });
|
|
491
|
+
ruleNoUnsnapshottableColor(sourceFile, relative, findings);
|
|
464
492
|
}
|