@foldspace_npm/harness 0.1.24 → 0.1.26
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 +20 -10
- package/README.md +6 -0
- package/bin/ask.mjs +10 -5
- package/bin/observe.mjs +22 -8
- package/package.json +1 -1
- package/src/attach-preflight.mjs +2 -1
- package/src/badge-auto.mjs +42 -0
- package/src/cli-registry.mjs +12 -2
- package/src/init.mjs +9 -0
- package/src/observe-core.mjs +104 -0
- package/src/region.mjs +54 -0
- package/src/upgrade.mjs +7 -2
- package/templates/agent-starter/foldspace.dev.json +2 -1
package/CLAUDE.md
CHANGED
|
@@ -21,12 +21,15 @@ something they can **see on their own page** — not at "it builds".
|
|
|
21
21
|
project `CLAUDE.md` does not contain
|
|
22
22
|
`@node_modules/@foldspace_npm/harness/CLAUDE.md`, ask before
|
|
23
23
|
`foldspace upgrade --refresh-instructions`.
|
|
24
|
-
2. **Is Foldspace connected? Check — never assume.**
|
|
24
|
+
2. **Is Foldspace connected? Check — never assume.** `region` in
|
|
25
|
+
`foldspace.dev.json` (absent means `us`) picks the server: `foldspace`, or
|
|
26
|
+
`foldspace-eu` for `eu`. Use that server's tools all session. Call its
|
|
27
|
+
`list_agents`:
|
|
25
28
|
|
|
26
29
|
| Result | Do |
|
|
27
30
|
|---|---|
|
|
28
|
-
| The tool does not exist | The Foldspace MCP is not installed in this editor. Give the one install step for *this* editor — Claude Code: `claude plugin install foldspace-agent-builder@foldspace-plugins`; anything else: the MCP block on <https://foldspace.ai/agents.md
|
|
29
|
-
| It exists but fails on auth | Installed, not signed in. Only the human can approve sign-in. Tell them exactly where — Claude Code: `/mcp` → **foldspace** → finish in the browser — then check again |
|
|
31
|
+
| The tool does not exist | The Foldspace MCP is not installed in this editor. Give the one install step for *this* region and editor — **EU**, any editor: the MCP block on <https://foldspace.ai/eu/agents.md>, never the plugin. **US** — Claude Code: `claude plugin install foldspace-agent-builder@foldspace-plugins`; anything else: the MCP block on <https://foldspace.ai/agents.md>. Then check again |
|
|
32
|
+
| It exists but fails on auth | Installed, not signed in. Only the human can approve sign-in. Tell them exactly where — Claude Code: `/mcp` → **foldspace-eu** (**EU**) or **foldspace** (**US**) → finish in the browser — then check again |
|
|
30
33
|
| It returns agents | One agent: state its name, Agent Key and product in a line and carry on; the human objects if it is wrong. Several: ask which, as a choice |
|
|
31
34
|
|
|
32
35
|
3. **Orient** — `get_agent_settings`, `list_actions`, `list_task_agents`,
|
|
@@ -78,9 +81,11 @@ current CLI contract — risk, prerequisites, effects, next step.
|
|
|
78
81
|
one that creates data - say so instead.
|
|
79
82
|
|
|
80
83
|
**Say when the test window is yours, and when it is theirs - on the page and
|
|
81
|
-
in chat.** You and the human share one Chrome window.
|
|
82
|
-
|
|
83
|
-
|
|
84
|
+
in chat.** You and the human share one Chrome window. `observe` and `ask`
|
|
85
|
+
raise the working pill by themselves for as long as they run, and drop it
|
|
86
|
+
after. For the longer stretch - from the moment you start working on their
|
|
87
|
+
window until it is their turn - run `npx foldspace badge working` (the pill at
|
|
88
|
+
the foot of the window says Foldspace is building here, leave it alone) and say
|
|
84
89
|
so in chat: *"I'm going to work in that Chrome window for a few minutes -
|
|
85
90
|
please leave it alone until I tell you it's ready."* When it is their turn,
|
|
86
91
|
`npx foldspace badge ready` takes the pill away (the small corner tag comes
|
|
@@ -487,10 +492,15 @@ Each of these cost a real build.
|
|
|
487
492
|
- **A card matches the panel, not the page.** The card sits in the agent's
|
|
488
493
|
panel - white by default (the agent's theme: `backgroundColor #FFFFFF`,
|
|
489
494
|
`primaryColor` for accents; a `darkTheme` when the panel is dark, and the
|
|
490
|
-
panel follows the OS colour scheme, not the app's own toggle).
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
`
|
|
495
|
+
panel follows the OS colour scheme, not the app's own toggle). **Before the
|
|
496
|
+
first card, make the brand reference:** `npx foldspace observe styles
|
|
497
|
+
--write` samples the app's own accent, text, border, font and radius and
|
|
498
|
+
writes `agent/views/brand.ts` for the panel's white surface - every card
|
|
499
|
+
imports it, and the recipes' `brand.ts` files are placeholders for it. A
|
|
500
|
+
dark app keeps its accent and font; its light-on-dark text would vanish on
|
|
501
|
+
white, so that falls back to greys and the file says so. Record where the
|
|
502
|
+
values came from in the profile's Brand section. Never copy colours by eye
|
|
503
|
+
(seen 2026-09-22: a dark card in a white panel).
|
|
494
504
|
- **Plain hex or rgba only.** The panel saves every card to the conversation
|
|
495
505
|
history as an image drawn with html2canvas, which cannot read `oklch()`,
|
|
496
506
|
`lab()` or `color-mix()`: one such value and the saved card is blank
|
package/README.md
CHANGED
|
@@ -118,6 +118,12 @@ name (`foldspace-agent` unless you pass a directory). The product ID must be
|
|
|
118
118
|
the bare ID, not the `EU-…-1-1` SDK loader key. The app domain may be a hostname
|
|
119
119
|
or an HTTP(S) URL without a port or path.
|
|
120
120
|
|
|
121
|
+
`--region us|eu` (default `us`) is the Foldspace region the account lives in.
|
|
122
|
+
It is written to `foldspace.dev.json` as `region`, and picks the SDK the
|
|
123
|
+
project loads (`sdkUrl`): each region has its own build, pointing at that
|
|
124
|
+
region's servers. A project with no `region` is US. The values per region live
|
|
125
|
+
in `src/region.mjs`.
|
|
126
|
+
|
|
121
127
|
For safety, `init` requires a target path that does not exist. It does not
|
|
122
128
|
initialize Git or overwrite files. On a TTY it can offer
|
|
123
129
|
`npm install --ignore-scripts` after scaffolding. After creation:
|
package/bin/ask.mjs
CHANGED
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
import { ATTACH_DAEMON_LOG_FILE } from "../src/attach-daemon.mjs";
|
|
28
28
|
import { CdpPage, parseArgs, printJson, sleep } from "../src/cdp-client.mjs";
|
|
29
29
|
import { recordEvent } from "../src/session-events.mjs";
|
|
30
|
+
import { withWorkingBadge } from "../src/badge-auto.mjs";
|
|
30
31
|
import { resolveProjectDir } from "../src/upgrade.mjs";
|
|
31
32
|
|
|
32
33
|
const PANEL_ID = "eucera-agent-iframe";
|
|
@@ -300,11 +301,15 @@ async function main() {
|
|
|
300
301
|
|
|
301
302
|
let attempts = 0;
|
|
302
303
|
let turn;
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
304
|
+
// The working pill is up while the question is typed and answered - the
|
|
305
|
+
// human must not type into the same composer - and down before the pictures.
|
|
306
|
+
await withWorkingBadge(page, async () => {
|
|
307
|
+
do {
|
|
308
|
+
if (attempts > 0) await sleep(everyS * 1000);
|
|
309
|
+
turn = await askOnce();
|
|
310
|
+
attempts += 1;
|
|
311
|
+
} while (turn.sent && shouldRetry({ ...turn, attempts, retries }));
|
|
312
|
+
});
|
|
308
313
|
|
|
309
314
|
if (!turn.sent) {
|
|
310
315
|
finish({ ok: false, question, reason: "Could not type into the composer.", attempts, url }, 1);
|
package/bin/observe.mjs
CHANGED
|
@@ -23,6 +23,7 @@ import path from "node:path";
|
|
|
23
23
|
|
|
24
24
|
import { CdpPage, parseArgs, printJson, readLaunchState, sleep } from "../src/cdp-client.mjs";
|
|
25
25
|
import { withoutStealingFocus } from "../src/keep-focus.mjs";
|
|
26
|
+
import { withWorkingBadge } from "../src/badge-auto.mjs";
|
|
26
27
|
import {
|
|
27
28
|
NAV_ITEM_SELECTOR,
|
|
28
29
|
NAV_SCOPE_SELECTOR,
|
|
@@ -41,6 +42,7 @@ import {
|
|
|
41
42
|
rankCandidates,
|
|
42
43
|
readHint,
|
|
43
44
|
refuseWriteFlags,
|
|
45
|
+
brandFile,
|
|
44
46
|
gotoDestination,
|
|
45
47
|
pageCoverage,
|
|
46
48
|
uncoveredSiblings,
|
|
@@ -56,7 +58,7 @@ const SUBCOMMANDS = {
|
|
|
56
58
|
screen: { values: ["click", "goto", "match", "wait"], flags: [] },
|
|
57
59
|
read: { values: [], flags: [] },
|
|
58
60
|
auth: { values: ["wait"], flags: [] },
|
|
59
|
-
styles: { values: [], flags: [] },
|
|
61
|
+
styles: { values: [], flags: ["write"] },
|
|
60
62
|
screenshot: { values: ["out"], flags: [] },
|
|
61
63
|
"wait-login": { values: ["timeout"], flags: [] },
|
|
62
64
|
};
|
|
@@ -591,7 +593,7 @@ function lowerKeys(wire = {}, pageSet = {}) {
|
|
|
591
593
|
return Object.fromEntries(Object.entries(wire).filter(([name]) => !known.has(name.toLowerCase())));
|
|
592
594
|
}
|
|
593
595
|
|
|
594
|
-
async function runStyles(page) {
|
|
596
|
+
async function runStyles(page, flags = {}) {
|
|
595
597
|
const raw = await page.evaluate(`
|
|
596
598
|
${VISIBLE_JS}
|
|
597
599
|
const clear = (color) =>
|
|
@@ -687,7 +689,7 @@ async function runStyles(page) {
|
|
|
687
689
|
radius: top(radii)[0] || null,
|
|
688
690
|
};`);
|
|
689
691
|
const { button } = raw;
|
|
690
|
-
|
|
692
|
+
const report = {
|
|
691
693
|
ok: true,
|
|
692
694
|
url: printableUrl(raw.url),
|
|
693
695
|
sampledFrom: "computed styles of visible elements",
|
|
@@ -709,6 +711,15 @@ async function runStyles(page) {
|
|
|
709
711
|
boxRadius: raw.radius,
|
|
710
712
|
body: { ...raw.body, color: toHex(raw.body.color) },
|
|
711
713
|
};
|
|
714
|
+
if (!flags.write) return report;
|
|
715
|
+
// The brand reference every card imports. Written, not printed: values
|
|
716
|
+
// copied by hand drift, and re-sampling must be one command.
|
|
717
|
+
const target = path.join(projectDir, "agent", "views", "brand.ts");
|
|
718
|
+
const existed = fs.existsSync(target);
|
|
719
|
+
const { content, notes } = brandFile(report, { url: report.url, date: new Date().toISOString().slice(0, 10) });
|
|
720
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
721
|
+
fs.writeFileSync(target, content, "utf8");
|
|
722
|
+
return { ...report, wrote: path.relative(projectDir, target), replaced: existed, notes };
|
|
712
723
|
}
|
|
713
724
|
|
|
714
725
|
async function runScreenshot(page, flags) {
|
|
@@ -796,11 +807,14 @@ async function main() {
|
|
|
796
807
|
|
|
797
808
|
const page = await CdpPage.open({ projectDir });
|
|
798
809
|
try {
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
if (sub === "
|
|
803
|
-
if (sub === "
|
|
810
|
+
// The working pill goes up while the harness drives the human's window,
|
|
811
|
+
// and comes down after - nobody has to remember it. Not for screenshot:
|
|
812
|
+
// the picture is for the human, and the pill would be in it.
|
|
813
|
+
if (sub === "menu") return await withWorkingBadge(page, () => runMenu(page));
|
|
814
|
+
if (sub === "screen") return await withWorkingBadge(page, () => runScreen(page, flags));
|
|
815
|
+
if (sub === "read") return await withWorkingBadge(page, () => runRead(page, flags, rest));
|
|
816
|
+
if (sub === "auth") return await withWorkingBadge(page, () => runAuth(page, flags));
|
|
817
|
+
if (sub === "styles") return await withWorkingBadge(page, () => runStyles(page, flags));
|
|
804
818
|
return await runScreenshot(page, flags);
|
|
805
819
|
} finally {
|
|
806
820
|
page.close();
|
package/package.json
CHANGED
package/src/attach-preflight.mjs
CHANGED
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
parseActionRequest,
|
|
5
5
|
} from "./attach-helpers.mjs";
|
|
6
6
|
import { ERROR_CODES } from "./protocol.mjs";
|
|
7
|
+
import { SDK_SCRIPT_HOSTS } from "./region.mjs";
|
|
7
8
|
|
|
8
9
|
export function productIdFromAgentKey(key) {
|
|
9
10
|
if (typeof key !== "string") return null;
|
|
@@ -135,7 +136,7 @@ function sdkScriptIdentity(url) {
|
|
|
135
136
|
const parsed = new URL(url);
|
|
136
137
|
if (
|
|
137
138
|
parsed.protocol !== "https:" ||
|
|
138
|
-
parsed.hostname
|
|
139
|
+
!SDK_SCRIPT_HOSTS.includes(parsed.hostname) ||
|
|
139
140
|
!/^\/web\/sdk\/(?:foldspace|eucera)\.js$/i.test(parsed.pathname)
|
|
140
141
|
) {
|
|
141
142
|
return null;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// The working pill, raised by the harness itself while it drives the shared
|
|
2
|
+
// window. `foldspace badge working` exists so the human knows when to leave the
|
|
3
|
+
// window alone - and across a long session of observe and ask it was never
|
|
4
|
+
// set, so both drove the same window (raised by the human, 2026-09-29). The
|
|
5
|
+
// commands know when they are about to drive it, so they raise the pill for
|
|
6
|
+
// as long as they run and put back whatever was there. A pill the agent set
|
|
7
|
+
// on purpose is left alone.
|
|
8
|
+
|
|
9
|
+
export const BADGE_KEY = "__foldspace_badge__";
|
|
10
|
+
|
|
11
|
+
export async function readBadge(page) {
|
|
12
|
+
try {
|
|
13
|
+
const raw = await page.evaluate(`return sessionStorage.getItem(${JSON.stringify(BADGE_KEY)});`);
|
|
14
|
+
return raw ? JSON.parse(raw) : null;
|
|
15
|
+
} catch {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function writeBadge(page, payload) {
|
|
21
|
+
await page.evaluate(
|
|
22
|
+
`sessionStorage.setItem(${JSON.stringify(BADGE_KEY)}, ${JSON.stringify(JSON.stringify(payload))}); return true;`,
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Run `action` with the working pill up, then restore what was there before. */
|
|
27
|
+
export async function withWorkingBadge(page, action) {
|
|
28
|
+
const before = await readBadge(page);
|
|
29
|
+
if (before?.state === "working") return action(); // set on purpose: theirs to drop
|
|
30
|
+
let raised = false;
|
|
31
|
+
try {
|
|
32
|
+
await writeBadge(page, { state: "working", auto: true });
|
|
33
|
+
raised = true;
|
|
34
|
+
} catch {
|
|
35
|
+
// no badge script on this page (attach not running): nothing to raise
|
|
36
|
+
}
|
|
37
|
+
try {
|
|
38
|
+
return await action();
|
|
39
|
+
} finally {
|
|
40
|
+
if (raised) await writeBadge(page, before ?? { state: "label" }).catch(() => {});
|
|
41
|
+
}
|
|
42
|
+
}
|
package/src/cli-registry.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { diagnosticCatalogue } from "./diagnostics.mjs";
|
|
2
2
|
import { HARNESS_PROTOCOL_VERSION } from "./protocol.mjs";
|
|
3
|
+
import { DEFAULT_REGION, REGION_NAMES } from "./region.mjs";
|
|
3
4
|
|
|
4
5
|
export const CLI_SCHEMA_VERSION = 1;
|
|
5
6
|
|
|
@@ -21,7 +22,8 @@ export const CLI_COMMANDS = Object.freeze([
|
|
|
21
22
|
group: "start",
|
|
22
23
|
summary: "Create a configured Foldspace actions project",
|
|
23
24
|
usage:
|
|
24
|
-
"foldspace init [<directory>] [--product-id <id>] [--agent-key <key>] [--app-domain <host>] [--name <display-name>]"
|
|
25
|
+
"foldspace init [<directory>] [--product-id <id>] [--agent-key <key>] [--app-domain <host>] [--name <display-name>] " +
|
|
26
|
+
`[--region <${REGION_NAMES.join("|")}>]`,
|
|
25
27
|
risk: "local-write",
|
|
26
28
|
environment: "node",
|
|
27
29
|
environmentVariables: [],
|
|
@@ -48,6 +50,12 @@ export const CLI_COMMANDS = Object.freeze([
|
|
|
48
50
|
value("--name", "display-name", "Display name", {
|
|
49
51
|
default: "directory name",
|
|
50
52
|
}),
|
|
53
|
+
value(
|
|
54
|
+
"--region",
|
|
55
|
+
REGION_NAMES.join("|"),
|
|
56
|
+
"Foldspace region of the account; sets the SDK the project loads",
|
|
57
|
+
{ default: DEFAULT_REGION, choices: REGION_NAMES },
|
|
58
|
+
),
|
|
51
59
|
],
|
|
52
60
|
prerequisites: [
|
|
53
61
|
"Node 20 or newer",
|
|
@@ -306,10 +314,11 @@ export const CLI_COMMANDS = Object.freeze([
|
|
|
306
314
|
],
|
|
307
315
|
options: [
|
|
308
316
|
value("--click", "label", "screen: reach the screen by a navigation label, as a person would"),
|
|
309
|
-
value("--goto", "path", "screen: a
|
|
317
|
+
value("--goto", "path", "screen: a path on this host, or a full https address on a host this project names"),
|
|
310
318
|
value("--match", "words", "screen: rank the requests seen by these words"),
|
|
311
319
|
value("--wait", "ms", "screen/auth: how long to keep listening"),
|
|
312
320
|
value("--out", "file", "screenshot: where to save the PNG"),
|
|
321
|
+
flag("--write", "styles: write agent/views/brand.ts - the brand reference every card imports"),
|
|
313
322
|
value("--timeout", "seconds", "wait-login: give up after this long", { default: "600" }),
|
|
314
323
|
],
|
|
315
324
|
prerequisites: ["foldspace inject has opened the test window"],
|
|
@@ -317,6 +326,7 @@ export const CLI_COMMANDS = Object.freeze([
|
|
|
317
326
|
"GET only. Never submits a form; clicks navigation elements only",
|
|
318
327
|
"Prints names, shapes and counts - never values, tokens or cookies",
|
|
319
328
|
"Works while foldspace attach is running",
|
|
329
|
+
"Raises the working pill on the window while it runs (not for screenshot), and drops it after",
|
|
320
330
|
],
|
|
321
331
|
next: ["Record what you established in docs/app-profile.md", "Write the handler"],
|
|
322
332
|
}),
|
package/src/init.mjs
CHANGED
|
@@ -5,6 +5,7 @@ import readline from "node:readline/promises";
|
|
|
5
5
|
import { stdin as input, stdout as output } from "node:process";
|
|
6
6
|
import { fileURLToPath } from "node:url";
|
|
7
7
|
import { commandByName } from "./cli-registry.mjs";
|
|
8
|
+
import { DEFAULT_REGION, REGIONS, parseRegion } from "./region.mjs";
|
|
8
9
|
|
|
9
10
|
const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
10
11
|
const defaultTemplateRoot = path.join(packageRoot, "templates", "agent-starter");
|
|
@@ -15,6 +16,7 @@ const allowedFlags = new Set([
|
|
|
15
16
|
"agent-api-name",
|
|
16
17
|
"app-domain",
|
|
17
18
|
"domain",
|
|
19
|
+
"region",
|
|
18
20
|
]);
|
|
19
21
|
const tokenPattern = /\{\{([A-Z0-9_]+)\}\}/g;
|
|
20
22
|
const defaultDirectory = "foldspace-agent";
|
|
@@ -99,6 +101,9 @@ export function parseInitArgs(argv) {
|
|
|
99
101
|
productId: flags["product-id"] || null,
|
|
100
102
|
agentApiName: flags["agent-key"] || flags["agent-api-name"] || null,
|
|
101
103
|
domain: flags["app-domain"] || flags.domain || null,
|
|
104
|
+
// Checked here, before any prompt: a typo must not cost a whole
|
|
105
|
+
// interactive run and then fail at the end.
|
|
106
|
+
region: flags.region ? parseRegion(flags.region) : null,
|
|
102
107
|
};
|
|
103
108
|
}
|
|
104
109
|
|
|
@@ -275,6 +280,7 @@ function finalizeInitConfig(parsed) {
|
|
|
275
280
|
productId: parsed.productId,
|
|
276
281
|
agentApiName: parsed.agentApiName,
|
|
277
282
|
domain: parsed.domain,
|
|
283
|
+
region: parsed.region || DEFAULT_REGION,
|
|
278
284
|
};
|
|
279
285
|
}
|
|
280
286
|
|
|
@@ -300,6 +306,7 @@ function createTemplateValues(config, harnessVersion) {
|
|
|
300
306
|
|
|
301
307
|
const productId = validateProductId(config.productId);
|
|
302
308
|
const agentApiName = validateIdentifier(config.agentApiName, "Agent Key");
|
|
309
|
+
const region = parseRegion(config.region || DEFAULT_REGION);
|
|
303
310
|
const target = normalizeTarget(config.domain);
|
|
304
311
|
const hosts =
|
|
305
312
|
target.domain === "localhost" || /^\d+(?:\.\d+){3}$/.test(target.domain)
|
|
@@ -319,6 +326,8 @@ function createTemplateValues(config, harnessVersion) {
|
|
|
319
326
|
START_URL: target.startUrl,
|
|
320
327
|
START_URL_JSON: JSON.stringify(target.startUrl),
|
|
321
328
|
HOSTS_JSON: JSON.stringify(hosts),
|
|
329
|
+
REGION_JSON: JSON.stringify(region),
|
|
330
|
+
SDK_URL_JSON: JSON.stringify(REGIONS[region].sdkUrl),
|
|
322
331
|
HARNESS_VERSION: harnessVersion,
|
|
323
332
|
};
|
|
324
333
|
}
|
package/src/observe-core.mjs
CHANGED
|
@@ -705,8 +705,46 @@ export function looksLoggedIn({ url, hasPasswordField = false, hosts = [] }) {
|
|
|
705
705
|
// ---------------------------------------------------------------- styles
|
|
706
706
|
|
|
707
707
|
/** Computed colours come back as rgb()/rgba(); anything else passes through. */
|
|
708
|
+
// oklch / oklab, as modern apps write their palettes and as Chrome reports
|
|
709
|
+
// them back, converted to sRGB. Cards must be plain hex: the panel saves them
|
|
710
|
+
// to history as an image drawn by a library that cannot read these.
|
|
711
|
+
function okToHex(text) {
|
|
712
|
+
const match = /^(oklch|oklab)\(\s*([\d.]+%?)\s+(-?[\d.]+%?)\s+(-?[\d.]+)(?:deg)?\s*(?:\/\s*([\d.]+%?))?\s*\)$/i.exec(text);
|
|
713
|
+
if (!match) return null;
|
|
714
|
+
const number = (value, scale = 1) => (value.endsWith("%") ? (Number(value.slice(0, -1)) / 100) * scale : Number(value));
|
|
715
|
+
const L = number(match[2]);
|
|
716
|
+
let a;
|
|
717
|
+
let b;
|
|
718
|
+
if (match[1].toLowerCase() === "oklch") {
|
|
719
|
+
const C = number(match[3], 0.4);
|
|
720
|
+
const H = (Number(match[4]) * Math.PI) / 180;
|
|
721
|
+
a = C * Math.cos(H);
|
|
722
|
+
b = C * Math.sin(H);
|
|
723
|
+
} else {
|
|
724
|
+
a = number(match[3], 0.4);
|
|
725
|
+
b = number(match[4], 0.4);
|
|
726
|
+
}
|
|
727
|
+
const l = (L + 0.3963377774 * a + 0.2158037573 * b) ** 3;
|
|
728
|
+
const m = (L - 0.1055613458 * a - 0.0638541728 * b) ** 3;
|
|
729
|
+
const s = (L - 0.0894841775 * a - 1.291485548 * b) ** 3;
|
|
730
|
+
const linear = [
|
|
731
|
+
4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
|
|
732
|
+
-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
|
|
733
|
+
-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s,
|
|
734
|
+
];
|
|
735
|
+
const channel = (c) => {
|
|
736
|
+
const v = c <= 0.0031308 ? 12.92 * c : 1.055 * Math.max(c, 0) ** (1 / 2.4) - 0.055;
|
|
737
|
+
return Math.max(0, Math.min(255, Math.round(v * 255))).toString(16).padStart(2, "0");
|
|
738
|
+
};
|
|
739
|
+
const hex = `#${linear.map(channel).join("")}`;
|
|
740
|
+
const alpha = match[5] === undefined ? 1 : number(match[5]);
|
|
741
|
+
return alpha >= 1 ? hex : `${hex}${Math.round(alpha * 255).toString(16).padStart(2, "0")}`;
|
|
742
|
+
}
|
|
743
|
+
|
|
708
744
|
export function toHex(cssColor) {
|
|
709
745
|
const text = String(cssColor || "").trim();
|
|
746
|
+
const ok = okToHex(text);
|
|
747
|
+
if (ok) return ok;
|
|
710
748
|
const match = /^rgba?\(\s*([\d.]+)[\s,]+([\d.]+)[\s,]+([\d.]+)(?:\s*[,/]\s*([\d.]+%?))?\s*\)$/i.exec(text);
|
|
711
749
|
if (!match) return text || null;
|
|
712
750
|
const channel = (value) =>
|
|
@@ -720,3 +758,69 @@ export function toHex(cssColor) {
|
|
|
720
758
|
const hex = `#${channel(match[1])}${channel(match[2])}${channel(match[3])}`;
|
|
721
759
|
return alpha >= 1 ? hex : `${hex}${channel(alpha * 255)}`;
|
|
722
760
|
}
|
|
761
|
+
|
|
762
|
+
// ------------------------------------------------------------ brand file
|
|
763
|
+
|
|
764
|
+
function luminance(hex) {
|
|
765
|
+
const m = /^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})/i.exec(String(hex || ""));
|
|
766
|
+
if (!m) return null;
|
|
767
|
+
const [r, g, b] = [m[1], m[2], m[3]].map((part) => parseInt(part, 16) / 255);
|
|
768
|
+
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
const isHex = (value) => /^#[0-9a-f]{6}([0-9a-f]{2})?$/i.test(String(value || ""));
|
|
772
|
+
|
|
773
|
+
/**
|
|
774
|
+
* `agent/views/brand.ts` from what `observe styles` sampled: the app's own
|
|
775
|
+
* accent, text, border, font and radius, on the panel's white surface. A dark
|
|
776
|
+
* app keeps its accent, font and radius; its light-on-dark text and borders
|
|
777
|
+
* would vanish on white, so those fall back to neutral greys and say so.
|
|
778
|
+
*/
|
|
779
|
+
export function brandFile(styles, { url, date, agentPrimary } = {}) {
|
|
780
|
+
const notes = [];
|
|
781
|
+
const pick = (value, fallback, name) => {
|
|
782
|
+
const hex = value ? toHex(value) : null; // rgb / oklch / oklab -> hex; anything else stays as it was
|
|
783
|
+
if (isHex(hex)) return hex.slice(0, 7).toUpperCase();
|
|
784
|
+
if (value) notes.push(`${name}: sampled "${value}" is not a plain colour - using ${fallback}`);
|
|
785
|
+
return fallback;
|
|
786
|
+
};
|
|
787
|
+
const background = pick(styles.background, "#FFFFFF", "background");
|
|
788
|
+
const darkApp = (luminance(background) ?? 1) < 0.4;
|
|
789
|
+
if (darkApp) notes.push(`the app is dark (${background}); cards sit in the white panel, so text, muted and border are neutral greys`);
|
|
790
|
+
const text = darkApp ? "#111827" : pick(styles.text, "#111827", "text");
|
|
791
|
+
const muted = darkApp ? "#6B7280" : pick(styles.textMuted, "#6B7280", "textMuted");
|
|
792
|
+
const border = darkApp ? "#E5E7EB" : pick(styles.border, "#E5E7EB", "border");
|
|
793
|
+
const buttonBackground = styles.primaryButton?.background;
|
|
794
|
+
const accent = isHex(agentPrimary)
|
|
795
|
+
? agentPrimary.slice(0, 7).toUpperCase()
|
|
796
|
+
: pick(buttonBackground || styles.link, "#3248F2", "accent");
|
|
797
|
+
const accentSource = isHex(agentPrimary) ? "the agent's own primary colour" : buttonBackground ? "the app's primary button" : styles.link ? "the app's links" : "Foldspace blue (nothing sampled)";
|
|
798
|
+
const accentText = pick(styles.primaryButton?.color, (luminance(accent) ?? 0) > 0.6 ? "#111827" : "#FFFFFF", "accentText");
|
|
799
|
+
const font = String(styles.fontFamily || "").replace(/["'\\]/g, "").trim() || "Roboto, sans-serif";
|
|
800
|
+
const radius = /^\d+(\.\d+)?px$/.test(String(styles.boxRadius || "")) ? styles.boxRadius : "8px";
|
|
801
|
+
const stamp = `${url || "the app"}${date ? ` on ${date}` : ""}`;
|
|
802
|
+
const lines = [
|
|
803
|
+
`// Brand reference for this product's cards. Sampled from ${stamp} with`,
|
|
804
|
+
"// `foldspace observe styles --write`. Cards live in the agent's panel, which is",
|
|
805
|
+
"// white, so the surface is the panel's; accent, text, border, font and radius",
|
|
806
|
+
"// are the app's own. Re-run the command to re-sample; do not edit by eye.",
|
|
807
|
+
"// Plain hex only: the panel saves cards to history as an image drawn by a",
|
|
808
|
+
"// library that cannot read oklch()/lab().",
|
|
809
|
+
`// accent: ${accentSource}.`,
|
|
810
|
+
...notes.map((note) => `// NOTE ${note}.`),
|
|
811
|
+
"",
|
|
812
|
+
"export const brand = {",
|
|
813
|
+
` surface: "#FFFFFF",`,
|
|
814
|
+
` surface2: "#F5F6FA",`,
|
|
815
|
+
` text: "${text}",`,
|
|
816
|
+
` muted: "${muted}",`,
|
|
817
|
+
` border: "${border}",`,
|
|
818
|
+
` accent: "${accent}",`,
|
|
819
|
+
` accentText: "${accentText}",`,
|
|
820
|
+
` font: '${font}',`,
|
|
821
|
+
` radius: "${radius}",`,
|
|
822
|
+
"} as const;",
|
|
823
|
+
"",
|
|
824
|
+
];
|
|
825
|
+
return { content: lines.join("\n"), notes, values: { text, muted, border, accent, accentText, font, radius } };
|
|
826
|
+
}
|
package/src/region.mjs
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// The Foldspace regions a project can live in. Everything region-specific in
|
|
2
|
+
// the harness comes from here: init writes `region` and its `sdkUrl` into
|
|
3
|
+
// foldspace.dev.json, attach recognizes the SDK on every region's script host,
|
|
4
|
+
// and the startup step in CLAUDE.md names each region's server and playbook.
|
|
5
|
+
//
|
|
6
|
+
// `sdkUrl`: each region has its own SDK build. Same code, but its config
|
|
7
|
+
// points at that region's agent, recording and tracking hosts - the US SDK on
|
|
8
|
+
// an EU page talks to a US backend that does not have the EU agents.
|
|
9
|
+
//
|
|
10
|
+
// `server`: the Product MCP's name in the editor, as the region's playbook
|
|
11
|
+
// installs it. Distinct per region, so both can sit side by side.
|
|
12
|
+
export const REGIONS = Object.freeze({
|
|
13
|
+
us: Object.freeze({
|
|
14
|
+
label: "US",
|
|
15
|
+
sdkUrl: "https://script.eucerahive.io/web/sdk/foldspace.js",
|
|
16
|
+
server: "foldspace",
|
|
17
|
+
playbook: "https://foldspace.ai/agents.md",
|
|
18
|
+
}),
|
|
19
|
+
eu: Object.freeze({
|
|
20
|
+
label: "EU",
|
|
21
|
+
sdkUrl: "https://script.eu.eucerahive.io/web/sdk/foldspace.js",
|
|
22
|
+
server: "foldspace-eu",
|
|
23
|
+
playbook: "https://foldspace.ai/eu/agents.md",
|
|
24
|
+
}),
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
// Projects created before regions existed have no `region`: they are US.
|
|
28
|
+
export const DEFAULT_REGION = "us";
|
|
29
|
+
|
|
30
|
+
export const REGION_NAMES = Object.freeze(Object.keys(REGIONS));
|
|
31
|
+
|
|
32
|
+
export function isRegion(value) {
|
|
33
|
+
return typeof value === "string" && Object.hasOwn(REGIONS, value);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function parseRegion(value) {
|
|
37
|
+
const region = typeof value === "string" ? value.trim().toLowerCase() : value;
|
|
38
|
+
if (!isRegion(region)) {
|
|
39
|
+
throw new Error(
|
|
40
|
+
`Unknown region: ${value}. Use one of: ${REGION_NAMES.join(", ")}.`,
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
return region;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// The region a foldspace.dev.json belongs to.
|
|
47
|
+
export function regionOf(config) {
|
|
48
|
+
return REGIONS[parseRegion(config?.region ?? DEFAULT_REGION)];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Hostnames the Foldspace SDK is served from, in any region.
|
|
52
|
+
export const SDK_SCRIPT_HOSTS = Object.freeze(
|
|
53
|
+
REGION_NAMES.map((name) => new URL(REGIONS[name].sdkUrl).hostname),
|
|
54
|
+
);
|
package/src/upgrade.mjs
CHANGED
|
@@ -307,7 +307,12 @@ export function checkForUpdate(projectDir, options = {}) {
|
|
|
307
307
|
} else if (skipped) {
|
|
308
308
|
status = "check_skipped";
|
|
309
309
|
} else {
|
|
310
|
-
|
|
310
|
+
// `fresh` asks the registry whatever was remembered, and still writes the
|
|
311
|
+
// new answer down. `foldspace upgrade` is always fresh: someone asking to
|
|
312
|
+
// upgrade minutes after a release was told the previous version was the
|
|
313
|
+
// latest, for up to 12 hours (seen 2026-09-28).
|
|
314
|
+
const cached =
|
|
315
|
+
options.cache === false || options.fresh === true ? null : readCache(projectDir, now);
|
|
311
316
|
const cachedDeprecation =
|
|
312
317
|
cached?.deprecation?.version === running ? cached.deprecation : null;
|
|
313
318
|
if (cached?.latest && cachedDeprecation) {
|
|
@@ -445,7 +450,7 @@ export async function runUpgrade(argv, options = {}) {
|
|
|
445
450
|
);
|
|
446
451
|
}
|
|
447
452
|
|
|
448
|
-
const report = checkForUpdate(projectDir, options);
|
|
453
|
+
const report = checkForUpdate(projectDir, { ...options, fresh: options.fresh ?? true });
|
|
449
454
|
|
|
450
455
|
if (parsed.checkOnly || (!parsed.yes && !isInteractive(options) && !parsed.refreshInstructions)) {
|
|
451
456
|
return report;
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$comment": "Generated by foldspace init. Use the bare product ID, not the EU-… SDK key.",
|
|
3
3
|
"defaultTarget": "app",
|
|
4
|
-
"
|
|
4
|
+
"region": {{REGION_JSON}},
|
|
5
|
+
"sdkUrl": {{SDK_URL_JSON}},
|
|
5
6
|
"targets": {
|
|
6
7
|
"app": {
|
|
7
8
|
"name": "App",
|