@foldspace_npm/harness 0.1.24 → 0.1.25

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 CHANGED
@@ -78,9 +78,11 @@ current CLI contract — risk, prerequisites, effects, next step.
78
78
  one that creates data - say so instead.
79
79
 
80
80
  **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. Before you start looking
82
- at the app or testing in it, run `npx foldspace badge working` (a bar across
83
- the top of the window says Foldspace is building here, leave it alone) and say
81
+ in chat.** You and the human share one Chrome window. `observe` and `ask`
82
+ raise the working pill by themselves for as long as they run, and drop it
83
+ after. For the longer stretch - from the moment you start working on their
84
+ window until it is their turn - run `npx foldspace badge working` (the pill at
85
+ the foot of the window says Foldspace is building here, leave it alone) and say
84
86
  so in chat: *"I'm going to work in that Chrome window for a few minutes -
85
87
  please leave it alone until I tell you it's ready."* When it is their turn,
86
88
  `npx foldspace badge ready` takes the pill away (the small corner tag comes
@@ -487,10 +489,15 @@ Each of these cost a real build.
487
489
  - **A card matches the panel, not the page.** The card sits in the agent's
488
490
  panel - white by default (the agent's theme: `backgroundColor #FFFFFF`,
489
491
  `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). `observe
491
- styles` samples the page: a dark app gives a dark card in a white panel
492
- (seen 2026-09-22). Start from the recipe palettes, put the agent's
493
- `primaryColor` in `accent`, and take only font and radius from the page.
492
+ panel follows the OS colour scheme, not the app's own toggle). **Before the
493
+ first card, make the brand reference:** `npx foldspace observe styles
494
+ --write` samples the app's own accent, text, border, font and radius and
495
+ writes `agent/views/brand.ts` for the panel's white surface - every card
496
+ imports it, and the recipes' `brand.ts` files are placeholders for it. A
497
+ dark app keeps its accent and font; its light-on-dark text would vanish on
498
+ white, so that falls back to greys and the file says so. Record where the
499
+ values came from in the profile's Brand section. Never copy colours by eye
500
+ (seen 2026-09-22: a dark card in a white panel).
494
501
  - **Plain hex or rgba only.** The panel saves every card to the conversation
495
502
  history as an image drawn with html2canvas, which cannot read `oklch()`,
496
503
  `lab()` or `color-mix()`: one such value and the saved card is blank
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
- do {
304
- if (attempts > 0) await sleep(everyS * 1000);
305
- turn = await askOnce();
306
- attempts += 1;
307
- } while (turn.sent && shouldRetry({ ...turn, attempts, retries }));
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
- return {
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
- if (sub === "menu") return await runMenu(page);
800
- if (sub === "screen") return await runScreen(page, flags);
801
- if (sub === "read") return await runRead(page, flags, rest);
802
- if (sub === "auth") return await runAuth(page, flags);
803
- if (sub === "styles") return await runStyles(page);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@foldspace_npm/harness",
3
- "version": "0.1.24",
3
+ "version": "0.1.25",
4
4
  "description": "Build and verify portable Foldspace action artifacts against a live app.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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
+ }
@@ -306,10 +306,11 @@ export const CLI_COMMANDS = Object.freeze([
306
306
  ],
307
307
  options: [
308
308
  value("--click", "label", "screen: reach the screen by a navigation label, as a person would"),
309
- value("--goto", "path", "screen: a same-origin path"),
309
+ value("--goto", "path", "screen: a path on this host, or a full https address on a host this project names"),
310
310
  value("--match", "words", "screen: rank the requests seen by these words"),
311
311
  value("--wait", "ms", "screen/auth: how long to keep listening"),
312
312
  value("--out", "file", "screenshot: where to save the PNG"),
313
+ flag("--write", "styles: write agent/views/brand.ts - the brand reference every card imports"),
313
314
  value("--timeout", "seconds", "wait-login: give up after this long", { default: "600" }),
314
315
  ],
315
316
  prerequisites: ["foldspace inject has opened the test window"],
@@ -317,6 +318,7 @@ export const CLI_COMMANDS = Object.freeze([
317
318
  "GET only. Never submits a form; clicks navigation elements only",
318
319
  "Prints names, shapes and counts - never values, tokens or cookies",
319
320
  "Works while foldspace attach is running",
321
+ "Raises the working pill on the window while it runs (not for screenshot), and drops it after",
320
322
  ],
321
323
  next: ["Record what you established in docs/app-profile.md", "Write the handler"],
322
324
  }),
@@ -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/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
- const cached = options.cache === false ? null : readCache(projectDir, now);
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;