diff --git a/config/runtime-targets.json b/config/runtime-targets.json index af97a65..749b23e 100644 --- a/config/runtime-targets.json +++ b/config/runtime-targets.json @@ -1,6 +1,6 @@ { "schema_version": 1, - "catalog_digest": "7bffd60e57b37c64831cc9e9df582d9d2f124df7f3556a26d1f7e147f1f51f64", + "catalog_digest": "aa3d2e236b45106db2c2121a2f22d2962a9aaa3c981a55ad298412b361f65ba2", "targets": [ { "name": "claude", diff --git a/registry/capabilities.json b/registry/capabilities.json index 914ddb0..d5a6627 100644 --- a/registry/capabilities.json +++ b/registry/capabilities.json @@ -7810,7 +7810,7 @@ "posture": "repository-local", "source_identity": "stack:skills/micro-branch" }, - "purpose": "Keep every design iteration as its own git branch (a take), lay takes out side by side on a local canvas (Twombly or a built-in board), jump back to any take, and pick one into trunk. Use when iterating on a UI and versions should never overwrite each other.", + "purpose": "Keep every design iteration as its own git branch (a take), lay takes out side by side on a local canvas (Twombly or a built-in board), jump back to any take, and pick one into trunk. Use when iterating on a UI and versions should never overwrite each other. Includes a Design Studio preset that opens each frame on one prototype view.", "role": "workflow", "runtimes": { "publish_targets": [ diff --git a/skills/design/micro-branch/SKILL.md b/skills/design/micro-branch/SKILL.md index 4324fda..6ec8489 100644 --- a/skills/design/micro-branch/SKILL.md +++ b/skills/design/micro-branch/SKILL.md @@ -7,7 +7,8 @@ description: >- one into trunk. Use when someone says "try a few versions", "give me three takes", "micro-branch this", "put the versions on a canvas", "go back to take 2", "build on take 3", "pick that one", or wants design iterations that never overwrite - each other. Works in any git repo with a static build or plain HTML. + each other. Works in any git repo with a static build or plain HTML, and has a + Design Studio preset that opens each frame on one prototype view. --- # micro-branch @@ -48,7 +49,9 @@ machine. 1. Check the repo: `git status` is clean and trunk has at least one commit. If the folder is not a repo, ask before running `git init`. 2. Work out how a take becomes static files. Read `references/setup-recipes.md` - for Vite, Next.js, Astro, plain HTML and other stacks. + for Vite, Next.js, Astro, plain HTML and other stacks. A folder with + `studio.config.json` is a Design Studio install: use `init --preset design-studio` + and read `references/design-studio.md`. 3. Initialise. Defaults: canvas `twombly`, frame `1280x800`, at most 4 takes per round. ```sh @@ -58,12 +61,17 @@ $MB init --name "" --build "" --out \ ``` Use `--frame-size 390x844` for mobile screens. Use `--canvas board` when Node is -missing or Twombly fails; the board needs only Python. +missing or Twombly fails; the board needs only Python. When Twombly rejects remote +fonts or images, `--localize-fonts` and `--localize ` copy them into each +frame. ## The loop 1. **Start a round** for one question the person cares about: - `$MB round new --question "How should view-all open?"` + `$MB round new --question "How should view-all open?"`. + Add `--entry "index.html?variant=b"` when the round's frames open somewhere other + than the repo's entry, or `--view /` in Design Studio + (`$MB views` lists them). 2. **Cut takes**, one idea each, usually 3: `$MB take new r1 --note "one line on the idea"`. Edit only inside the `worktree` path it returns. Never edit trunk during a round. @@ -71,6 +79,8 @@ missing or Twombly fails; the board needs only Python. 3. **Publish each take**: `$MB publish r1/t2`. It commits the take, runs the build, snapshots the output and places a frame on the canvas. A failed build publishes nothing and marks the take `broken`; read the log path in the hint and fix it. + `prepared` in the reply lists what the snapshot changed (copied assets, scrubbed + strings). Mention it if anything was skipped. 4. **Show the canvas**: `$MB canvas` prints the URL. Tell the person which label is which idea in one line each. 5. **Read feedback**: `$MB feedback` returns what the person is looking at and their @@ -111,4 +121,6 @@ missing or Twombly fails; the board needs only Python. to fix a frame that fails them. - `references/setup-recipes.md`: build, output and setup values per stack, and what to do when a project has no static build. +- `references/design-studio.md`: the Design Studio preset, picking a view, and what + changes in its snapshots. - `references/troubleshooting.md`: common errors and their fixes. diff --git a/skills/design/micro-branch/capability.json b/skills/design/micro-branch/capability.json index 8bab4c7..0a17c8d 100644 --- a/skills/design/micro-branch/capability.json +++ b/skills/design/micro-branch/capability.json @@ -33,7 +33,7 @@ "posture": "repository-local", "source_identity": "stack:skills/micro-branch" }, - "purpose": "Keep every design iteration as its own git branch (a take), lay takes out side by side on a local canvas (Twombly or a built-in board), jump back to any take, and pick one into trunk. Use when iterating on a UI and versions should never overwrite each other.", + "purpose": "Keep every design iteration as its own git branch (a take), lay takes out side by side on a local canvas (Twombly or a built-in board), jump back to any take, and pick one into trunk. Use when iterating on a UI and versions should never overwrite each other. Includes a Design Studio preset that opens each frame on one prototype view.", "role": "workflow", "runtimes": { "publish_targets": [ diff --git a/skills/design/micro-branch/references/design-studio.md b/skills/design/micro-branch/references/design-studio.md new file mode 100644 index 0000000..9a7892b --- /dev/null +++ b/skills/design/micro-branch/references/design-studio.md @@ -0,0 +1,78 @@ +# Design Studio + +Design Studio is an open-source prototype sandbox: React views, Markdown documents, +diagrams and canvases in one folder, with a browser preview. The `design-studio` +preset makes each take's frame open on one view, on its own, without the studio +around it. + +## Set up + +Run init from the Design Studio checkout, after its dependencies are installed +(`pnpm install`, once): + +```sh +$MB init --preset design-studio [--localize-fonts] [--localize ,] +``` + +| Setting | Preset value | Why | +|---|---|---| +| `--build` | `node src/platform/node/build/build-manifest.js --deploy && ./node_modules/.bin/vite build` | The manifest and Vite only, about 10 seconds. `pnpm build` adds release checks and typechecking, which reject work in progress | +| `--out` | `dist` | | +| `--setup` | Links `node_modules` and each `src/systems/*/node_modules` from the main checkout | Instant. A fresh `pnpm install` in each take takes about a minute | +| `--root-paths` | on | The build loads `/assets/...`. Twombly serves each frame at its own root, and the board serves it at `r-t.localhost` | +| `--scrub` | `excalidraw-public-config` | See "What changes in the snapshot" | + +Trunk is the branch checked out when init runs. In a Design Studio folder that is +often a feature branch, not `main`. Pass `--trunk` to choose. + +## Pick the view + +Each round opens its frames on one view, a `.tsx` file in a prototype folder: + +```sh +$MB views # every view on trunk, as / +$MB round new card --question "How should the card read?" --view "/.tsx" +``` + +`--view` takes the full `/`, the path alone, or the end of the +path, as long as only one view matches. A take can use a different view: +`take new r1 --view ...`. A view that a take creates is not on trunk yet, so +`take new` warns instead of failing, and `views --take r1/t2` lists the take's files. + +At publish, the script reads the take's build manifest and points the frame at +Design Studio's preview address (`index.html?studio-preview=1&...`). The studio +uses that address to embed views itself. It renders one view with no navigation +or chrome. + +## What changes in the snapshot + +Snapshots are copies. The take's own files never change. + +- **Two Excalidraw strings.** Design Studio's canvas module bundles Excalidraw, + which ships the public Firebase client key of its own collaboration server and a + browser-storage key named `excalidraw-oai-api-key`. Twombly's secret check flags + both. The `excalidraw-public-config` rule rewrites exactly those two strings to + placeholders, in files that mention Excalidraw. Frames use neither. `publish` + lists each rewrite under `prepared.scrubbed`. +- **Remote assets.** Frames can't reach the network. If a design system loads + Google Fonts, or a prototype shows images from a media host, name the hosts at + init: `--localize-fonts` for Google Fonts, `--localize ` for the rest. Each + asset is downloaded once over https and cached in + `/micro-branch/cache/remote/`. The script copies it into the + frame's `mb-assets/` folder and rewrites the references. + `prepared.localized.skipped` lists anything that failed to download. + +## Limits + +- A view is live in its frame, so motion and interaction work. Anything that calls + a server needs mock data, like any frame. +- Archived prototypes are left out of the build by `--deploy`. Unarchive one to + explore it. +- Takes share the main checkout's dependencies. A take that adds or upgrades a + package needs its own install: delete the take's linked `node_modules` and run + `pnpm install` inside the take. +- The preview address is part of Design Studio's internals. If an upgrade changes + it, frames show "Invalid preview address". Fix `design_studio_entry()` in + `scripts/mb.py` through a Stack change. +- The Design Studio folder's own `AGENTS.md` still applies inside each take. Build + prototypes the way it says. diff --git a/skills/design/micro-branch/references/setup-recipes.md b/skills/design/micro-branch/references/setup-recipes.md index d486aba..58ee465 100644 --- a/skills/design/micro-branch/references/setup-recipes.md +++ b/skills/design/micro-branch/references/setup-recipes.md @@ -6,8 +6,10 @@ output lands (`--out`), and what each new worktree needs before it can build before choosing. Frames are served from a subfolder on the board and as standalone folders in -Twombly, so **asset paths must be relative**. A blank frame or 404s for `/assets/...` -means the build wrote absolute paths. +Twombly, so **asset paths should be relative**. A blank frame or 404s for +`/assets/...` on the board means the build wrote root paths. If the build can't +change, `init --root-paths` makes the board serve each frame at the root of its own +address (`r1-t2.localhost`), which Chrome, Edge and Firefox resolve locally. | Stack | `--build` | `--out` | Notes | |---|---|---|---| @@ -19,6 +21,7 @@ means the build wrote absolute paths. | Create React App | `PUBLIC_URL=. npm run build` | `build` | | | Storybook | `npx storybook build -o storybook-static` | `storybook-static` | Each frame shows the whole Storybook | | Monorepo package | `cd apps/web && npm run build -- --base=./` | `apps/web/dist` | Paths are relative to the take's root | +| Design Studio | `init --preset design-studio` | `dist` | Fills in build, setup and root paths. See `design-studio.md` | Swap `npm run` for `pnpm`, `yarn` or `bun` to match the lockfile. @@ -37,6 +40,11 @@ Every take is a separate worktree, so it starts without `node_modules`. Install dependencies in the main checkout first when using the symlink. If a take adds a package, replace its symlink with a real install inside that worktree. +A `.gitignore` line like `node_modules/` matches folders, not links, so a linked +`node_modules` would land in the take's commits. After setup, the script adds every +link setup made to the repo's local `.git/info/exclude` (never committed) and lists +them under `setup.ignored_links`. + ## Environment `--setup` and `--build` run through the shell inside the take's worktree with diff --git a/skills/design/micro-branch/references/troubleshooting.md b/skills/design/micro-branch/references/troubleshooting.md index f4fdeb9..9dddf44 100644 --- a/skills/design/micro-branch/references/troubleshooting.md +++ b/skills/design/micro-branch/references/troubleshooting.md @@ -11,15 +11,22 @@ Every failure prints `"ok": false` with `error` and `hint`. Match the error here | `round ... is picked` / `closed` | Start a new round, or `round next ` to build on one of its takes | | `build failed ... nothing was published` | Read the log path in `hint`. Run the build by hand in the take's worktree, fix, publish again | | `build output '' does not exist` | The build wrote somewhere else. `init --out ` | -| `'index.html' is missing from the build output` | `init --entry ` | +| `'index.html' is missing from the build output` | `init --entry `, or per round with `round new --entry` | +| `--view needs the design-studio preset` | Use `--entry` outside Design Studio. In a Design Studio folder, `init --preset design-studio` | +| `no view matches ...` / `matches N views` | `views` lists every view as `/`; use that form. At publish, archived prototypes are missing from the build | +| `this repo has no studio.config.json` | The preset only fits a Design Studio checkout. Run init there, or drop `--preset` | +| `Design Studio's dependencies are not installed` | Run `pnpm install` in the main checkout once | +| `not a host name` | `--localize` takes bare hosts, comma-separated: `images.example.com,cdn.example.com` | | `setup failed in ` | Run the setup command by hand there. The worktree stays, so publish after fixing | | `trunk checkout ... has uncommitted changes` | Ask the person whether to commit or stash their trunk changes. Never discard them | | `does not merge cleanly into ` | Trunk moved since the round started. In the take's worktree: `git merge `, resolve, `publish`, then `pick` again. Trunk was left unchanged | | `has no changes against ` | The take is identical to trunk; nothing to pick | -| `twombly failed` | Check `node --version` (18 or newer) and that npm can reach the registry for the first download. If it still fails, `init --canvas board` and carry on | +| `twombly failed` | Check `node --version` (22 or newer) and that npm can reach the registry for the first download. If it still fails, `init --canvas board` and carry on | | `micro-branch never shares a canvas` | Expected. The person clicks Share in Twombly | | `no single take matches` | Use the full id from `status`, e.g. `r2-type/t3-serif` | -| Board frame is blank or assets 404 | The build uses absolute asset paths. See the relative-path notes in `setup-recipes.md` | +| Board frame is blank or assets 404 | The build uses root paths. Make them relative (`setup-recipes.md`) or `init --root-paths` | +| `prepared.localized.skipped` is not empty | Those assets failed to download and the frame still points at the network. Check the host and that the person is online; the cache keeps what did download | +| A take's commit contains a `node_modules` link | Made before the exclude fix. Remove it in the take with `git rm --cached node_modules`, then publish | | A take's folder was deleted by hand | `open ` recreates it from the branch or the archive tag | | The canvas shows an old version of a take | `publish` the take again; the snapshot is replaced | diff --git a/skills/design/micro-branch/references/twombly.md b/skills/design/micro-branch/references/twombly.md index be07288..8d63862 100644 --- a/skills/design/micro-branch/references/twombly.md +++ b/skills/design/micro-branch/references/twombly.md @@ -50,11 +50,11 @@ Twombly checks every frame before placing it. `publish` then fails with | Code | Cause | Fix | |---|---|---| -| `EXTERNAL_REQUEST` | The page loads something from another host: Google Fonts, Typekit, a CDN script or stylesheet, a remote image, analytics | Download the asset into the build output and load it with a relative path. For fonts, copy the `.woff2` files and write a local `@font-face`, or inline them as `data:` URLs | +| `EXTERNAL_REQUEST` | The page loads something from another host: Google Fonts, Typekit, a CDN script or stylesheet, a remote image, analytics | For fonts and images, let the script copy them into each snapshot: `init --localize-fonts` for Google Fonts, `init --localize ` for other hosts. Otherwise download the asset into the build output and load it with a relative path. Scripts and analytics should come out of the page | | `MISSING_FILE` | A file the page asks for is not in the output | Use relative asset paths (see `setup-recipes.md`) and check the build copies the file | | `NO_ENTRY` | No entry file in the output | `init --entry ` or fix `--out` | | `PROJECT_FILE`, `SERVER_CODE`, `ENV_FILE`, `KEY_FILE`, `CREDENTIAL_FILE`, `DATABASE_FILE` | The output folder holds source or config, not just the built site | Point `--out` at the real build output. Common offenders are already left out of snapshots | -| `SECRET_FOUND` | Something that looks like a key or token is in the files | Remove it. Frames use mock data only. Realistic fake tokens can trip this too; make them obviously fake | +| `SECRET_FOUND` | Something that looks like a key or token is in the files | Remove it. Frames use mock data only. Realistic fake tokens can trip this too; make them obviously fake. In Design Studio, the bundled Excalidraw strings are handled by the `excalidraw-public-config` scrub rule (`design-studio.md`) | | `BLANK_RENDER`, `UNCAUGHT_ERROR`, `LOAD_TIMEOUT` | The page renders nothing, throws, or never finishes loading | Open the snapshot from the board and fix the error. Pages that need a backend need mock data | | `FILE_TOO_LARGE`, `TOO_MANY_FILES`, `FRAME_TOO_LARGE` | Over 25 MB per file, 5,000 files or 100 MB per frame | Trim the output, compress media | diff --git a/skills/design/micro-branch/scripts/mb.py b/skills/design/micro-branch/scripts/mb.py index 73f6e1c..9b61502 100644 --- a/skills/design/micro-branch/scripts/mb.py +++ b/skills/design/micro-branch/scripts/mb.py @@ -16,7 +16,10 @@ import contextlib import datetime as dt import fcntl +import functools +import hashlib import html +import http.server import json import os import re @@ -27,6 +30,7 @@ import subprocess import sys import time +import urllib.parse import urllib.request from pathlib import Path from typing import Any, Iterator @@ -49,6 +53,35 @@ ".git", "node_modules", ".micro-branch", ".twombly", ".DS_Store", ".env*", "*.map", "package.json", "package-lock.json", "pnpm-lock.yaml", "yarn.lock", "bun.lock", "bun.lockb", "tsconfig*.json", ) +# The board serves a frame on its own origin (r1-t2.localhost) when its build uses root paths like /assets/. +FRAME_HOST = re.compile(r"^r(\d+)-t(\d+)\.localhost$") +EXCLUDE_HEADER = "# micro-branch: links that take setup made (safe to delete)" +GOOGLE_FONT_HOSTS = ("fonts.googleapis.com", "fonts.gstatic.com") +LOCALIZE_SUFFIXES = {".css", ".js", ".mjs", ".html", ".htm", ".json", ".svg"} +MARKUP_SUFFIXES = (".html", ".htm", ".svg") +LOCALIZE_MAX_BYTES = 15 * 1024 * 1024 +# Google serves woff2 only to browser user agents. +BROWSER_UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0 Safari/537.36" +ASSET_TYPES = { + "text/css": ".css", "font/woff2": ".woff2", "font/woff": ".woff", "font/ttf": ".ttf", "font/otf": ".otf", + "image/jpeg": ".jpg", "image/png": ".png", "image/gif": ".gif", "image/webp": ".webp", "image/avif": ".avif", + "image/svg+xml": ".svg", "video/mp4": ".mp4", "video/webm": ".webm", +} +KNOWN_SUFFIXES = set(ASSET_TYPES.values()) | {".jpeg", ".ico", ".json"} +# Named, narrow rewrites for strings that libraries bundle and Twombly's secret check flags. +# Each rule only touches .js files that contain its marker. +SCRUB_RULES: dict[str, dict[str, Any]] = { + # Excalidraw (Design Studio's canvas module) bundles the public Firebase client key of its own + # collaboration server, and a browser-storage key name that reads like a secret. Frames use neither. + "excalidraw-public-config": { + "marker": "excalidraw", + "rewrites": ( + (re.compile(r'("apiKey":")AIza[0-9A-Za-z_\-]{35}(")'), r"\1placeholder\2"), + (re.compile(r"excalidraw-oai-api-key"), "placeholder-excalidraw-oai"), + ), + }, +} +PRESETS = ("design-studio",) DEFAULT_CONFIG: dict[str, Any] = { "trunk": None, @@ -64,6 +97,10 @@ "twombly_version": TWOMBLY_VERSION, "twombly_port": TWOMBLY_PORT, "takes_dir": None, + "preset": None, + "root_paths": False, + "localize": [], + "scrub": [], } @@ -103,6 +140,11 @@ def git(*args: str, cwd: Path | str | None = None, check: bool = True) -> str: return run(["git", *args], cwd=cwd, check=check).stdout.strip() +def if_set(key: str, value: Any) -> dict[str, Any]: + """`{key: value}` when value is set, else `{}`: for optional fields in replies.""" + return {key: value} if value else {} + + def emit(payload: dict[str, Any], code: int = 0) -> int: print(json.dumps(payload, indent=2, sort_keys=False)) return code @@ -240,9 +282,17 @@ def __init__(self, repo: Repo, state: dict[str, Any]) -> None: self.repo, self.state = repo, state self.root = repo.state_dir - def publish(self, rnd: dict[str, Any], take: dict[str, Any], snapshot: Path) -> dict[str, Any]: + def publish(self, rnd: dict[str, Any], take: dict[str, Any], snapshot: Path, entry: str) -> dict[str, Any]: return {"adapter": self.name, "path": str(snapshot.relative_to(self.root))} + def frame_link(self, rnd: dict[str, Any], take: dict[str, Any], attr: str) -> str: + """`src`/`href` for a frame: a path on the board, or its own origin when the build uses root paths.""" + frame = take.get("frame") or {} + entry = html.escape(frame.get("entry") or self.state["config"]["entry"]) + if self.state["config"].get("root_paths"): + return f'data-frame-host="r{rnd["n"]}-t{take["n"]}" data-entry="{entry}" data-attr="{attr}"' + return f'{attr}="../{html.escape(frame["path"])}/{entry}"' + def render(self) -> Path: board = self.root / "board" board.mkdir(parents=True, exist_ok=True) @@ -259,13 +309,13 @@ def render(self) -> Path: status = take["status"] body = ( f'
' - f'
' if src else '
not published yet
' ) - link = f'open' if src else "" + link = f'open' if src else "" note = f'

{html.escape(take["note"])}

' if take.get("note") else "" cards.append( f'
{html.escape(label(rnd, take))}' @@ -292,7 +342,7 @@ def ensure(self) -> dict[str, Any]: port = free_port(BOARD_PORT) log = open(self.root / "board.log", "a") proc = subprocess.Popen( - [sys.executable, "-m", "http.server", str(port), "--bind", "127.0.0.1", "--directory", str(self.root)], + [sys.executable, str(Path(__file__).resolve()), "_board-serve", str(self.root), str(port)], stdout=log, stderr=log, stdin=subprocess.DEVNULL, start_new_session=True, ) for _ in range(50): @@ -380,12 +430,12 @@ def ensure(self) -> dict[str, Any]: return {"adapter": self.name, "url": self.info.get("url"), "name": self.info.get("name"), "port": self.info.get("port"), "daemon": self.info.get("daemon")} - def publish(self, rnd: dict[str, Any], take: dict[str, Any], snapshot: Path) -> dict[str, Any]: + def publish(self, rnd: dict[str, Any], take: dict[str, Any], snapshot: Path, entry: str) -> dict[str, Any]: if not self.info.get("initialized"): self.ensure() cfg = self.state["config"] flags = ["--title", label(rnd, take), "--width", str(cfg["frame_width"]), - "--height", str(cfg["frame_height"]), "--entry", cfg["entry"]] + "--height", str(cfg["frame_height"]), "--entry", entry] frame_id = ((take.get("frame") or {}).get("twombly") or {}).get("id") result = self.call("update", frame_id, str(snapshot), *flags, allow_fail=True) if frame_id else None if result is None or (not result.get("ok") and not result.get("problems")): @@ -481,6 +531,269 @@ def free_port(start: int) -> int: raise MBError("no free port for the board between 4780 and 4829") +# ---------------------------------------------------------------- Design Studio + + +# A fast build for frames: the manifest plus Vite, without the release checks of `pnpm build`. +DESIGN_STUDIO_BUILD = "node src/platform/node/build/build-manifest.js --deploy && ./node_modules/.bin/vite build" +# Takes link to the main checkout's dependencies: the root node_modules and each design system's own. +DESIGN_STUDIO_SETUP = ( + 'ln -s "$MB_MAIN/node_modules" node_modules && ' + '{ for d in "$MB_MAIN"/src/systems/*/node_modules; do s="${d#"$MB_MAIN"/}"; ' + '[ -d "$d" ] && [ -d "$(dirname "$s")" ] && [ ! -e "$s" ] && ln -s "$d" "$s"; done; :; }' +) + + +def design_studio_preset(main: Path) -> dict[str, Any]: + if not (main / "studio.config.json").is_file(): + raise MBError("this repo has no studio.config.json, so it is not a Design Studio folder", + hint="run init from the Design Studio checkout, or drop --preset") + if not (main / "node_modules").is_dir(): + raise MBError("Design Studio's dependencies are not installed in the main checkout", + hint="run `pnpm install` there once; every take links to them") + return {"build": DESIGN_STUDIO_BUILD, "out": "dist", "entry": "index.html", "setup": DESIGN_STUDIO_SETUP, + "root_paths": True, "scrub": ["excalidraw-public-config"]} + + +def design_studio_views(paths: list[str]) -> list[dict[str, str]]: + """Views are .tsx files in a prototype folder (one with meta.json), outside folders starting with _ or .""" + protos = {Path(p).parent for p in paths if re.fullmatch(r"src/prototypes/[^/]+/[^/]+/meta\.json", p)} + views = [] + for p in sorted(paths): + path = Path(p) + root = next((r for r in protos if r in path.parents), None) + if root is None or path.suffix != ".tsx": + continue + rel = path.relative_to(root) + if any(part.startswith(("_", ".")) for part in rel.parts): + continue + views.append({"view": f"{root.name}/{rel.as_posix()}", "prototype": root.name, "contributor": root.parent.name}) + return views + + +def prototype_files(repo: Repo, ref: str | None = None, worktree: Path | None = None) -> list[str]: + if worktree is not None: + out = git("ls-files", "-z", "--cached", "--others", "--exclude-standard", "--", "src/prototypes", cwd=worktree) + else: + out = git("ls-tree", "-r", "-z", "--name-only", ref or "HEAD", "--", "src/prototypes", cwd=repo.cwd) + return [p for p in out.split("\0") if p] + + +def match_view(view: str, refs: list[str]) -> str: + """Find one view by '/', by its path alone, or by the end of its path.""" + key = view.strip().strip("/") + bare = key.removesuffix(".tsx").lower() + tests = ( + lambda r: r == key, + lambda r: r.removesuffix(".tsx").lower() == bare, + lambda r: r.split("/", 1)[1].removesuffix(".tsx").lower() == bare, + lambda r: r.removesuffix(".tsx").lower().endswith(bare), + ) + for test in tests: + hits = [r for r in refs if test(r)] + if len(hits) == 1: + return hits[0] + if hits: + raise MBError(f"'{view}' matches {len(hits)} views", hint="name it as /: " + "; ".join(hits[:12])) + raise MBError(f"no view matches '{view}'", hint="known views: " + ("; ".join(refs[:15]) or "none")) + + +def check_view(repo: Repo, cfg: dict[str, Any], view: str | None, ref: str | None = None, + worktree: Path | None = None) -> str | None: + """Reject --view outside Design Studio; return a warning when the view isn't in the files yet.""" + if not view: + return None + if cfg.get("preset") != "design-studio": + raise MBError("--view needs the design-studio preset", hint="use --entry for other projects") + refs = [v["view"] for v in design_studio_views(prototype_files(repo, ref, worktree))] + try: + match_view(view, refs) + except MBError as err: + return f"{err}; publish looks again in the take's build" + return None + + +def design_studio_entry(out_dir: Path, view: str) -> tuple[str, str]: + """Entry that opens one view on its own, through Design Studio's preview address.""" + folder = out_dir / "prototypes" + try: + manifest = json.loads((folder / "manifest.json").read_text()) + except (OSError, ValueError): + raise MBError("the build has no prototypes/manifest.json", hint="check the build command; Design Studio writes it while building") + found: dict[str, tuple[dict[str, Any], dict[str, Any]]] = {} + for proto in manifest.get("prototypes", []): + with contextlib.suppress(OSError, ValueError, KeyError, TypeError): + items = json.loads((folder / "artifacts" / proto["contributorKey"] / f"{proto['id']}.json").read_text()) + for item in items: + if item.get("fileType") == "view": + found[f"{proto['id']}/{item['path']}"] = (proto, item) + try: + ref = match_view(view, sorted(found)) + except MBError as err: + raise MBError(f"{err} in the take's build", hint=f"{err.hint}. Archived prototypes are left out of the build") + proto, item = found[ref] + if not proto.get("studioId") or not item.get("studioId"): + raise MBError(f"{ref} has no studio id in the build", hint="run `pnpm studio sync` in the take, then publish again") + config = { + "target": {"contributor": proto["contributorKey"], "prototype": proto["id"], "prototypeId": proto["studioId"], + "artifact": item["path"], "artifactId": item["studioId"]}, + "href": f"/prototypes/{proto['studioId']}/artifacts/{item['studioId']}", "dark": False, "surface": "page", + } + query = urllib.parse.urlencode({"studio-preview": "1", "session": "micro-branch", + "config": json.dumps(config, separators=(",", ":"))}) + return f"index.html?{query}", ref + + +# ---------------------------------------------------------------- snapshot + + +def frame_entry(cfg: dict[str, Any], rnd: dict[str, Any], take: dict[str, Any], out_dir: Path) -> tuple[str, str | None]: + """The take's own view or entry wins, then the round's, then the repo's entry.""" + for scope in (take, rnd): + if scope.get("view"): + return design_studio_entry(out_dir, scope["view"]) + if scope.get("entry"): + return scope["entry"], None + return cfg["entry"], None + + +def entry_file(entry: str) -> str: + return re.split(r"[?#]", entry, maxsplit=1)[0] + + +def scrub_snapshot(snapshot: Path, rules: list[str]) -> list[dict[str, Any]]: + hits = [] + for path in sorted(snapshot.rglob("*")): + if path.suffix not in (".js", ".mjs") or not path.is_file(): + continue + text = original = path.read_text("utf-8", errors="surrogateescape") + for name in rules: + rule = SCRUB_RULES[name] + if rule["marker"] not in text: + continue + count = 0 + for pattern, replacement in rule["rewrites"]: + text, n = pattern.subn(replacement, text) + count += n + if count: + hits.append({"rule": name, "file": path.relative_to(snapshot).as_posix(), "replaced": count}) + if text != original: + path.write_text(text, "utf-8", errors="surrogateescape") + return hits + + +def fetch_remote(repo: Repo, url: str) -> tuple[bytes, str]: + """Download one asset, cached in the state folder so republishing works offline.""" + cache = repo.state_dir / "cache" / "remote" + key = hashlib.sha256(url.encode()).hexdigest() + body, meta = cache / f"{key}.bin", cache / f"{key}.json" + if body.is_file() and meta.is_file(): + return body.read_bytes(), json.loads(meta.read_text()).get("type", "") + if urllib.parse.urlsplit(url).scheme != "https": + raise MBError("only https assets are copied") + request = urllib.request.Request(url, headers={"User-Agent": BROWSER_UA, "Accept": "*/*"}) + try: + with urllib.request.urlopen(request, timeout=20) as resp: + data, kind = resp.read(LOCALIZE_MAX_BYTES + 1), resp.headers.get_content_type() + except (OSError, ValueError) as err: + raise MBError(f"download failed: {err}") + if len(data) > LOCALIZE_MAX_BYTES: + raise MBError("larger than 15 MB") + cache.mkdir(parents=True, exist_ok=True) + body.write_bytes(data) + meta.write_text(json.dumps({"url": url, "type": kind, "fetched_at": now()}) + "\n") + return data, kind + + +def asset_suffix(url: str, kind: str) -> str: + suffix = Path(urllib.parse.urlsplit(url).path).suffix.lower() + if suffix in KNOWN_SUFFIXES: + return suffix + return ASSET_TYPES.get(kind, ".bin") + + +def localize_snapshot(repo: Repo, cfg: dict[str, Any], snapshot: Path) -> dict[str, Any]: + """Copy assets from the listed hosts into mb-assets/ and point the snapshot's files at the copies.""" + hosts = list(cfg.get("localize") or []) + pattern = re.compile(r"https://(?:%s)/[^\s\"'`()<>\\{}$]+(?=[\s\"'`()<>\\]|$)" % "|".join(map(re.escape, hosts))) + assets = snapshot / "mb-assets" + copies: dict[str, str | None] = {} + skipped: list[dict[str, str]] = [] + + def reference(target: str, source: Path) -> str: + """How `source` should point at the copy at `target` (a path inside the snapshot).""" + if source.suffix == ".css": # url() resolves against the stylesheet itself + return os.path.relpath(snapshot / target, source.parent).replace(os.sep, "/") + if cfg.get("root_paths"): + return "/" + target + if source.suffix in MARKUP_SUFFIXES: + return os.path.relpath(snapshot / target, source.parent).replace(os.sep, "/") + return target # scripts resolve against the page, which sits at the snapshot root + + def copy(url: str) -> str | None: + if url in copies: + return copies[url] + copies[url] = None # also stops a stylesheet that imports itself + try: + data, kind = fetch_remote(repo, url) + except MBError as err: + skipped.append({"url": url, "reason": str(err)}) + return None + suffix = asset_suffix(url, kind) + dest = assets / (hashlib.sha256(url.encode()).hexdigest()[:16] + suffix) + assets.mkdir(parents=True, exist_ok=True) + if suffix == ".css": # a hosted stylesheet, such as Google Fonts CSS, points at more files + dest.write_text(rewrite(data.decode("utf-8", "replace"), dest)) + else: + dest.write_bytes(data) + copies[url] = dest.relative_to(snapshot).as_posix() + return copies[url] + + def rewrite(text: str, source: Path) -> str: + def swap(match: re.Match) -> str: + url = match.group(0) + target = copy(html.unescape(url) if source.suffix in MARKUP_SUFFIXES else url) + return reference(target, source) if target else url + return pattern.sub(swap, text) + + files = [] + for path in sorted(snapshot.rglob("*")): + if path.suffix.lower() not in LOCALIZE_SUFFIXES or not path.is_file() or assets in path.parents: + continue + text = path.read_text("utf-8", errors="surrogateescape") + if not any(host in text for host in hosts): + continue + new = rewrite(text, path) + if new != text: + path.write_text(new, "utf-8", errors="surrogateescape") + files.append(path.relative_to(snapshot).as_posix()) + return {"hosts": hosts, "assets": sum(1 for v in copies.values() if v), "files": files, "skipped": skipped[:20]} + + +def prepare_snapshot(repo: Repo, cfg: dict[str, Any], snapshot: Path) -> dict[str, Any]: + report: dict[str, Any] = {} + if cfg.get("scrub"): + report["scrubbed"] = scrub_snapshot(snapshot, cfg["scrub"]) + if cfg.get("localize"): + report["localized"] = localize_snapshot(repo, cfg, snapshot) + return report + + +def serve_board(root: str, port: int) -> None: + """The board's server: the state folder on 127.0.0.1, and each frame at the root of r-t.localhost.""" + + class Handler(http.server.SimpleHTTPRequestHandler): + def translate_path(self, path: str) -> str: + match = FRAME_HOST.match((self.headers.get("Host") or "").rsplit(":", 1)[0].lower()) + if match: + hits = sorted((Path(root) / "frames").glob(f"r{match[1]}-*/t{match[2]}-*")) + self.directory = str(hits[0]) if len(hits) == 1 else str(Path(root) / "frames" / ".none") + return super().translate_path(path) + + server = http.server.ThreadingHTTPServer(("127.0.0.1", port), functools.partial(Handler, directory=root)) + server.serve_forever() + + # ---------------------------------------------------------------- commands @@ -492,10 +805,26 @@ def cmd_init(repo: Repo, args: argparse.Namespace) -> dict[str, Any]: if not trunk: raise MBError("the main checkout is on a detached HEAD", hint="pass --trunk ") cfg["trunk"] = trunk + if args.preset: + cfg.update(design_studio_preset(Path(repo.main_worktree)), preset=args.preset) for key in ("name", "build", "out", "entry", "setup", "canvas", "twombly_version", "takes_dir", "twombly_port"): value = getattr(args, key, None) if value is not None: cfg[key] = None if value == "" else value + if args.root_paths is not None: + cfg["root_paths"] = args.root_paths + if args.localize is not None: + cfg["localize"] = [h.strip().lower() for h in args.localize.split(",") if h.strip()] + if args.localize_fonts: + cfg["localize"] = list(dict.fromkeys([*cfg["localize"], *GOOGLE_FONT_HOSTS])) + bad = [h for h in cfg["localize"] if not re.fullmatch(r"[a-z0-9-]+(\.[a-z0-9-]+)+", h)] + if bad: + raise MBError(f"not a host name: {', '.join(bad)}", hint="pass bare hosts, e.g. --localize images.example.com") + if args.scrub is not None: + cfg["scrub"] = [r.strip() for r in args.scrub.split(",") if r.strip()] + unknown = [r for r in cfg["scrub"] if r not in SCRUB_RULES] + if unknown: + raise MBError(f"unknown scrub rule: {', '.join(unknown)}", hint=f"known: {', '.join(SCRUB_RULES)}") if args.max_takes is not None: if not 1 <= args.max_takes <= MAX_TAKES_CEILING: raise MBError(f"--max-takes must be between 1 and {MAX_TAKES_CEILING}") @@ -527,16 +856,21 @@ def cmd_round_new(repo: Repo, args: argparse.Namespace) -> dict[str, Any]: base_ref = args.from_ref or state["config"]["trunk"] if not repo.ref_exists(base_ref): raise MBError(f"'{base_ref}' is not a commit") - rnd = add_round(state, args.slug, args.question, git("rev-parse", base_ref, cwd=repo.cwd), base_ref, None) - return {"ok": True, "round": rnd} + warning = check_view(repo, state["config"], args.view, ref=base_ref) + rnd = add_round(state, args, git("rev-parse", base_ref, cwd=repo.cwd), base_ref, None) + return {"ok": True, "round": rnd, **if_set("warning", warning)} -def add_round(state: dict[str, Any], slug: str, question: str, base: str, base_ref: str, built_on: str | None) -> dict[str, Any]: +def add_round(state: dict[str, Any], args: argparse.Namespace, base: str, base_ref: str, built_on: str | None, + inherit: dict[str, Any] | None = None) -> dict[str, Any]: n = len(state["rounds"]) + 1 - rid = f"r{n}-{slugify(slug)}" + slug = slugify(args.slug) + rid = f"r{n}-{slug}" + inherit = inherit or {} rnd = { - "id": rid, "n": n, "slug": slugify(slug), "question": question, "base": base, "base_ref": base_ref, + "id": rid, "n": n, "slug": slug, "question": args.question, "base": base, "base_ref": base_ref, "built_on": built_on, "status": "open", "picked": None, "created_at": now(), "takes": {}, + "entry": args.entry or inherit.get("entry"), "view": args.view or inherit.get("view"), } state["rounds"][rid] = rnd return rnd @@ -546,8 +880,10 @@ def cmd_round_next(repo: Repo, args: argparse.Namespace) -> dict[str, Any]: with locked_state(repo) as state: src_round, take = resolve_take(state, args.take) ref = take_ref(repo, take) - rnd = add_round(state, args.slug, args.question, git("rev-parse", ref, cwd=repo.cwd), ref, f"{src_round['id']}/{take['id']}") - return {"ok": True, "round": rnd} + warning = check_view(repo, state["config"], args.view, ref=ref) + inherit = {"entry": take.get("entry") or src_round.get("entry"), "view": take.get("view") or src_round.get("view")} + rnd = add_round(state, args, git("rev-parse", ref, cwd=repo.cwd), ref, f"{src_round['id']}/{take['id']}", inherit) + return {"ok": True, "round": rnd, **if_set("warning", warning)} def take_ref(repo: Repo, take: dict[str, Any]) -> str: @@ -573,6 +909,7 @@ def cmd_take_new(repo: Repo, args: argparse.Namespace) -> dict[str, Any]: if args.from_take: _, src = resolve_take(state, args.from_take, round_hint=rnd["id"]) base, from_take = git("rev-parse", take_ref(repo, src), cwd=repo.cwd), src["id"] + warning = check_view(repo, cfg, args.view, ref=base) # before any branch exists n, slug = len(rnd["takes"]) + 1, slugify(args.slug) tid = f"t{n}-{slug}" branch = f"mb/{rnd['id']}/{tid}" @@ -584,11 +921,12 @@ def cmd_take_new(repo: Repo, args: argparse.Namespace) -> dict[str, Any]: take = { "id": tid, "n": n, "slug": slug, "branch": branch, "worktree": str(path), "base": base, "from_take": from_take, "note": args.note or "", "status": "open", "commit": None, "frame": None, - "archive_tag": None, "created_at": now(), + "archive_tag": None, "created_at": now(), "entry": args.entry, "view": args.view, } rnd["takes"][tid] = take - setup = run_setup(cfg.get("setup"), take, take_env(repo, rnd, take)) - return {"ok": True, "round": rnd["id"], "take": take, "label": label(rnd, take), "setup": setup} + setup = run_setup(repo, cfg.get("setup"), take, take_env(repo, rnd, take)) + return {"ok": True, "round": rnd["id"], "take": take, "label": label(rnd, take), "setup": setup, + **if_set("warning", warning)} def take_env(repo: Repo, rnd: dict[str, Any], take: dict[str, Any]) -> dict[str, str]: @@ -597,14 +935,43 @@ def take_env(repo: Repo, rnd: dict[str, Any], take: dict[str, Any]) -> dict[str, MB_MAIN=str(repo.main_worktree)) -def run_setup(cmd: str | None, take: dict[str, Any], env: dict[str, str]) -> dict[str, Any] | None: +def run_setup(repo: Repo, cmd: str | None, take: dict[str, Any], env: dict[str, str]) -> dict[str, Any] | None: if not cmd: return None proc = subprocess.run(cmd, shell=True, cwd=take["worktree"], text=True, capture_output=True, env=env) if proc.returncode != 0: raise MBError(f"setup failed in {take['worktree']}", hint="fix the setup command with `mb.py init --setup`", stderr=proc.stderr.strip()[-2000:]) - return {"command": cmd, "ok": True} + return {"command": cmd, "ok": True, "ignored_links": ignore_setup_links(repo, Path(take["worktree"]))} + + +def ignore_setup_links(repo: Repo, worktree: Path) -> list[str]: + """Keep symlinks that setup made out of take commits. + + `.gitignore` entries like `node_modules/` match folders only, so a linked + node_modules would otherwise be committed. The links go into the repo's + local `info/exclude`, which is never committed or pushed. + """ + out = git("ls-files", "-z", "--others", "--exclude-standard", cwd=worktree) + links = [p for p in out.split("\0") if p and (worktree / p).is_symlink()] + exclude = repo.common_dir / "info" / "exclude" + text = exclude.read_text() if exclude.exists() else "" + lines = text.splitlines() + patterns = ["/" + re.sub(r"([\\*?\[!#])", r"\\\1", p) for p in links] # match each link literally + new = [line for line in dict.fromkeys(patterns) if line not in lines] + if not new: + return links + block = [] + if text and not text.endswith("\n"): + block.append("") + if EXCLUDE_HEADER not in lines: + if lines: + block.append("") + block.append(EXCLUDE_HEADER) + exclude.parent.mkdir(parents=True, exist_ok=True) + with open(exclude, "a") as fh: + fh.write("\n".join(block + new) + "\n") + return links def cmd_open(repo: Repo, args: argparse.Namespace) -> dict[str, Any]: @@ -648,29 +1015,34 @@ def cmd_publish(repo: Repo, args: argparse.Namespace) -> dict[str, Any]: out_dir = (worktree / cfg["out"]).resolve() if not out_dir.is_dir(): raise MBError(f"build output '{cfg['out']}' does not exist in the take", hint="set the right folder with `mb.py init --out`") - if not (out_dir / cfg["entry"]).exists(): - raise MBError(f"'{cfg['entry']}' is missing from the build output", hint="set the entry file with `mb.py init --entry`") + entry, view = frame_entry(cfg, rnd, take, out_dir) + if not (out_dir / entry_file(entry)).exists(): + raise MBError(f"'{entry_file(entry)}' is missing from the build output", + hint="set the entry with `mb.py init --entry`, or per round with `round new --entry`") snapshot = repo.state_dir / "frames" / rnd["id"] / take["id"] if snapshot.exists(): shutil.rmtree(snapshot) snapshot.parent.mkdir(parents=True, exist_ok=True) shutil.copytree(out_dir, snapshot, ignore=shutil.ignore_patterns(*FRAME_IGNORE)) + prepared = prepare_snapshot(repo, cfg, snapshot) failure: MBError | None = None with locked_state(repo) as fresh: f_rnd = fresh["rounds"][rnd["id"]] stored = f_rnd["takes"][take["id"]] try: - frame = adapter_for(repo, fresh, args.canvas).publish(f_rnd, stored, snapshot) + frame = adapter_for(repo, fresh, args.canvas).publish(f_rnd, stored, snapshot, entry) status = "published" except MBError as err: failure, status = err, "rejected" frame = {**(stored.get("frame") or {}), "adapter": "board", "path": str(snapshot.relative_to(repo.state_dir))} - frame.update(published_at=now(), commit=commit) + frame.update(published_at=now(), commit=commit, entry=entry, view=view) stored.update(status=status, commit=commit, frame=frame) BoardAdapter(repo, fresh).render() # the local board mirrors every canvas if failure: + failure.extra.setdefault("prepared", prepared) raise failure - return {"ok": True, "label": label(rnd, take), "committed": committed, "commit": commit, "frame": frame} + return {"ok": True, "label": label(rnd, take), "committed": committed, "commit": commit, "frame": frame, + **if_set("prepared", prepared)} def commit_take(worktree: Path, rnd: dict[str, Any], take: dict[str, Any], message: str | None) -> bool: @@ -800,6 +1172,20 @@ def cmd_feedback(repo: Repo, args: argparse.Namespace) -> dict[str, Any]: return {"ok": True, "takes_by_label": labels, **result} +def cmd_views(repo: Repo, args: argparse.Namespace) -> dict[str, Any]: + state = read_state(repo) + if state["config"].get("preset") != "design-studio": + raise MBError("views lists Design Studio views", hint="run `mb.py init --preset design-studio` in a Design Studio folder") + if args.take: + _, take = resolve_take(state, args.take) + files, source = prototype_files(repo, worktree=Path(take["worktree"])), take["worktree"] + else: + ref = args.from_ref or state["config"]["trunk"] + files, source = prototype_files(repo, ref=ref), ref + views = design_studio_views(files) + return {"ok": True, "source": source, "count": len(views), "views": views} + + # ---------------------------------------------------------------- cli @@ -820,6 +1206,12 @@ def build_parser() -> argparse.ArgumentParser: init.add_argument("--twombly-version") init.add_argument("--twombly-port", type=int, help="local port for Twombly (default 4848)") init.add_argument("--takes-dir") + init.add_argument("--preset", choices=PRESETS, help="fill in build, output and setup for a known kind of project") + init.add_argument("--root-paths", action=argparse.BooleanOptionalAction, default=None, + help="the build uses root paths like /assets/; the board serves each frame on its own origin") + init.add_argument("--localize", help="comma-separated hosts whose assets are copied into each frame ('' for none)") + init.add_argument("--localize-fonts", action="store_true", help="add the Google Fonts hosts to --localize") + init.add_argument("--scrub", help=f"comma-separated scrub rules ('' for none): {', '.join(SCRUB_RULES)}") init.set_defaults(func=cmd_init) st = sub.add_parser("status", help="rounds, takes, branches and frames") @@ -832,11 +1224,14 @@ def build_parser() -> argparse.ArgumentParser: rn.add_argument("slug") rn.add_argument("--question", required=True) rn.add_argument("--from", dest="from_ref", help="base commit or branch (default: trunk)") - rn.set_defaults(func=cmd_round_new) rx = rsub.add_parser("next", help="start a round from a take without merging it") rx.add_argument("take") rx.add_argument("slug") rx.add_argument("--question", required=True) + for parser in (rn, rx): + parser.add_argument("--entry", help="entry for this round's frames, e.g. 'index.html?variant=b'") + parser.add_argument("--view", help="Design Studio view the frames open on, e.g. '/.tsx'") + rn.set_defaults(func=cmd_round_new) rx.set_defaults(func=cmd_round_next) rc = rsub.add_parser("close", help="archive every take without picking one") rc.add_argument("round") @@ -849,6 +1244,8 @@ def build_parser() -> argparse.ArgumentParser: tn.add_argument("slug") tn.add_argument("--note") tn.add_argument("--from", dest="from_take", help="branch from another take instead of the round base") + tn.add_argument("--entry", help="entry for this take's frame (default: the round's)") + tn.add_argument("--view", help="Design Studio view for this take's frame (default: the round's)") tn.set_defaults(func=cmd_take_new) pub = sub.add_parser("publish", help="commit, build, snapshot and place a take on the canvas") @@ -874,10 +1271,19 @@ def build_parser() -> argparse.ArgumentParser: fb = sub.add_parser("feedback", help="what the person is looking at and their comments") fb.add_argument("--canvas", choices=sorted(ADAPTERS)) fb.set_defaults(func=cmd_feedback) + + vw = sub.add_parser("views", help="list Design Studio views a round can open on") + vw.add_argument("--from", dest="from_ref", help="commit or branch to list (default: trunk)") + vw.add_argument("--take", help="list a take's files instead, including new views") + vw.set_defaults(func=cmd_views) return p def main(argv: list[str] | None = None) -> int: + argv = sys.argv[1:] if argv is None else argv + if argv[:1] == ["_board-serve"]: # internal: the board's server process + serve_board(argv[1], int(argv[2])) + return 0 args = build_parser().parse_args(argv) try: repo = Repo(Path.cwd()) @@ -903,7 +1309,7 @@ def main(argv: list[str] | None = None) -> int: .viewport{{position:relative;overflow:hidden;border-top:1px solid var(--line);background:#fff}}.viewport iframe{{border:0;transform-origin:0 0;position:absolute;inset:0 auto auto 0}} .viewport.empty{{height:120px;display:grid;place-items:center;color:var(--muted);background:transparent}} .note{{margin:0;padding:8px 12px 0;color:var(--muted)}}a{{color:var(--accent)}} -

{title}

Local board · updated {updated} · reload to refresh

{body} +

{title}

Local board · updated {updated} · reload to refresh

{body} """ if __name__ == "__main__": diff --git a/tests/test_micro_branch.py b/tests/test_micro_branch.py index ed8d53a..66d67ae 100644 --- a/tests/test_micro_branch.py +++ b/tests/test_micro_branch.py @@ -1,6 +1,7 @@ """End-to-end tests for skills/design/micro-branch/scripts/mb.py on throwaway git repos.""" from __future__ import annotations +import hashlib import json import os import re @@ -9,6 +10,8 @@ import tempfile import textwrap import unittest +import urllib.parse +import urllib.request from pathlib import Path ROOT = Path(__file__).resolve().parents[1] @@ -56,6 +59,37 @@ ) +FAKE_STUDIO_BUILD = textwrap.dedent( + """ + import hashlib, json, pathlib + def sid(text): + return hashlib.sha1(text.encode()).hexdigest()[:16] + dist = pathlib.Path("dist") + (dist / "assets").mkdir(parents=True, exist_ok=True) + protos = [] + for meta in sorted(pathlib.Path("src/prototypes").glob("*/*/meta.json")): + folder, owner = meta.parent, meta.parent.parent.name + items = [] + for f in sorted(folder.rglob("*.tsx")): + rel = f.relative_to(folder) + if not any(part.startswith("_") for part in rel.parts): + items.append({"path": rel.as_posix(), "fileType": "view", "studioId": sid(folder.name + rel.as_posix())}) + (dist / "prototypes" / "artifacts" / owner).mkdir(parents=True, exist_ok=True) + (dist / "prototypes" / "artifacts" / owner / (folder.name + ".json")).write_text(json.dumps(items)) + protos.append({"id": folder.name, "contributorKey": owner, "studioId": sid(folder.name)}) + (dist / "prototypes" / "manifest.json").write_text(json.dumps({"prototypes": protos})) + (dist / "index.html").write_text( + '' + '') + (dist / "assets" / "app.css").write_text("@font-face{src:url(https://fonts.gstatic.com/s/demo/a.woff2)}") + (dist / "assets" / "app.js").write_text( + 'const c=`{"apiKey":"AIza' + "B" * 35 + '","authDomain":"x"}`;' + "const k={OAI_API_KEY:`excalidraw-oai-api-key`};" + 'const f="https://fonts.gstatic.com/s/demo/a.woff2";const t=`https://fonts.gstatic.com/${f}`;') + """ +) + + class Sandbox: def __init__(self, tmp: Path) -> None: self.tmp = tmp @@ -265,6 +299,32 @@ def test_twombly_refuses_share_and_records_gate_rejections(self) -> None: mb.TwomblyAdapter.call(object.__new__(mb.TwomblyAdapter), blocked) self.assertNotIn("share", log.read_text()) + def test_round_entry_with_query_reaches_frame_and_board(self) -> None: + self.sb.mb("round", "new", "variant", "--question", "q", "--entry", "index.html?variant=b") + self.cut("r1", "a", "

a

\n") + out = self.sb.mb("publish", "r1/t1") + self.assertEqual(out["frame"]["entry"], "index.html?variant=b") + board = (self.sb.repo / ".git" / "micro-branch" / "board" / "index.html").read_text() + self.assertIn('src="../frames/r1-variant/t1-a/index.html?variant=b"', board) + + def test_view_needs_the_design_studio_preset(self) -> None: + self.assertIn("design-studio preset", self.sb.mb("round", "new", "x", "--question", "q", "--view", "a", expect=2)["error"]) + self.sb.mb("round", "new", "x", "--question", "q") + self.assertIn("design-studio preset", self.sb.mb("take", "new", "r1", "a", "--view", "a", expect=2)["error"]) + self.assertEqual(self.sb.git("branch", "--list", "mb/*"), "") + self.assertIn("studio.config.json", self.sb.mb("init", "--preset", "design-studio", expect=2)["error"]) + + def test_setup_symlinks_never_land_in_take_commits(self) -> None: + self.sb.mb("init", "--setup", 'ln -s "$MB_MAIN" linked-deps') + self.sb.mb("round", "new", "deps", "--question", "q") + take = self.cut("r1", "a", "

a

\n") + tree = Path(take["worktree"]) + self.assertTrue((tree / "linked-deps").is_symlink()) + self.sb.mb("publish", "r1/t1") + self.assertNotIn("linked-deps", self.sb.git("ls-tree", "-r", "--name-only", "HEAD", cwd=tree).splitlines()) + self.assertNotIn("linked-deps", self.sb.git("status", "--porcelain", cwd=tree)) + self.assertIn("/linked-deps", (self.sb.repo / ".git" / "info" / "exclude").read_text()) + def test_outside_git_repo_fails_cleanly(self) -> None: proc = subprocess.run([sys.executable, str(MB), "status"], cwd=self.sb.tmp, capture_output=True, text=True, env={**os.environ, **GIT_ENV}) @@ -272,6 +332,100 @@ def test_outside_git_repo_fails_cleanly(self) -> None: self.assertIn("not inside a git work tree", json.loads(proc.stdout)["error"]) +class DesignStudioPresetTest(unittest.TestCase): + def setUp(self) -> None: + self._tmp = tempfile.TemporaryDirectory() + self.sb = sb = Sandbox(Path(self._tmp.name).resolve()) + sb.write(".gitignore", "node_modules/\ndist/\n") + sb.write("studio.config.json", '{"name": "Studio"}\n') + sb.write("build.py", FAKE_STUDIO_BUILD) + sb.write("src/systems/demo/package.json", "{}\n") + for proto, files in {"me/shop": ["Screens/home.tsx", "Screens/cart.tsx", "_parts/button.tsx"], + "you/blog": ["Screens/home.tsx"]}.items(): + sb.write(f"src/prototypes/{proto}/meta.json", "{}\n") + for rel in files: + sb.write(f"src/prototypes/{proto}/{rel}", "export default () => null\n") + (sb.repo / "node_modules").mkdir() + (sb.repo / "src" / "systems" / "demo" / "node_modules").mkdir() + sb.git("add", "-A") + sb.git("commit", "-q", "-m", "studio") + self.init = sb.mb("init", "--preset", "design-studio", "--canvas", "board", "--build", "python3 build.py", + "--localize-fonts") + + def tearDown(self) -> None: + self.sb.mb("canvas", "stop", "--canvas", "board") + self._tmp.cleanup() + + def cache(self, url: str, data: bytes, kind: str) -> str: + folder = self.sb.repo / ".git" / "micro-branch" / "cache" / "remote" + folder.mkdir(parents=True, exist_ok=True) + key = hashlib.sha256(url.encode()).hexdigest() + (folder / f"{key}.bin").write_bytes(data) + (folder / f"{key}.json").write_text(json.dumps({"type": kind})) + return key[:16] + + def test_preset_views_frame_entry_and_snapshot(self) -> None: + cfg = self.init["config"] + self.assertEqual((cfg["preset"], cfg["out"], cfg["root_paths"]), ("design-studio", "dist", True)) + self.assertEqual(cfg["scrub"], ["excalidraw-public-config"]) + self.assertEqual(cfg["localize"], ["fonts.googleapis.com", "fonts.gstatic.com"]) + views = [v["view"] for v in self.sb.mb("views")["views"]] + self.assertEqual(views, ["shop/Screens/cart.tsx", "shop/Screens/home.tsx", "blog/Screens/home.tsx"]) + vague = self.sb.mb("round", "new", "hero", "--question", "q", "--view", "Screens/home") + self.assertIn("matches 2 views", vague["warning"]) + self.sb.mb("round", "new", "cart", "--question", "Which cart?", "--view", "shop/Screens/cart") + take = self.sb.mb("take", "new", "r2", "dense") + tree = Path(take["take"]["worktree"]) + self.assertEqual(sorted(take["setup"]["ignored_links"]), ["node_modules", "src/systems/demo/node_modules"]) + self.assertEqual(self.sb.git("status", "--porcelain", cwd=tree), "") + woff_a = self.cache("https://fonts.gstatic.com/s/demo/a.woff2", b"wOF2-a", "font/woff2") + woff_b = self.cache("https://fonts.gstatic.com/s/demo/b.woff2", b"wOF2-b", "font/woff2") + sheet = self.cache("https://fonts.googleapis.com/css2?family=Demo&display=swap", + b"@font-face{src:url(https://fonts.gstatic.com/s/demo/b.woff2) format('woff2')}", "text/css") + out = self.sb.mb("publish", "r2/t1") + entry = out["frame"]["entry"] + self.assertTrue(entry.startswith("index.html?studio-preview=1&session=micro-branch&config=")) + config = json.loads(urllib.parse.parse_qs(urllib.parse.urlsplit(entry).query)["config"][0]) + self.assertEqual(config["target"]["prototype"], "shop") + self.assertEqual(config["target"]["artifact"], "Screens/cart.tsx") + self.assertEqual(config["href"], f"/prototypes/{config['target']['prototypeId']}/artifacts/{config['target']['artifactId']}") + self.assertEqual((config["surface"], config["dark"]), ("page", False)) + self.assertEqual(out["frame"]["view"], "shop/Screens/cart.tsx") + snap = self.sb.repo / ".git" / "micro-branch" / "frames" / "r2-cart" / "t1-dense" + js = (snap / "assets" / "app.js").read_text() + self.assertIn('"apiKey":"placeholder"', js) + self.assertNotIn("excalidraw-oai-api-key", js) + self.assertIn(f'"/mb-assets/{woff_a}.woff2"', js) + self.assertIn("`https://fonts.gstatic.com/${f}`", js) # template literals stay as they are + self.assertIn(f"url(../mb-assets/{woff_a}.woff2)", (snap / "assets" / "app.css").read_text()) + self.assertIn(f'href="/mb-assets/{sheet}.css"', (snap / "index.html").read_text()) + self.assertIn(f"url({woff_b}.woff2)", (snap / "mb-assets" / f"{sheet}.css").read_text()) + self.assertEqual((snap / "mb-assets" / f"{woff_b}.woff2").read_bytes(), b"wOF2-b") + self.assertEqual(out["prepared"]["localized"]["assets"], 3) + self.assertEqual(out["prepared"]["scrubbed"][0]["replaced"], 2) + self.assertNotIn("node_modules", self.sb.git("ls-tree", "-r", "--name-only", "HEAD", cwd=tree)) + + def test_unknown_view_fails_publish_and_board_serves_frames_on_their_own_origin(self) -> None: + self.sb.mb("round", "new", "cart", "--question", "q", "--view", "shop/Screens/cart.tsx") + self.sb.mb("take", "new", "r1", "a") + missing = self.sb.mb("take", "new", "r1", "b", "--view", "shop/Screens/nope.tsx") + self.assertIn("no view matches", missing["warning"]) + self.cache("https://fonts.gstatic.com/s/demo/a.woff2", b"a", "font/woff2") + self.cache("https://fonts.gstatic.com/s/demo/b.woff2", b"b", "font/woff2") + self.cache("https://fonts.googleapis.com/css2?family=Demo&display=swap", b"", "text/css") + self.assertIn("no view matches", self.sb.mb("publish", "r1/t2", expect=2)["error"]) + self.sb.mb("publish", "r1/t1") + board = self.sb.mb("canvas", "open", "--canvas", "board") + port = urllib.parse.urlsplit(board["url"]).port + page = (self.sb.repo / ".git" / "micro-branch" / "board" / "index.html").read_text() + self.assertIn('data-frame-host="r1-t1"', page) + request = urllib.request.Request(f"http://127.0.0.1:{port}/assets/app.css", headers={"Host": f"r1-t1.localhost:{port}"}) + with urllib.request.urlopen(request, timeout=5) as resp: + self.assertIn("mb-assets", resp.read().decode()) + with urllib.request.urlopen(board["url"], timeout=5) as resp: + self.assertIn("R1 · T1 a", resp.read().decode()) + + class MicroBranchPortabilityTest(unittest.TestCase): def test_skill_names_no_employer_or_private_system(self) -> None: banned = re.compile(r"change\.org|sunrise|corgi|@change/|change-prototypes", re.IGNORECASE)