From 640eecdfb87d34fdd36b02fd718c993f914ac01b Mon Sep 17 00:00:00 2001 From: sidmohanty11 Date: Mon, 20 Jul 2026 13:33:38 +0200 Subject: [PATCH 1/4] fix(core): keep feature-flag definitions out of the browser server graph Importing feature-flag definitions from @agent-native/core/feature-flags dragged the barrel's server store -> settings/store -> db/client -> request-telemetry chain into the client dev graph. Vite dev does not tree-shake, so the browser evaluated it and request-telemetry's top-level new AsyncLocalStorage() threw against the externalized node:async_hooks stub, breaking app load in dev (prod tree-shakes it away). - Add client-safe @agent-native/core/feature-flags/registry entry (package export + dev source alias + optimizeDeps). - Resolve AsyncLocalStorage / EventEmitter lazily via process.getBuiltinModule (shared/optional-node-builtins.ts); no top-level value import, no-op fallback off Node. - Repoint design + clips shared config and all 15 feature-flags SKILL.md docs at /registry. - Add browser-safe import-graph regression guard spec. --- .../browser-safe-feature-flag-registry.md | 24 +++ packages/core/package.json | 1 + packages/core/src/db/request-telemetry.ts | 40 +++- .../feature-flags/browser-safe.guard.spec.ts | 194 ++++++++++++++++++ packages/core/src/settings/store.ts | 21 +- .../core/src/shared/optional-node-builtins.ts | 66 ++++++ packages/core/src/vite/client.ts | 5 + .../.agents/skills/feature-flags/SKILL.md | 2 +- .../.agents/skills/feature-flags/SKILL.md | 2 +- .../.agents/skills/feature-flags/SKILL.md | 2 +- .../.agents/skills/feature-flags/SKILL.md | 2 +- .../.agents/skills/feature-flags/SKILL.md | 2 +- .../.agents/skills/feature-flags/SKILL.md | 2 +- templates/clips/shared/feature-flags.ts | 2 +- .../.agents/skills/feature-flags/SKILL.md | 2 +- .../.agents/skills/feature-flags/SKILL.md | 2 +- templates/design/shared/full-app.ts | 2 +- .../.agents/skills/feature-flags/SKILL.md | 2 +- .../.agents/skills/feature-flags/SKILL.md | 2 +- .../.agents/skills/feature-flags/SKILL.md | 2 +- .../.agents/skills/feature-flags/SKILL.md | 2 +- .../.agents/skills/feature-flags/SKILL.md | 2 +- .../.agents/skills/feature-flags/SKILL.md | 2 +- .../.agents/skills/feature-flags/SKILL.md | 2 +- 24 files changed, 354 insertions(+), 31 deletions(-) create mode 100644 .changeset/browser-safe-feature-flag-registry.md create mode 100644 packages/core/src/feature-flags/browser-safe.guard.spec.ts create mode 100644 packages/core/src/shared/optional-node-builtins.ts diff --git a/.changeset/browser-safe-feature-flag-registry.md b/.changeset/browser-safe-feature-flag-registry.md new file mode 100644 index 0000000000..589c6b5548 --- /dev/null +++ b/.changeset/browser-safe-feature-flag-registry.md @@ -0,0 +1,24 @@ +--- +"@agent-native/core": patch +--- + +fix(core): keep feature-flag definitions out of the browser server graph + +App-shared config that imported feature-flag definitions from +`@agent-native/core/feature-flags` pulled the barrel's server re-exports +(`store` → `settings/store` → `db/client` → request telemetry) into the client +dev graph. Vite's dev server does not tree-shake, so the browser evaluated that +server chain and `request-telemetry`'s top-level `new AsyncLocalStorage()` threw +against the externalized `node:async_hooks` stub, breaking app load in dev +(production tree-shakes it away and was unaffected). + +- Add a client-safe `@agent-native/core/feature-flags/registry` entry for + `defineFeatureFlag` / `defineFeatureFlags` / `registerFeatureFlags` so shared + config no longer imports the server barrel. +- Make `db/request-telemetry` and `settings/store` resolve `AsyncLocalStorage` / + `EventEmitter` lazily via `process.getBuiltinModule` (matching + `server/request-context`) instead of a top-level value import, so the modules + can be evaluated in any runtime without tripping the browser stub. +- Add a regression guard asserting the client-safe registry entry never reaches + the server layer and that those modules never statically value-import the + builtins. diff --git a/packages/core/package.json b/packages/core/package.json index 55db8e0465..d04a44b0bf 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -159,6 +159,7 @@ "./application-state": "./dist/application-state/index.js", "./settings": "./dist/settings/index.js", "./feature-flags": "./dist/feature-flags/index.js", + "./feature-flags/registry": "./dist/feature-flags/registry.js", "./feature-flags/actions/get-feature-flags": "./dist/feature-flags/actions/get-feature-flags.js", "./feature-flags/actions/list-feature-flags": "./dist/feature-flags/actions/list-feature-flags.js", "./feature-flags/actions/set-feature-flag": "./dist/feature-flags/actions/set-feature-flag.js", diff --git a/packages/core/src/db/request-telemetry.ts b/packages/core/src/db/request-telemetry.ts index 0b4186304a..e67de8fe1d 100644 --- a/packages/core/src/db/request-telemetry.ts +++ b/packages/core/src/db/request-telemetry.ts @@ -1,4 +1,4 @@ -import { AsyncLocalStorage } from "node:async_hooks"; +import { getAsyncLocalStorageCtor } from "../shared/optional-node-builtins.js"; export type DatabaseOperationKind = "connect" | "query"; @@ -30,15 +30,39 @@ interface StartupDatabaseTelemetryState { claimed: boolean; telemetry: DatabaseRequestTelemetry; } +interface TelemetryStorage { + getStore(): DatabaseRequestTelemetry | undefined; + run(store: DatabaseRequestTelemetry, fn: () => T): T; + enterWith(store: DatabaseRequestTelemetry): void; +} + type GlobalWithDatabaseTelemetry = typeof globalThis & { - [STORAGE_KEY]?: AsyncLocalStorage; + [STORAGE_KEY]?: TelemetryStorage; [STARTUP_STATE_KEY]?: StartupDatabaseTelemetryState; }; const globalRef = globalThis as GlobalWithDatabaseTelemetry; -const storage = - globalRef[STORAGE_KEY] ?? - (globalRef[STORAGE_KEY] = new AsyncLocalStorage()); + +// AsyncLocalStorage is resolved lazily, never at module load: this module can +// land in the browser dev graph, where a top-level `new AsyncLocalStorage()` +// would throw against Vite's externalized `node:async_hooks` stub. On non-Node +// runtimes telemetry no-ops. +const NOOP_STORAGE: TelemetryStorage = { + getStore: () => undefined, + run: (_store, fn) => fn(), + enterWith: () => {}, +}; + +function getStorage(): TelemetryStorage { + const existing = globalRef[STORAGE_KEY]; + if (existing) return existing; + const Ctor = getAsyncLocalStorageCtor(); + const created: TelemetryStorage = Ctor + ? new Ctor() + : NOOP_STORAGE; + globalRef[STORAGE_KEY] = created; + return created; +} export function createDatabaseRequestTelemetry(): DatabaseRequestTelemetry { return { @@ -66,7 +90,7 @@ const startupState = }); function currentDatabaseTelemetry(): DatabaseRequestTelemetry | undefined { - const requestTelemetry = storage.getStore(); + const requestTelemetry = getStorage().getStore(); if (requestTelemetry) return requestTelemetry; if (!startupState.claimed && Date.now() <= startupState.captureUntil) { return startupState.telemetry; @@ -86,13 +110,13 @@ export function runWithDatabaseRequestTelemetry( telemetry: DatabaseRequestTelemetry, fn: () => T, ): T { - return storage.run(telemetry, fn); + return getStorage().run(telemetry, fn); } export function enterDatabaseRequestTelemetry( telemetry: DatabaseRequestTelemetry, ): void { - storage.enterWith(telemetry); + getStorage().enterWith(telemetry); } export function beginDatabaseOperation( diff --git a/packages/core/src/feature-flags/browser-safe.guard.spec.ts b/packages/core/src/feature-flags/browser-safe.guard.spec.ts new file mode 100644 index 0000000000..02fb671c7d --- /dev/null +++ b/packages/core/src/feature-flags/browser-safe.guard.spec.ts @@ -0,0 +1,194 @@ +import { existsSync, readFileSync, statSync } from "node:fs"; +import { dirname, join, resolve, sep } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { describe, expect, it } from "vitest"; + +/** + * Regression guard for the dev-only "server code in the browser" crash. + * + * Root cause history: `@agent-native/core/feature-flags` (the server barrel) + * value-re-exports `store.ts`, which reaches `settings/store` → `db/client` → + * `request-telemetry`. When app-shared config (imported by client routes) + * pulled definitions from that barrel, Vite's dev server (no tree-shaking) + * evaluated the whole server chain in the browser. `request-telemetry`'s + * top-level `new AsyncLocalStorage()` and `settings/store`'s top-level + * `new EventEmitter()` then threw against Vite's externalized node-builtin + * stubs and broke the app on load. + * + * Two invariants keep it fixed: + * 1. `feature-flags/registry.ts` (the client-safe definition entry that + * app-shared config imports) must never statically reach the server layer + * (`db/`, `settings/`, `server/`, or the feature-flags server modules) or a + * Node builtin. + * 2. The modules that legitimately touch Node builtins in a possibly-browser + * graph must not statically value-import them (only `import type`), so the + * module can be evaluated anywhere without tripping the externalized stub. + */ + +const SRC_DIR = resolve(dirname(fileURLToPath(import.meta.url)), ".."); + +const NODE_BUILTINS = new Set([ + "assert", + "async_hooks", + "buffer", + "child_process", + "cluster", + "console", + "crypto", + "dgram", + "dns", + "events", + "fs", + "fs/promises", + "http", + "http2", + "https", + "module", + "net", + "os", + "path", + "perf_hooks", + "process", + "querystring", + "readline", + "stream", + "string_decoder", + "timers", + "tls", + "tty", + "url", + "util", + "v8", + "vm", + "worker_threads", + "zlib", +]); + +function isNodeBuiltin(spec: string): boolean { + if (spec.startsWith("node:")) return true; + return NODE_BUILTINS.has(spec); +} + +/** + * Runtime (non-type) static import / re-export specifiers. Whole-statement + * `import type` / `export type` is skipped (erased at build); dynamic + * `import()` is intentionally skipped (it does not force eager evaluation). + */ +function staticSpecifiers(code: string): string[] { + const specs: string[] = []; + const fromRe = + /(?:^|\n)[ \t]*(?:import|export)\b([^;'"]*?)\bfrom[ \t\n]*["']([^"']+)["']/g; + let m: RegExpExecArray | null; + while ((m = fromRe.exec(code))) { + const clause = m[1] ?? ""; + if (/^\s*type\b/.test(clause)) continue; + specs.push(m[2]); + } + const sideEffectRe = /(?:^|\n)[ \t]*import[ \t]*["']([^"']+)["']/g; + while ((m = sideEffectRe.exec(code))) specs.push(m[1]); + return specs; +} + +function resolveRelative(fromFile: string, spec: string): string | null { + if (!spec.startsWith(".")) return null; + const base = resolve(dirname(fromFile), spec); + const candidates = [ + base, + base.replace(/\.js$/, ".ts"), + base.replace(/\.js$/, ".tsx"), + `${base}.ts`, + `${base}.tsx`, + join(base, "index.ts"), + join(base, "index.tsx"), + ]; + for (const c of candidates) { + if (existsSync(c) && statSync(c).isFile()) return c; + } + return null; +} + +interface WalkResult { + visited: Set; + builtinHits: { file: string; spec: string }[]; +} + +function walkGraph(entry: string): WalkResult { + const visited = new Set(); + const builtinHits: { file: string; spec: string }[] = []; + const stack = [entry]; + while (stack.length) { + const file = stack.pop()!; + if (visited.has(file)) continue; + visited.add(file); + const code = readFileSync(file, "utf8"); + for (const spec of staticSpecifiers(code)) { + if (isNodeBuiltin(spec)) { + builtinHits.push({ file, spec }); + continue; + } + const resolved = resolveRelative(file, spec); + if (resolved) stack.push(resolved); + // Bare npm specifiers are out of scope: the guard protects our own src + // graph and any direct `node:` import, not third-party transitive deps. + } + } + return { visited, builtinHits }; +} + +const rel = (file: string) => file.slice(SRC_DIR.length + 1); + +const FORBIDDEN_SERVER_SEGMENTS = [ + `${sep}db${sep}`, + `${sep}settings${sep}`, + `${sep}server${sep}`, +]; +const FORBIDDEN_FF_SERVER = [ + join(SRC_DIR, "feature-flags", "store.ts"), + join(SRC_DIR, "feature-flags", "plugin.ts"), + join(SRC_DIR, "feature-flags", "a2a-action-route.ts"), +]; + +describe("feature-flags/registry is a browser-safe leaf", () => { + const entry = join(SRC_DIR, "feature-flags", "registry.ts"); + + it("never statically reaches the server layer", () => { + const { visited } = walkGraph(entry); + const leaked = [...visited].filter( + (f) => + f !== entry && + (FORBIDDEN_SERVER_SEGMENTS.some((s) => f.includes(s)) || + FORBIDDEN_FF_SERVER.includes(f)), + ); + expect( + leaked.map(rel), + "registry.ts must not pull server modules into the client graph", + ).toEqual([]); + }); + + it("never statically imports a Node builtin", () => { + const { builtinHits } = walkGraph(entry); + expect( + builtinHits.map((h) => `${rel(h.file)} -> ${h.spec}`), + "registry.ts must stay free of node: builtins", + ).toEqual([]); + }); +}); + +describe("possibly-browser modules access Node builtins lazily", () => { + const VALUE_IMPORT_RE = + /(?:^|\n)[ \t]*import[ \t]+(?!type\b)[^;]*from[ \t\n]*["'](?:node:)?(?:async_hooks|events)["']/; + const files = [ + join(SRC_DIR, "db", "request-telemetry.ts"), + join(SRC_DIR, "settings", "store.ts"), + ]; + for (const file of files) { + it(`${rel(file)} does not statically value-import async_hooks/events`, () => { + const code = readFileSync(file, "utf8"); + expect( + VALUE_IMPORT_RE.test(code), + `${rel(file)} must use process.getBuiltinModule via shared/optional-node-builtins, not a top-level value import`, + ).toBe(false); + }); + } +}); diff --git a/packages/core/src/settings/store.ts b/packages/core/src/settings/store.ts index 565e629886..cc845accff 100644 --- a/packages/core/src/settings/store.ts +++ b/packages/core/src/settings/store.ts @@ -1,9 +1,10 @@ -import { EventEmitter } from "events"; +import type { EventEmitter } from "node:events"; import { getDbExec, isPostgres, intType } from "../db/client.js"; import { ensureIndexExists, ensureTableExists } from "../db/ddl-guard.js"; import { widenIntColumnsToBigInt } from "../db/widen-columns.js"; import { getRequestContext } from "../server/request-context.js"; +import { createEventEmitter } from "../shared/optional-node-builtins.js"; let _initPromise: Promise | undefined; @@ -29,12 +30,20 @@ function requestSettingsCache(): Map | null { return cache; } -const _emitter = new EventEmitter(); +// Created lazily so this module can be evaluated in the browser dev graph +// without a top-level `new EventEmitter()` tripping Vite's externalized +// `node:events` stub. The emitter drives server-side SSE fan-out only. +let _emitter: EventEmitter | undefined; -export function getSettingsEmitter(): EventEmitter { +function settingsEmitter(): EventEmitter { + if (!_emitter) _emitter = createEventEmitter(); return _emitter; } +export function getSettingsEmitter(): EventEmitter { + return settingsEmitter(); +} + function settingsTable(): string { return isPostgres() ? "public.settings" : "settings"; } @@ -174,7 +183,7 @@ export async function mutateSetting( }); if (result.rowsAffected === 0) continue; requestSettingsCache()?.set(key, nextRaw); - _emitter.emit("settings", { + settingsEmitter().emit("settings", { source: "settings", type: "change", key, @@ -200,7 +209,7 @@ export async function putSetting( args: [key, JSON.stringify(value), Date.now()], }); requestSettingsCache()?.set(key, JSON.stringify(value)); - _emitter.emit("settings", { + settingsEmitter().emit("settings", { source: "settings", type: "change", key, @@ -221,7 +230,7 @@ export async function deleteSetting( }); requestSettingsCache()?.set(key, null); if (result.rowsAffected > 0) { - _emitter.emit("settings", { + settingsEmitter().emit("settings", { source: "settings", type: "delete", key, diff --git a/packages/core/src/shared/optional-node-builtins.ts b/packages/core/src/shared/optional-node-builtins.ts new file mode 100644 index 0000000000..097d78a717 --- /dev/null +++ b/packages/core/src/shared/optional-node-builtins.ts @@ -0,0 +1,66 @@ +/** + * Browser-safe accessors for optional Node builtins. + * + * `node:async_hooks` and `node:events` are server-only, but the modules that + * use them (db telemetry, settings/resource emitters) can land in the browser + * dev graph before tree-shaking runs. A static `import { X } from "node:..."` + * resolves to Vite's externalized stub and throws the instant the value is + * touched at module load. We read the builtin through `process.getBuiltinModule` + * instead — the same guard `server/request-context.ts` uses — so there is no + * static `node:` import to evaluate in the browser, and callers fall back to a + * no-op on non-Node runtimes (browser, or Node without getBuiltinModule). + */ +import type { AsyncLocalStorage } from "node:async_hooks"; +import type { EventEmitter } from "node:events"; + +type BuiltinProcess = { + versions?: { node?: string }; + getBuiltinModule?: (name: string) => unknown; +}; + +function nodeBuiltin(name: string): T | undefined { + const proc = + typeof process === "undefined" ? undefined : (process as BuiltinProcess); + if ( + typeof window !== "undefined" || + !proc || + !proc.versions?.node || + typeof proc.getBuiltinModule !== "function" + ) { + return undefined; + } + return proc.getBuiltinModule(name) as T | undefined; +} + +export type AsyncLocalStorageCtor = new () => AsyncLocalStorage; + +export function getAsyncLocalStorageCtor(): AsyncLocalStorageCtor | undefined { + return nodeBuiltin<{ AsyncLocalStorage: AsyncLocalStorageCtor }>( + "node:async_hooks", + )?.AsyncLocalStorage; +} + +type EventEmitterCtor = new () => EventEmitter; + +/** + * Returns a real Node EventEmitter on Node, or a no-op emitter elsewhere. The + * emitters that use this drive server-side SSE fan-out; nothing subscribes in + * the browser, so the no-op fallback is inert rather than wrong. + */ +export function createEventEmitter(): EventEmitter { + const Ctor = nodeBuiltin<{ EventEmitter: EventEmitterCtor }>( + "node:events", + )?.EventEmitter; + if (Ctor) return new Ctor(); + const noop = { + on: () => noop, + off: () => noop, + once: () => noop, + addListener: () => noop, + removeListener: () => noop, + removeAllListeners: () => noop, + setMaxListeners: () => noop, + emit: () => false, + }; + return noop as unknown as EventEmitter; +} diff --git a/packages/core/src/vite/client.ts b/packages/core/src/vite/client.ts index 3e1d1a0378..bccf57ee47 100644 --- a/packages/core/src/vite/client.ts +++ b/packages/core/src/vite/client.ts @@ -805,6 +805,7 @@ const CORE_CLIENT_SUBPATHS = [ "@agent-native/core/client/components/RemoteSelectionRings", "@agent-native/core/client/visual-style-controls", "@agent-native/core/client/feature-flags", + "@agent-native/core/feature-flags/registry", "@agent-native/core/client/hooks", "@agent-native/core/client/host", "@agent-native/core/client/i18n", @@ -1158,6 +1159,10 @@ function getCoreSourceAliases( coreSrc, "client/feature-flags/index.ts", ), + "@agent-native/core/feature-flags/registry": path.join( + coreSrc, + "feature-flags/registry.ts", + ), "@agent-native/core/client/hooks": path.join( coreSrc, "client/hooks/index.ts", diff --git a/templates/analytics/.agents/skills/feature-flags/SKILL.md b/templates/analytics/.agents/skills/feature-flags/SKILL.md index c1d9e2448d..4759514ae1 100644 --- a/templates/analytics/.agents/skills/feature-flags/SKILL.md +++ b/templates/analytics/.agents/skills/feature-flags/SKILL.md @@ -37,7 +37,7 @@ Keep definitions in a shared TypeScript module so server and client code use the same stable key. Flags are boolean and default-off. ```ts -import { defineFeatureFlag } from "@agent-native/core/feature-flags"; +import { defineFeatureFlag } from "@agent-native/core/feature-flags/registry"; export const FULL_APP_BUILDING = defineFeatureFlag({ key: "full-app-building", diff --git a/templates/assets/.agents/skills/feature-flags/SKILL.md b/templates/assets/.agents/skills/feature-flags/SKILL.md index c1d9e2448d..4759514ae1 100644 --- a/templates/assets/.agents/skills/feature-flags/SKILL.md +++ b/templates/assets/.agents/skills/feature-flags/SKILL.md @@ -37,7 +37,7 @@ Keep definitions in a shared TypeScript module so server and client code use the same stable key. Flags are boolean and default-off. ```ts -import { defineFeatureFlag } from "@agent-native/core/feature-flags"; +import { defineFeatureFlag } from "@agent-native/core/feature-flags/registry"; export const FULL_APP_BUILDING = defineFeatureFlag({ key: "full-app-building", diff --git a/templates/brain/.agents/skills/feature-flags/SKILL.md b/templates/brain/.agents/skills/feature-flags/SKILL.md index c1d9e2448d..4759514ae1 100644 --- a/templates/brain/.agents/skills/feature-flags/SKILL.md +++ b/templates/brain/.agents/skills/feature-flags/SKILL.md @@ -37,7 +37,7 @@ Keep definitions in a shared TypeScript module so server and client code use the same stable key. Flags are boolean and default-off. ```ts -import { defineFeatureFlag } from "@agent-native/core/feature-flags"; +import { defineFeatureFlag } from "@agent-native/core/feature-flags/registry"; export const FULL_APP_BUILDING = defineFeatureFlag({ key: "full-app-building", diff --git a/templates/calendar/.agents/skills/feature-flags/SKILL.md b/templates/calendar/.agents/skills/feature-flags/SKILL.md index c1d9e2448d..4759514ae1 100644 --- a/templates/calendar/.agents/skills/feature-flags/SKILL.md +++ b/templates/calendar/.agents/skills/feature-flags/SKILL.md @@ -37,7 +37,7 @@ Keep definitions in a shared TypeScript module so server and client code use the same stable key. Flags are boolean and default-off. ```ts -import { defineFeatureFlag } from "@agent-native/core/feature-flags"; +import { defineFeatureFlag } from "@agent-native/core/feature-flags/registry"; export const FULL_APP_BUILDING = defineFeatureFlag({ key: "full-app-building", diff --git a/templates/chat/.agents/skills/feature-flags/SKILL.md b/templates/chat/.agents/skills/feature-flags/SKILL.md index c1d9e2448d..4759514ae1 100644 --- a/templates/chat/.agents/skills/feature-flags/SKILL.md +++ b/templates/chat/.agents/skills/feature-flags/SKILL.md @@ -37,7 +37,7 @@ Keep definitions in a shared TypeScript module so server and client code use the same stable key. Flags are boolean and default-off. ```ts -import { defineFeatureFlag } from "@agent-native/core/feature-flags"; +import { defineFeatureFlag } from "@agent-native/core/feature-flags/registry"; export const FULL_APP_BUILDING = defineFeatureFlag({ key: "full-app-building", diff --git a/templates/clips/.agents/skills/feature-flags/SKILL.md b/templates/clips/.agents/skills/feature-flags/SKILL.md index c1d9e2448d..4759514ae1 100644 --- a/templates/clips/.agents/skills/feature-flags/SKILL.md +++ b/templates/clips/.agents/skills/feature-flags/SKILL.md @@ -37,7 +37,7 @@ Keep definitions in a shared TypeScript module so server and client code use the same stable key. Flags are boolean and default-off. ```ts -import { defineFeatureFlag } from "@agent-native/core/feature-flags"; +import { defineFeatureFlag } from "@agent-native/core/feature-flags/registry"; export const FULL_APP_BUILDING = defineFeatureFlag({ key: "full-app-building", diff --git a/templates/clips/shared/feature-flags.ts b/templates/clips/shared/feature-flags.ts index b62e25f1c9..dd5b3732bc 100644 --- a/templates/clips/shared/feature-flags.ts +++ b/templates/clips/shared/feature-flags.ts @@ -1,7 +1,7 @@ import { defineFeatureFlag, defineFeatureFlags, -} from "@agent-native/core/feature-flags"; +} from "@agent-native/core/feature-flags/registry"; /** * Desktop app: use the custom ScreenCaptureKit (SCK) capture pipeline diff --git a/templates/content/.agents/skills/feature-flags/SKILL.md b/templates/content/.agents/skills/feature-flags/SKILL.md index c1d9e2448d..4759514ae1 100644 --- a/templates/content/.agents/skills/feature-flags/SKILL.md +++ b/templates/content/.agents/skills/feature-flags/SKILL.md @@ -37,7 +37,7 @@ Keep definitions in a shared TypeScript module so server and client code use the same stable key. Flags are boolean and default-off. ```ts -import { defineFeatureFlag } from "@agent-native/core/feature-flags"; +import { defineFeatureFlag } from "@agent-native/core/feature-flags/registry"; export const FULL_APP_BUILDING = defineFeatureFlag({ key: "full-app-building", diff --git a/templates/design/.agents/skills/feature-flags/SKILL.md b/templates/design/.agents/skills/feature-flags/SKILL.md index c1d9e2448d..4759514ae1 100644 --- a/templates/design/.agents/skills/feature-flags/SKILL.md +++ b/templates/design/.agents/skills/feature-flags/SKILL.md @@ -37,7 +37,7 @@ Keep definitions in a shared TypeScript module so server and client code use the same stable key. Flags are boolean and default-off. ```ts -import { defineFeatureFlag } from "@agent-native/core/feature-flags"; +import { defineFeatureFlag } from "@agent-native/core/feature-flags/registry"; export const FULL_APP_BUILDING = defineFeatureFlag({ key: "full-app-building", diff --git a/templates/design/shared/full-app.ts b/templates/design/shared/full-app.ts index 25367eddc2..7da5d80d2f 100644 --- a/templates/design/shared/full-app.ts +++ b/templates/design/shared/full-app.ts @@ -13,7 +13,7 @@ * a design as app-backed. */ -import { defineFeatureFlag } from "@agent-native/core/feature-flags"; +import { defineFeatureFlag } from "@agent-native/core/feature-flags/registry"; /** * Runtime rollout for full app building in the Design app. Builder diff --git a/templates/dispatch/.agents/skills/feature-flags/SKILL.md b/templates/dispatch/.agents/skills/feature-flags/SKILL.md index c1d9e2448d..4759514ae1 100644 --- a/templates/dispatch/.agents/skills/feature-flags/SKILL.md +++ b/templates/dispatch/.agents/skills/feature-flags/SKILL.md @@ -37,7 +37,7 @@ Keep definitions in a shared TypeScript module so server and client code use the same stable key. Flags are boolean and default-off. ```ts -import { defineFeatureFlag } from "@agent-native/core/feature-flags"; +import { defineFeatureFlag } from "@agent-native/core/feature-flags/registry"; export const FULL_APP_BUILDING = defineFeatureFlag({ key: "full-app-building", diff --git a/templates/forms/.agents/skills/feature-flags/SKILL.md b/templates/forms/.agents/skills/feature-flags/SKILL.md index c1d9e2448d..4759514ae1 100644 --- a/templates/forms/.agents/skills/feature-flags/SKILL.md +++ b/templates/forms/.agents/skills/feature-flags/SKILL.md @@ -37,7 +37,7 @@ Keep definitions in a shared TypeScript module so server and client code use the same stable key. Flags are boolean and default-off. ```ts -import { defineFeatureFlag } from "@agent-native/core/feature-flags"; +import { defineFeatureFlag } from "@agent-native/core/feature-flags/registry"; export const FULL_APP_BUILDING = defineFeatureFlag({ key: "full-app-building", diff --git a/templates/macros/.agents/skills/feature-flags/SKILL.md b/templates/macros/.agents/skills/feature-flags/SKILL.md index c1d9e2448d..4759514ae1 100644 --- a/templates/macros/.agents/skills/feature-flags/SKILL.md +++ b/templates/macros/.agents/skills/feature-flags/SKILL.md @@ -37,7 +37,7 @@ Keep definitions in a shared TypeScript module so server and client code use the same stable key. Flags are boolean and default-off. ```ts -import { defineFeatureFlag } from "@agent-native/core/feature-flags"; +import { defineFeatureFlag } from "@agent-native/core/feature-flags/registry"; export const FULL_APP_BUILDING = defineFeatureFlag({ key: "full-app-building", diff --git a/templates/mail/.agents/skills/feature-flags/SKILL.md b/templates/mail/.agents/skills/feature-flags/SKILL.md index c1d9e2448d..4759514ae1 100644 --- a/templates/mail/.agents/skills/feature-flags/SKILL.md +++ b/templates/mail/.agents/skills/feature-flags/SKILL.md @@ -37,7 +37,7 @@ Keep definitions in a shared TypeScript module so server and client code use the same stable key. Flags are boolean and default-off. ```ts -import { defineFeatureFlag } from "@agent-native/core/feature-flags"; +import { defineFeatureFlag } from "@agent-native/core/feature-flags/registry"; export const FULL_APP_BUILDING = defineFeatureFlag({ key: "full-app-building", diff --git a/templates/plan/.agents/skills/feature-flags/SKILL.md b/templates/plan/.agents/skills/feature-flags/SKILL.md index c1d9e2448d..4759514ae1 100644 --- a/templates/plan/.agents/skills/feature-flags/SKILL.md +++ b/templates/plan/.agents/skills/feature-flags/SKILL.md @@ -37,7 +37,7 @@ Keep definitions in a shared TypeScript module so server and client code use the same stable key. Flags are boolean and default-off. ```ts -import { defineFeatureFlag } from "@agent-native/core/feature-flags"; +import { defineFeatureFlag } from "@agent-native/core/feature-flags/registry"; export const FULL_APP_BUILDING = defineFeatureFlag({ key: "full-app-building", diff --git a/templates/slides/.agents/skills/feature-flags/SKILL.md b/templates/slides/.agents/skills/feature-flags/SKILL.md index c1d9e2448d..4759514ae1 100644 --- a/templates/slides/.agents/skills/feature-flags/SKILL.md +++ b/templates/slides/.agents/skills/feature-flags/SKILL.md @@ -37,7 +37,7 @@ Keep definitions in a shared TypeScript module so server and client code use the same stable key. Flags are boolean and default-off. ```ts -import { defineFeatureFlag } from "@agent-native/core/feature-flags"; +import { defineFeatureFlag } from "@agent-native/core/feature-flags/registry"; export const FULL_APP_BUILDING = defineFeatureFlag({ key: "full-app-building", diff --git a/templates/tasks/.agents/skills/feature-flags/SKILL.md b/templates/tasks/.agents/skills/feature-flags/SKILL.md index c1d9e2448d..4759514ae1 100644 --- a/templates/tasks/.agents/skills/feature-flags/SKILL.md +++ b/templates/tasks/.agents/skills/feature-flags/SKILL.md @@ -37,7 +37,7 @@ Keep definitions in a shared TypeScript module so server and client code use the same stable key. Flags are boolean and default-off. ```ts -import { defineFeatureFlag } from "@agent-native/core/feature-flags"; +import { defineFeatureFlag } from "@agent-native/core/feature-flags/registry"; export const FULL_APP_BUILDING = defineFeatureFlag({ key: "full-app-building", From 4f9385d6760020eeb8222b48eb73441759bfe09f Mon Sep 17 00:00:00 2001 From: sidmohanty11 Date: Wed, 22 Jul 2026 14:34:35 +0200 Subject: [PATCH 2/4] more parity based on rules --- .../bridge/editor-chrome.generated.ts | 106 +++- templates/design/AGENTS.md | 26 + .../actions/create-design-checkpoint.ts | 65 ++ .../design/actions/restore-design-version.ts | 124 ++++ .../design/CanvasAgentStateBadge.tsx | 114 ++++ .../app/components/design/DesignCanvas.tsx | 22 + .../design/SelectionAgentAffordance.tsx | 232 ++++++++ .../design/bridge/editor-chrome.bridge.ts | 195 +++++- .../design/editor-chrome-bridge.snap.test.ts | 198 +++++++ .../design/app/hooks/useDesignHotkeys.ts | 8 + templates/design/app/pages/DesignEditor.tsx | 554 ++++++++++++++++-- .../design-editor/canvas-agent-state.test.ts | 156 +++++ .../pages/design-editor/canvas-agent-state.ts | 50 ++ .../app/pages/design-editor/editor-state.ts | 6 +- .../design/app/pages/design-editor/history.ts | 49 ++ .../design-editor/paste-placement.test.ts | 495 ++++++++++++++++ .../pages/design-editor/paste-placement.ts | 285 +++++++++ .../selection-affordance-placement.test.ts | 78 +++ .../selection-affordance-placement.ts | 62 ++ .../design-editor/selection-history.test.ts | 154 +++++ .../design-editor/selection-topology.test.ts | 297 ++++++++++ .../pages/design-editor/selection-topology.ts | 81 +++ templates/design/server/db/schema.ts | 9 + .../server/lib/checkpoint-pruning.test.ts | 119 ++++ .../design/server/lib/checkpoint-pruning.ts | 41 ++ .../design/server/lib/design-checkpoint.ts | 116 ++++ templates/design/server/plugins/db.ts | 8 + 27 files changed, 3565 insertions(+), 85 deletions(-) create mode 100644 templates/design/actions/create-design-checkpoint.ts create mode 100644 templates/design/actions/restore-design-version.ts create mode 100644 templates/design/app/components/design/CanvasAgentStateBadge.tsx create mode 100644 templates/design/app/components/design/SelectionAgentAffordance.tsx create mode 100644 templates/design/app/pages/design-editor/canvas-agent-state.test.ts create mode 100644 templates/design/app/pages/design-editor/canvas-agent-state.ts create mode 100644 templates/design/app/pages/design-editor/paste-placement.test.ts create mode 100644 templates/design/app/pages/design-editor/paste-placement.ts create mode 100644 templates/design/app/pages/design-editor/selection-affordance-placement.test.ts create mode 100644 templates/design/app/pages/design-editor/selection-affordance-placement.ts create mode 100644 templates/design/app/pages/design-editor/selection-history.test.ts create mode 100644 templates/design/app/pages/design-editor/selection-topology.test.ts create mode 100644 templates/design/app/pages/design-editor/selection-topology.ts create mode 100644 templates/design/server/lib/checkpoint-pruning.test.ts create mode 100644 templates/design/server/lib/checkpoint-pruning.ts create mode 100644 templates/design/server/lib/design-checkpoint.ts diff --git a/templates/design/.generated/bridge/editor-chrome.generated.ts b/templates/design/.generated/bridge/editor-chrome.generated.ts index 439afaef36..ba865f1ca9 100644 --- a/templates/design/.generated/bridge/editor-chrome.generated.ts +++ b/templates/design/.generated/bridge/editor-chrome.generated.ts @@ -23,6 +23,13 @@ export const editorChromeBridgeScript: string = `"use strict"; return false; } })(); + var selectedLayerDragPriorityEnabled = (function() { + try { + return !!__SELECTED_LAYER_DRAG_PRIORITY__; + } catch (_e) { + return false; + } + })(); var scaleToolEnabled = false; function dndLog(phase, data) { if (!window.__DND_DEBUG) return; @@ -1582,6 +1589,7 @@ export const editorChromeBridgeScript: string = `"use strict"; clearComponentTag(); } var selectedEl = null; + var selectionChromeHidden = false; var hoveredEl = null; var activeNodeHtmlPreview = null; var lastHoverInfoPostedEl = null; @@ -2754,7 +2762,11 @@ export const editorChromeBridgeScript: string = `"use strict"; hideSelectionOverlay(); } } else if (selectedEl) { - positionOverlay(selectionOverlay, selectedEl); + if (selectionChromeHidden) { + hideSelectionOverlay(); + } else { + positionOverlay(selectionOverlay, selectedEl); + } } else { hideParentAutoLayoutOverlay(); } @@ -3319,6 +3331,28 @@ export const editorChromeBridgeScript: string = `"use strict"; window.parent.postMessage({ type: "clear-selection" }, "*"); return; } + if ((e.metaKey || e.ctrlKey) && !e.shiftKey && selectedEl) { + var stack = collectLayerHitCandidates(e.clientX, e.clientY); + var currentIdx = stack.elements.indexOf(selectedEl); + if (currentIdx !== -1) { + var stackKeys = stack.layerCandidates.map(function(candidate) { + return candidate.key; + }); + var nextKey = nextStackCandidate( + stackKeys, + stack.layerCandidates[currentIdx].key + ); + var nextEl = nextKey === null ? null : stack.elements[stackKeys.indexOf(nextKey)]; + if (nextEl && !isLayerInteractionBlocked(nextEl)) { + selectedSpacingHovered = false; + hoveredSpacingHandleKey = ""; + selectedEl = nextEl; + positionOverlay(selectionOverlay, selectedEl); + postElementSelect(selectedEl); + return; + } + } + } selectedSpacingHovered = false; hoveredSpacingHandleKey = ""; var previousSelectedEl = selectedEl; @@ -3488,29 +3522,27 @@ export const editorChromeBridgeScript: string = `"use strict"; document.addEventListener(events.move, onMove, true); document.addEventListener(events.up, onUp, true); } - function openContextMenuAtEvent(e) { - stopNativeInteraction(e); - blurActiveTextEditor(); + function collectLayerHitCandidates(clientX, clientY) { var shieldPointerEvents = shieldOverlay.style.pointerEvents; var selectionPointerEvents = selectionOverlay.style.pointerEvents; var highlightPointerEvents = highlightOverlay.style.pointerEvents; shieldOverlay.style.pointerEvents = "none"; selectionOverlay.style.pointerEvents = "none"; highlightOverlay.style.pointerEvents = "none"; - var pointTargets = document.elementsFromPoint ? document.elementsFromPoint(e.clientX, e.clientY) : [document.elementFromPoint(e.clientX, e.clientY)]; + var pointTargets = document.elementsFromPoint ? document.elementsFromPoint(clientX, clientY) : [document.elementFromPoint(clientX, clientY)]; shieldOverlay.style.pointerEvents = shieldPointerEvents; selectionOverlay.style.pointerEvents = selectionPointerEvents; highlightOverlay.style.pointerEvents = highlightPointerEvents; - var candidateElements = []; + var elements = []; var layerCandidates = []; pointTargets.forEach(function(pointTarget) { if (!pointTarget || pointTarget.nodeType !== 1) return; if (isOverlayElement(pointTarget)) return; var candidate = selectionTargetForHit(pointTarget); - if (!candidate || isDocumentRootElement(candidate) || isOverlayElement(candidate) || isLayerInteractionBlocked(candidate) || isTemplateCloneElement(candidate) || candidateElements.indexOf(candidate) !== -1) { + if (!candidate || isDocumentRootElement(candidate) || isOverlayElement(candidate) || isLayerInteractionBlocked(candidate) || isTemplateCloneElement(candidate) || elements.indexOf(candidate) !== -1) { return; } - candidateElements.push(candidate); + elements.push(candidate); var candidateInfo = getElementInfo(candidate); var explicitLabel = candidate.getAttribute && candidate.getAttribute("data-agent-native-layer-name") || ""; var textLabel = (candidate.textContent || "").trim().replace(/\\s+/g, " "); @@ -3522,6 +3554,14 @@ export const editorChromeBridgeScript: string = `"use strict"; info: candidateInfo }); }); + return { elements, layerCandidates }; + } + function openContextMenuAtEvent(e) { + stopNativeInteraction(e); + blurActiveTextEditor(); + var collected = collectLayerHitCandidates(e.clientX, e.clientY); + var candidateElements = collected.elements; + var layerCandidates = collected.layerCandidates; var target = candidateElements[0] || null; var info = null; if (target) { @@ -6217,7 +6257,9 @@ export const editorChromeBridgeScript: string = `"use strict"; height: dragElStartHeight }, snapCandidateRects, - SNAP_THRESHOLD_PX + // Task 3c — screen-space base converted to content px (1/zoom) so + // the snap tolerance feels constant on screen at any zoom. + SNAP_THRESHOLD_PX * chromeLineScale() ) : { dx: 0, dy: 0, guideV: null, guideH: null }; nextLeft += snapResult.dx; nextTop += snapResult.dy; @@ -6703,6 +6745,29 @@ export const editorChromeBridgeScript: string = `"use strict"; } pendingShieldDrag = null; } + function dragTargetForPointerDown(args) { + var selectedEl2 = args.selectedEl; + var hitEl = args.hitEl; + var hitRaw = args.hitRaw || hitEl; + var selectedAlive = !!args.selectedAlive; + if (selectedEl2 && selectedAlive && selectedEl2.contains && selectedEl2.contains(hitRaw)) { + return selectedEl2; + } + if (args.preferSelected && selectedEl2 && selectedAlive) { + var r = args.selectedRect; + var p = args.point; + if (r && p && r.width > 0 && r.height > 0 && p.x >= r.left && p.x <= r.right && p.y >= r.top && p.y <= r.bottom) { + return selectedEl2; + } + } + return hitEl; + } + function nextStackCandidate(candidateKeys, currentKey) { + if (!candidateKeys || candidateKeys.length === 0) return null; + var idx = candidateKeys.indexOf(currentKey); + if (idx === -1) return null; + return candidateKeys[(idx + 1) % candidateKeys.length]; + } function beginPotentialShieldDrag(e) { stopNativeInteraction(e); if (e.button !== 0) return; @@ -6714,7 +6779,17 @@ export const editorChromeBridgeScript: string = `"use strict"; beginMarqueeSelection(e); return; } - var dragTarget = selectedEl && document.documentElement.contains(selectedEl) && selectedEl.contains(hit) ? selectedEl : hitTarget; + var selectedAlive = !!selectedEl && document.documentElement.contains(selectedEl); + var selectedRect = selectedAlive && selectedEl.getBoundingClientRect ? selectedEl.getBoundingClientRect() : null; + var dragTarget = dragTargetForPointerDown({ + selectedEl, + selectedAlive, + selectedRect, + hitEl: hitTarget, + hitRaw: hit, + point: { x: e.clientX, y: e.clientY }, + preferSelected: selectedLayerDragPriorityEnabled + }); var clickTarget = hitTarget; if (!dragTarget || dragTarget === document.body || dragTarget === document.documentElement || isLayerInteractionBlocked(dragTarget)) { return; @@ -7806,6 +7881,17 @@ export const editorChromeBridgeScript: string = `"use strict"; setPassiveSelectionElements(passiveTargets); return; } + if (e.data.type === "set-selection-chrome-hidden") { + selectionChromeHidden = !!e.data.hidden; + if (selectionChromeHidden) { + hideSelectionOverlay(); + } else if (selectedEl) { + positionOverlay(selectionOverlay, selectedEl); + updateParentAutoLayoutOverlay(selectedEl); + refreshOverlays(); + } + return; + } if (e.data.type === "select-element") { var candidates = []; if (Array.isArray(e.data.selectorCandidates)) { diff --git a/templates/design/AGENTS.md b/templates/design/AGENTS.md index 86c3b316fe..d70b52f797 100644 --- a/templates/design/AGENTS.md +++ b/templates/design/AGENTS.md @@ -275,6 +275,32 @@ ladder. bridge URL, and snapshot/state references when moving between actions so later flow-edge derivation has stable anchors. +## Chrome placement + +Editor chrome has fixed homes; put new UI in the right one so surfaces stay +predictable and Figma-clean. + +- **Bottom toolbar**: cursor-changing creation/manipulation tools only — + move/hand/scale, frame, shapes, pen, text, comment. Never mode switches + (annotate/edit/interact) or one-off command buttons. +- **Left rail**: orientation and structure — pages, layers, assets, and the + `code` workbench. +- **Right panel**: properties, progressively disclosed (the EditPanel model). +- **Bottom status area**: warnings, sync/offline state, library updates, and the + outbox conflict count; render nothing when all-clear — the strip is + Figma-silent and usually invisible. +- **Long-tail features**: extensions in the left-rail tools/extensions surface, + not new permanent toolbar buttons. +- **No new always-visible controls for an existing command.** Do not add a + permanent button to fix discoverability; use the command menu, context menu, or + an existing surface. New permanent chrome for an existing command is a + regression. +- The mode switch (annotate/edit/interact) belongs in a compact segmented + control near where mode context already lives (top-right), not in the + cursor-tool pill. +- `Cmd+\` hides the whole chrome set (rail + inspector + toolbar); any new chrome + must participate in that hide. + ## Application State - `navigation` tells you the current view, design id, file id, and related UI diff --git a/templates/design/actions/create-design-checkpoint.ts b/templates/design/actions/create-design-checkpoint.ts new file mode 100644 index 0000000000..ab72e9787d --- /dev/null +++ b/templates/design/actions/create-design-checkpoint.ts @@ -0,0 +1,65 @@ +/** + * create-design-checkpoint — Task 5a. + * + * Creates a durable, attributable `design_versions` snapshot of the full + * current file set so it can be restored later via `restore-design-version`. + * Use before risky operations (an agent generation, a screen delete, a bulk + * import) or as an explicit user checkpoint. Auto-created kinds + * (`pre-agent-run`, `pre-structural`) are bounded: only the newest N per design + * are kept and older auto-checkpoints are pruned (a delete of rows this system + * created automatically — never of manual/user snapshots). + * + * Additive only: writes a new versions row; never alters or drops file data. + */ + +import { defineAction } from "@agent-native/core"; +import { getRequestUserEmail } from "@agent-native/core/server/request-context"; +import { assertAccess } from "@agent-native/core/sharing"; +import { z } from "zod"; + +import "../server/db/index.js"; // ensure registerShareableResource runs +import { writeDesignCheckpoint } from "../server/lib/design-checkpoint.js"; + +const CHECKPOINT_KINDS = ["manual", "pre-agent-run", "pre-structural"] as const; + +export default defineAction({ + description: + "Create a durable, attributable design checkpoint — a design_versions " + + "snapshot of the full current file set — that can be restored later with " + + "restore-design-version. kind 'manual' is an explicit user checkpoint; " + + "'pre-agent-run' should be created once before an agent generation; " + + "'pre-structural' before a destructive structural edit (screen delete, " + + "bulk import). Auto-created kinds are pruned to the newest 20 per design.", + schema: z.object({ + designId: z.string().describe("Design project ID to checkpoint"), + kind: z + .enum(CHECKPOINT_KINDS) + .default("manual") + .describe("Checkpoint kind (controls pruning + attribution)"), + trigger: z + .string() + .optional() + .describe("Provenance: originating action name or agent run id"), + label: z.string().optional().describe("Optional human-readable label"), + }), + run: async ({ designId, kind, trigger, label }) => { + await assertAccess("design", designId, "editor"); + const createdBy = getRequestUserEmail() ?? "agent"; + const result = await writeDesignCheckpoint({ + designId, + kind, + createdBy, + trigger, + label, + // Only auto-created kinds are pruned; a manual checkpoint is always kept. + prune: kind !== "manual", + }); + return { + versionId: result.versionId, + kind, + createdBy, + filesCaptured: result.filesCaptured, + pruned: result.pruned, + }; + }, +}); diff --git a/templates/design/actions/restore-design-version.ts b/templates/design/actions/restore-design-version.ts new file mode 100644 index 0000000000..ec14dcd4f9 --- /dev/null +++ b/templates/design/actions/restore-design-version.ts @@ -0,0 +1,124 @@ +/** + * restore-design-version — Task 5b. + * + * Restores a design to a previously captured checkpoint (design_versions + * snapshot). Restore is itself undoable: it first writes a fresh `pre-restore` + * checkpoint of the current state, then writes each snapshot file's content + * back — updating existing files and recreating any that were since deleted — + * and re-seeds the live collaboration document so open editors converge on the + * restored content. Destructive by intent; the UI gates it behind a confirm. + * + * Additive to schema: only writes/updates file rows and versions rows; never + * alters or drops columns. + */ + +import { defineAction } from "@agent-native/core"; +import { hasCollabState, seedFromText } from "@agent-native/core/collab"; +import { getRequestUserEmail } from "@agent-native/core/server/request-context"; +import { assertAccess } from "@agent-native/core/sharing"; +import { and, eq } from "drizzle-orm"; +import { z } from "zod"; + +import { getDb, schema } from "../server/db/index.js"; +import "../server/db/index.js"; // ensure registerShareableResource runs +import { + parseCheckpointSnapshotFiles, + writeDesignCheckpoint, +} from "../server/lib/design-checkpoint.js"; + +export default defineAction({ + description: + "Restore a design to a previously captured checkpoint (design_versions " + + "snapshot id). First writes a fresh 'pre-restore' checkpoint of the current " + + "state so the restore can itself be rolled back, then writes each snapshot " + + "file's content back (updating existing files, recreating any deleted ones) " + + "and re-seeds the live collaboration doc. Destructive by intent — confirm " + + "with the user before calling.", + schema: z.object({ + designId: z.string().describe("Design project ID to restore"), + versionId: z + .string() + .describe("design_versions.id of the checkpoint to restore"), + }), + run: async ({ designId, versionId }) => { + const db = getDb(); + await assertAccess("design", designId, "editor"); + + const [version] = await db + .select({ + id: schema.designVersions.id, + snapshot: schema.designVersions.snapshot, + }) + .from(schema.designVersions) + .where( + and( + eq(schema.designVersions.id, versionId), + eq(schema.designVersions.designId, designId), + ), + ) + .limit(1); + if (!version) throw new Error(`Version not found: ${versionId}`); + + const snapshotFiles = parseCheckpointSnapshotFiles(version.snapshot); + if (snapshotFiles.length === 0) { + throw new Error("Checkpoint snapshot contains no restorable files"); + } + + // Checkpoint the pre-restore state first so this restore is undoable. + const createdBy = getRequestUserEmail() ?? "agent"; + const preRestore = await writeDesignCheckpoint({ + designId, + kind: "pre-restore", + createdBy, + trigger: `restore:${versionId}`, + prune: true, + }); + + const now = new Date().toISOString(); + let filesRestored = 0; + let filesRecreated = 0; + for (const file of snapshotFiles) { + const [existing] = await db + .select({ id: schema.designFiles.id }) + .from(schema.designFiles) + .where( + and( + eq(schema.designFiles.id, file.id), + eq(schema.designFiles.designId, designId), + ), + ) + .limit(1); + if (existing) { + await db + .update(schema.designFiles) + .set({ content: file.content, updatedAt: now }) + .where(eq(schema.designFiles.id, file.id)); + filesRestored += 1; + } else { + await db.insert(schema.designFiles).values({ + id: file.id, + designId, + filename: file.filename, + content: file.content, + fileType: file.fileType ?? "html", + createdAt: now, + updatedAt: now, + }); + filesRecreated += 1; + } + // Re-seed the live collab document so open editors converge on the + // restored content (mirrors update-file's seed path). + if (await hasCollabState(file.id)) { + await seedFromText(file.id, file.content ?? "", "content"); + } + } + + return { + restored: true, + versionId, + preRestoreVersionId: preRestore.versionId, + filesRestored, + filesRecreated, + }; + }, +}); diff --git a/templates/design/app/components/design/CanvasAgentStateBadge.tsx b/templates/design/app/components/design/CanvasAgentStateBadge.tsx new file mode 100644 index 0000000000..136b70a308 --- /dev/null +++ b/templates/design/app/components/design/CanvasAgentStateBadge.tsx @@ -0,0 +1,114 @@ +import { + IconAlertTriangle, + IconCheck, + IconLoader2, + IconMessageQuestion, + IconWifiOff, +} from "@tabler/icons-react"; +import type { ComponentType } from "react"; +import { useEffect, useState } from "react"; + +import { cn } from "@/lib/utils"; + +import { + type CanvasAgentState, + type CanvasAgentStateInputs, + deriveCanvasAgentState, +} from "../../pages/design-editor/canvas-agent-state"; + +export interface CanvasAgentStateBadgeProps { + inputs: CanvasAgentStateInputs; + /** Optional override for the transient "done" window (ms). */ + doneWindowMs?: number; +} + +interface BadgeStyle { + icon: ComponentType<{ className?: string }>; + label: string; + spin: boolean; + className: string; +} + +const BADGE_STYLES: Record, BadgeStyle> = { + working: { + icon: IconLoader2, + label: "Working…", + spin: true, + className: + "border-blue-500/30 bg-blue-500/10 text-blue-600 dark:text-blue-300", + }, + applying: { + icon: IconLoader2, + label: "Applying…", + spin: true, + className: + "border-blue-500/30 bg-blue-500/10 text-blue-600 dark:text-blue-300", + }, + "needs-answer": { + icon: IconMessageQuestion, + label: "Needs answer", + spin: false, + className: + "border-amber-500/30 bg-amber-500/10 text-amber-600 dark:text-amber-300", + }, + warning: { + icon: IconWifiOff, + label: "Offline", + spin: false, + className: + "border-amber-500/30 bg-amber-500/10 text-amber-600 dark:text-amber-300", + }, + done: { + icon: IconCheck, + label: "Done", + spin: false, + className: + "border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-300", + }, + failed: { + icon: IconAlertTriangle, + label: "Failed", + spin: false, + className: "border-red-500/30 bg-red-500/10 text-red-600 dark:text-red-300", + }, +}; + +const DEFAULT_DONE_WINDOW_MS = 4000; + +export function CanvasAgentStateBadge({ + inputs, + doneWindowMs = DEFAULT_DONE_WINDOW_MS, +}: CanvasAgentStateBadgeProps) { + // Bumped by the decay timer so a transient "done" re-derives to "ready". + const [, setTick] = useState(0); + const state = deriveCanvasAgentState(inputs, Date.now(), doneWindowMs); + + useEffect(() => { + if (state !== "done" || inputs.lastRunCompletedAt == null) return; + const remaining = inputs.lastRunCompletedAt + doneWindowMs - Date.now(); + const timer = setTimeout( + () => setTick((value) => value + 1), + Math.max(0, remaining), + ); + return () => clearTimeout(timer); + }, [state, inputs.lastRunCompletedAt, doneWindowMs]); + + if (state === "ready") return null; + + const style = BADGE_STYLES[state]; + const Icon = style.icon; + + return ( +
+ + {style.label} +
+ ); +} diff --git a/templates/design/app/components/design/DesignCanvas.tsx b/templates/design/app/components/design/DesignCanvas.tsx index dea94bb937..cd01291cc0 100644 --- a/templates/design/app/components/design/DesignCanvas.tsx +++ b/templates/design/app/components/design/DesignCanvas.tsx @@ -314,6 +314,20 @@ ${editorChromeBridgeScript} */ const LIVE_REFLOW_ENABLED = true; +/** + * Task 1a rollout gate (Editor Interaction Parity). When on, a pointerdown + * whose point lands inside the current selection's box keeps the SELECTED + * element as the drag target even when an overlapping non-descendant sibling + * wins the hit test — a selected object stays draggable from under another + * layer (Figma parity). Baked into the bridge as + * `__SELECTED_LAYER_DRAG_PRIORITY__`, same in-file bridge-flag pattern as + * `LIVE_REFLOW_ENABLED`; flip to `false` to restore the previous + * descendant-only behavior without a revert. The pure decision + * (`dragTargetForPointerDown`) is truth-table tested in + * editor-chrome-bridge.snap.test.ts regardless of this gate. + */ +const SELECTED_LAYER_DRAG_PRIORITY_ENABLED = true; + interface DesignCanvasProps { content: string; contentKey?: string; @@ -945,6 +959,10 @@ function buildEditorChromeBridgeScript(args: { "__LIVE_REFLOW_ENABLED__", LIVE_REFLOW_ENABLED ? "true" : "false", ) + .replace( + "__SELECTED_LAYER_DRAG_PRIORITY__", + SELECTED_LAYER_DRAG_PRIORITY_ENABLED ? "true" : "false", + ) ); } @@ -2124,6 +2142,10 @@ export function DesignCanvas({ .replace( "__LIVE_REFLOW_ENABLED__", LIVE_REFLOW_ENABLED ? "true" : "false", + ) + .replace( + "__SELECTED_LAYER_DRAG_PRIORITY__", + SELECTED_LAYER_DRAG_PRIORITY_ENABLED ? "true" : "false", ); // ALWAYS injected (like the other always-on bridges above) so // MultiScreenCanvas's cross-screen drag hit-testing diff --git a/templates/design/app/components/design/SelectionAgentAffordance.tsx b/templates/design/app/components/design/SelectionAgentAffordance.tsx new file mode 100644 index 0000000000..f2a5ff2d8b --- /dev/null +++ b/templates/design/app/components/design/SelectionAgentAffordance.tsx @@ -0,0 +1,232 @@ +import { IconSparkles } from "@tabler/icons-react"; +import { + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState, +} from "react"; + +import { Button } from "@/components/ui/button"; +import { Spinner } from "@/components/ui/spinner"; +import { cn } from "@/lib/utils"; + +import type { CanvasAgentState } from "../../pages/design-editor/canvas-agent-state"; +import { placeAffordance } from "../../pages/design-editor/selection-affordance-placement"; + +export interface SelectionAgentAffordanceProps { + /** The selection rect in the SAME coordinate space as `containerRect` (viewport px). null hides the affordance. */ + anchorRect: { + left: number; + top: number; + right: number; + bottom: number; + width: number; + height: number; + } | null; + /** Bounding rect of the canvas container the affordance is positioned within (from containerRef.getBoundingClientRect()). */ + containerRect: { width: number; height: number } | null; + /** Re-key signal: changes whenever zoom/pan/scroll/layout changes so the parent forces a reposition. Optional. */ + layoutTick?: number; + /** Hide entirely (pin mode, text editing, drag in progress, pending questions). */ + suppressed?: boolean; + /** Fired when the user clicks "Change this…" (opens composer for a mutation). */ + onChange: () => void; + /** Fired when the user clicks "Ask about this" (read-only question). */ + onAsk: () => void; + /** Optional current canvas agent state to show as a small status dot on the chip. */ + agentState?: CanvasAgentState; +} + +const DEFAULT_CHIP_SIZE = { width: 176, height: 32 }; +const CLOSE_DELAY_MS = 300; + +interface StatusDot { + spinner: boolean; + dotClass: string; + title: string; +} + +function statusDot(state: CanvasAgentState): StatusDot | null { + switch (state) { + case "working": + return { spinner: true, dotClass: "", title: "Agent is working" }; + case "applying": + return { spinner: true, dotClass: "", title: "Applying changes" }; + case "needs-answer": + return { + spinner: false, + dotClass: "bg-amber-500", + title: "Agent needs an answer", + }; + case "warning": + return { + spinner: false, + dotClass: "bg-amber-500", + title: "Agent is offline", + }; + case "done": + return { + spinner: false, + dotClass: "bg-emerald-500", + title: "Agent finished", + }; + case "failed": + return { spinner: false, dotClass: "bg-red-500", title: "Agent failed" }; + default: + return null; + } +} + +function anchorSignature( + rect: SelectionAgentAffordanceProps["anchorRect"], +): string { + if (!rect) return "none"; + return `${rect.left},${rect.top},${rect.right},${rect.bottom}`; +} + +export function SelectionAgentAffordance({ + anchorRect, + containerRect, + layoutTick = 0, + suppressed = false, + onChange, + onAsk, + agentState, +}: SelectionAgentAffordanceProps) { + const chipRef = useRef(null); + const closeTimer = useRef | null>(null); + const [chipSize, setChipSize] = useState(DEFAULT_CHIP_SIZE); + const [position, setPosition] = useState<{ + left: number; + top: number; + } | null>(null); + const [hidden, setHidden] = useState(false); + + const signature = anchorSignature(anchorRect); + + const cancelClose = useCallback(() => { + if (closeTimer.current) { + clearTimeout(closeTimer.current); + closeTimer.current = null; + } + }, []); + + const scheduleClose = useCallback(() => { + cancelClose(); + closeTimer.current = setTimeout(() => setHidden(true), CLOSE_DELAY_MS); + }, [cancelClose]); + + // A fresh selection re-shows the chip immediately and cancels any pending close. + useEffect(() => { + cancelClose(); + setHidden(false); + }, [signature, cancelClose]); + + useEffect(() => () => cancelClose(), [cancelClose]); + + useLayoutEffect(() => { + const element = chipRef.current; + if (!element) return; + const measure = () => { + const rect = element.getBoundingClientRect(); + if (rect.width <= 0 || rect.height <= 0) return; + setChipSize((current) => + current.width === rect.width && current.height === rect.height + ? current + : { width: rect.width, height: rect.height }, + ); + }; + measure(); + const observer = new ResizeObserver(measure); + observer.observe(element); + return () => observer.disconnect(); + }, [hidden]); + + useLayoutEffect(() => { + if (!anchorRect || !containerRect) { + setPosition(null); + return; + } + const placement = placeAffordance(anchorRect, containerRect, chipSize); + setPosition((current) => + current && + current.left === placement.left && + current.top === placement.top + ? current + : { left: placement.left, top: placement.top }, + ); + }, [anchorRect, containerRect, chipSize, layoutTick]); + + if (suppressed || !anchorRect || !containerRect || hidden) { + return null; + } + + const dot = agentState ? statusDot(agentState) : null; + + return ( +
+
{ + if (event.key === "Escape") { + event.stopPropagation(); + setHidden(true); + } + }} + onBlur={(event) => { + if ( + !event.currentTarget.contains(event.relatedTarget as Node | null) + ) { + setHidden(true); + } + }} + > + + + + {dot ? ( + + {dot.spinner ? ( + + ) : ( + + )} + + ) : null} +
+
+ ); +} diff --git a/templates/design/app/components/design/bridge/editor-chrome.bridge.ts b/templates/design/app/components/design/bridge/editor-chrome.bridge.ts index 24881ba5de..9989288947 100644 --- a/templates/design/app/components/design/bridge/editor-chrome.bridge.ts +++ b/templates/design/app/components/design/bridge/editor-chrome.bridge.ts @@ -35,6 +35,7 @@ declare var __DESIGN_CANVAS_CONTENT_OFFSET_X__: number; declare var __DESIGN_CANVAS_CONTENT_OFFSET_Y__: number; declare var __RUNTIME_LAYER_SNAPSHOT_ENABLED__: boolean; declare var __LIVE_REFLOW_ENABLED__: boolean; +declare var __SELECTED_LAYER_DRAG_PRIORITY__: boolean; (function () { // Idempotency guard: replace-document-content / srcdoc rebuilds can end up @@ -78,6 +79,18 @@ declare var __LIVE_REFLOW_ENABLED__: boolean; return false; } })(); + // Task 1a — selected-layer drag priority. When on, a pointerdown whose point + // lands inside the current selection's box keeps the SELECTED element as the + // drag target even when an overlapping non-descendant sibling wins the hit + // test (Figma: a selected object stays draggable from under another layer). + // Same crash-safe single-substitution pattern as liveReflowEnabled above. + var selectedLayerDragPriorityEnabled = (function () { + try { + return !!__SELECTED_LAYER_DRAG_PRIORITY__; + } catch (_e) { + return false; + } + })(); var scaleToolEnabled = false; // ── Drag-and-drop debug logging ──────────────────────────────────── @@ -2230,6 +2243,12 @@ declare var __LIVE_REFLOW_ENABLED__: boolean; } var selectedEl: Element | null = null; + // Task 1d — while true, the selection outline + spacing/parent overlays stay + // hidden even as async reflows (ResizeObserver/MutationObserver) fire + // refreshOverlays. The host sets it during a keyboard-nudge burst so the + // moving object isn't chased by flickering chrome, then clears it once the + // burst settles. Selection itself is unchanged — only its chrome is hidden. + var selectionChromeHidden = false; var hoveredEl: Element | null = null; type NodeHtmlPreviewSession = { proposalId: string; @@ -4052,7 +4071,11 @@ declare var __LIVE_REFLOW_ENABLED__: boolean; hideSelectionOverlay(); } } else if (selectedEl) { - positionOverlay(selectionOverlay, selectedEl); + if (selectionChromeHidden) { + hideSelectionOverlay(); + } else { + positionOverlay(selectionOverlay, selectedEl); + } } else { hideParentAutoLayoutOverlay(); } @@ -4867,6 +4890,37 @@ declare var __LIVE_REFLOW_ENABLED__: boolean; (window.parent as Window).postMessage({ type: "clear-selection" }, "*"); return; } + // Task 1b — Cmd/Ctrl+click (without Shift) deep-selects the next layer + // below the current selection in the z-stack under the pointer, wrapping at + // the bottom (Figma stack cycling). Only activates when the current + // selection is actually in that stack; otherwise it falls through so a + // Cmd/Ctrl+click elsewhere keeps its existing (additive) meaning, and + // Shift-click stays purely additive. + if ((e.metaKey || e.ctrlKey) && !e.shiftKey && selectedEl) { + var stack = collectLayerHitCandidates(e.clientX, e.clientY); + var currentIdx = stack.elements.indexOf(selectedEl); + if (currentIdx !== -1) { + var stackKeys = stack.layerCandidates.map(function (candidate) { + return candidate.key; + }); + var nextKey = nextStackCandidate( + stackKeys, + stack.layerCandidates[currentIdx].key, + ); + var nextEl = + nextKey === null ? null : stack.elements[stackKeys.indexOf(nextKey)]; + if (nextEl && !isLayerInteractionBlocked(nextEl)) { + selectedSpacingHovered = false; + hoveredSpacingHandleKey = ""; + selectedEl = nextEl; + positionOverlay(selectionOverlay, selectedEl); + // Plain (no-intent) select so the host replaces the selection with + // the cycled layer instead of treating Cmd/Ctrl as additive. + postElementSelect(selectedEl); + return; + } + } + } selectedSpacingHovered = false; hoveredSpacingHandleKey = ""; var previousSelectedEl = selectedEl; @@ -5075,9 +5129,20 @@ declare var __LIVE_REFLOW_ENABLED__: boolean; document.addEventListener(events.up, onUp, true); } - function openContextMenuAtEvent(e) { - stopNativeInteraction(e); - blurActiveTextEditor(); + // Task 1b — shared hit-stack collector. Returns the full z-stack of + // selectable layers under a point (topmost first), each element aligned by + // index with a { key, label, info } descriptor. Extracted verbatim from the + // context-menu path so modifier-click stack cycling and the context menu + // resolve the exact same stack (single source of truth for "what's under + // this point"). Overlays are momentarily made pointer-transparent so + // elementsFromPoint sees through the editor chrome. + function collectLayerHitCandidates( + clientX: number, + clientY: number, + ): { + elements: Element[]; + layerCandidates: Array<{ key: string; label: string; info: unknown }>; + } { var shieldPointerEvents = shieldOverlay.style.pointerEvents; var selectionPointerEvents = selectionOverlay.style.pointerEvents; var highlightPointerEvents = highlightOverlay.style.pointerEvents; @@ -5085,13 +5150,13 @@ declare var __LIVE_REFLOW_ENABLED__: boolean; selectionOverlay.style.pointerEvents = "none"; highlightOverlay.style.pointerEvents = "none"; var pointTargets = document.elementsFromPoint - ? document.elementsFromPoint(e.clientX, e.clientY) - : [document.elementFromPoint(e.clientX, e.clientY)]; + ? document.elementsFromPoint(clientX, clientY) + : [document.elementFromPoint(clientX, clientY)]; shieldOverlay.style.pointerEvents = shieldPointerEvents; selectionOverlay.style.pointerEvents = selectionPointerEvents; highlightOverlay.style.pointerEvents = highlightPointerEvents; - var candidateElements: Element[] = []; + var elements: Element[] = []; var layerCandidates: Array<{ key: string; label: string; @@ -5107,11 +5172,11 @@ declare var __LIVE_REFLOW_ENABLED__: boolean; isOverlayElement(candidate) || isLayerInteractionBlocked(candidate) || isTemplateCloneElement(candidate) || - candidateElements.indexOf(candidate) !== -1 + elements.indexOf(candidate) !== -1 ) { return; } - candidateElements.push(candidate); + elements.push(candidate); var candidateInfo = getElementInfo(candidate); var explicitLabel = (candidate.getAttribute && @@ -5134,6 +5199,15 @@ declare var __LIVE_REFLOW_ENABLED__: boolean; info: candidateInfo, }); }); + return { elements: elements, layerCandidates: layerCandidates }; + } + + function openContextMenuAtEvent(e) { + stopNativeInteraction(e); + blurActiveTextEditor(); + var collected = collectLayerHitCandidates(e.clientX, e.clientY); + var candidateElements = collected.elements; + var layerCandidates = collected.layerCandidates; var target = candidateElements[0] || null; var info = null; @@ -8198,10 +8272,14 @@ declare var __LIVE_REFLOW_ENABLED__: boolean; // Minimal, dependency-free port of the overview canvas's edge/center snap // routine (shared/canvas-math.ts computeMoveSnap) for in-iframe element // dragging. The bridge's pointer coordinates and getBoundingClientRect() - // values are already in the same iframe-local, zoom-normalized coordinate - // space (the host CSS-scales the whole iframe, not individual elements), - // so — unlike the overview canvas, which divides a screen-px threshold by - // its own camera zoom — no extra scale correction is needed here. + // values are iframe-local content px, but the host CSS-scales the whole + // iframe, so a fixed content-px threshold shrinks on SCREEN as the board + // zooms out (6 content px is only 3 screen px at 50% zoom) — the snap tolerance + // felt tighter and tighter zoomed out. Task 3c: keep SNAP_THRESHOLD_PX as a + // screen-space base and convert it to content px at snap time by multiplying + // by chromeLineScale() (= 1/zoom, the same constant-size factor the chrome + // overlays use), matching getCanvasSnapThreshold's zoom-invariant screen-px + // semantics in the overview. var SNAP_THRESHOLD_PX = 6; var SNAP_CANDIDATE_CAP = 200; @@ -9291,7 +9369,9 @@ declare var __LIVE_REFLOW_ENABLED__: boolean; height: dragElStartHeight, }, snapCandidateRects, - SNAP_THRESHOLD_PX, + // Task 3c — screen-space base converted to content px (1/zoom) so + // the snap tolerance feels constant on screen at any zoom. + SNAP_THRESHOLD_PX * chromeLineScale(), ) : { dx: 0, dy: 0, guideV: null, guideH: null }; nextLeft += snapResult.dx; @@ -9938,6 +10018,61 @@ declare var __LIVE_REFLOW_ENABLED__: boolean; pendingShieldDrag = null; } + // Task 1a (pure, closure-exported for editor-chrome-bridge.snap.test.ts): + // decide the drag target for a pointerdown. Descendant hits always keep the + // selected element (legacy rule, preserved via the raw `hitRaw` containment + // check). When `preferSelected` is on, a point inside the selected element's + // live box also keeps the selected element even over an overlapping + // non-descendant sibling — so a selected object stays draggable from under + // another layer. Falls through to `hitEl` when the selection is + // removed/detached (`selectedAlive` false) or collapsed to a zero-area box + // (hidden). Self-contained: no outer-scope reads, so brace-extraction in the + // test evaluates it in isolation. + function dragTargetForPointerDown(args) { + var selectedEl = args.selectedEl; + var hitEl = args.hitEl; + var hitRaw = args.hitRaw || hitEl; + var selectedAlive = !!args.selectedAlive; + if ( + selectedEl && + selectedAlive && + selectedEl.contains && + selectedEl.contains(hitRaw) + ) { + return selectedEl; + } + if (args.preferSelected && selectedEl && selectedAlive) { + var r = args.selectedRect; + var p = args.point; + if ( + r && + p && + r.width > 0 && + r.height > 0 && + p.x >= r.left && + p.x <= r.right && + p.y >= r.top && + p.y <= r.bottom + ) { + return selectedEl; + } + } + return hitEl; + } + + // Task 1b (pure, closure-exported for tests): given the ordered hit-stack + // candidate keys (topmost first, as document.elementsFromPoint returns) and + // the key of the current selection, return the key of the next candidate + // BELOW it, wrapping from the bottom back to the top. Returns null when the + // current selection is not in the stack, so a modifier-click there keeps its + // existing meaning instead of hijacking it. + function nextStackCandidate(candidateKeys, currentKey) { + if (!candidateKeys || candidateKeys.length === 0) return null; + var idx = candidateKeys.indexOf(currentKey); + if (idx === -1) return null; + return candidateKeys[(idx + 1) % candidateKeys.length]; + } + function beginPotentialShieldDrag(e) { stopNativeInteraction(e); if (e.button !== 0) return; @@ -9956,12 +10091,21 @@ declare var __LIVE_REFLOW_ENABLED__: boolean; beginMarqueeSelection(e); return; } - var dragTarget = - selectedEl && - document.documentElement.contains(selectedEl) && - selectedEl.contains(hit) - ? selectedEl - : hitTarget; + var selectedAlive = + !!selectedEl && document.documentElement.contains(selectedEl); + var selectedRect = + selectedAlive && selectedEl.getBoundingClientRect + ? selectedEl.getBoundingClientRect() + : null; + var dragTarget = dragTargetForPointerDown({ + selectedEl: selectedEl, + selectedAlive: selectedAlive, + selectedRect: selectedRect, + hitEl: hitTarget, + hitRaw: hit, + point: { x: e.clientX, y: e.clientY }, + preferSelected: selectedLayerDragPriorityEnabled, + }); var clickTarget = hitTarget; if ( !dragTarget || @@ -11507,6 +11651,17 @@ declare var __LIVE_REFLOW_ENABLED__: boolean; setPassiveSelectionElements(passiveTargets); return; } + if (e.data.type === "set-selection-chrome-hidden") { + selectionChromeHidden = !!e.data.hidden; + if (selectionChromeHidden) { + hideSelectionOverlay(); + } else if (selectedEl) { + positionOverlay(selectionOverlay, selectedEl); + updateParentAutoLayoutOverlay(selectedEl); + refreshOverlays(); + } + return; + } if (e.data.type === "select-element") { var candidates: string[] = []; if (Array.isArray(e.data.selectorCandidates)) { diff --git a/templates/design/app/components/design/editor-chrome-bridge.snap.test.ts b/templates/design/app/components/design/editor-chrome-bridge.snap.test.ts index e26064ac70..0ba87ab257 100644 --- a/templates/design/app/components/design/editor-chrome-bridge.snap.test.ts +++ b/templates/design/app/components/design/editor-chrome-bridge.snap.test.ts @@ -114,6 +114,204 @@ function loadSnapMath(): { const { rectBounds, computeMoveSnapOffset } = loadSnapMath(); +// Task 1a/1b — both functions are self-contained (they read only their +// arguments), so a single brace-extracted declaration evaluates in isolation. +function loadPureBridgeFn(name: string): T { + const editorChromeBridgeScript = loadEditorChromeBridgeScript(); + const src = extractFunction(editorChromeBridgeScript, name); + // eslint-disable-next-line @typescript-eslint/no-implied-eval + const factory = new Function(`${src}\nreturn ${name};`); + return factory() as T; +} + +interface DragTargetArgs { + selectedEl: unknown; + selectedAlive: boolean; + selectedRect: { + left: number; + top: number; + right: number; + bottom: number; + width: number; + height: number; + } | null; + hitEl: unknown; + hitRaw?: unknown; + point: { x: number; y: number } | null; + preferSelected: boolean; +} +const dragTargetForPointerDown = loadPureBridgeFn< + (args: DragTargetArgs) => unknown +>("dragTargetForPointerDown"); +const nextStackCandidate = + loadPureBridgeFn<(keys: string[], current: string | null) => string | null>( + "nextStackCandidate", + ); + +describe("editor-chrome bridge — dragTargetForPointerDown (Task 1a)", () => { + const selRect = { + left: 10, + top: 10, + right: 110, + bottom: 60, + width: 100, + height: 50, + }; + + it("keeps the selected element when the hit is its descendant (legacy rule, flag off)", () => { + const hitRaw = { tag: "child" }; + const selectedEl = { tag: "sel", contains: (x: unknown) => x === hitRaw }; + const hitEl = { tag: "hitTarget" }; + expect( + dragTargetForPointerDown({ + selectedEl, + selectedAlive: true, + selectedRect: null, + hitEl, + hitRaw, + point: { x: 0, y: 0 }, + preferSelected: false, + }), + ).toBe(selectedEl); + }); + + it("keeps the selected element when the point is inside its box over an overlapping sibling (flag on)", () => { + const hitRaw = { tag: "sibling-on-top" }; + const selectedEl = { tag: "sel", contains: () => false }; + const hitEl = hitRaw; + expect( + dragTargetForPointerDown({ + selectedEl, + selectedAlive: true, + selectedRect: selRect, + hitEl, + hitRaw, + point: { x: 50, y: 30 }, + preferSelected: true, + }), + ).toBe(selectedEl); + }); + + it("selects the overlapping top sibling when the flag is off (legacy hit wins)", () => { + const hitRaw = { tag: "sibling-on-top" }; + const selectedEl = { tag: "sel", contains: () => false }; + const hitEl = hitRaw; + expect( + dragTargetForPointerDown({ + selectedEl, + selectedAlive: true, + selectedRect: selRect, + hitEl, + hitRaw, + point: { x: 50, y: 30 }, + preferSelected: false, + }), + ).toBe(hitEl); + }); + + it("falls through to the hit when the point is outside the selected box", () => { + const hitRaw = { tag: "elsewhere" }; + const selectedEl = { tag: "sel", contains: () => false }; + const hitEl = hitRaw; + expect( + dragTargetForPointerDown({ + selectedEl, + selectedAlive: true, + selectedRect: selRect, + hitEl, + hitRaw, + point: { x: 500, y: 500 }, + preferSelected: true, + }), + ).toBe(hitEl); + }); + + it("falls through to the hit when the selected element is detached (selectedAlive false)", () => { + const hitRaw = { tag: "hit" }; + const selectedEl = { tag: "sel", contains: () => true }; + const hitEl = hitRaw; + expect( + dragTargetForPointerDown({ + selectedEl, + selectedAlive: false, + selectedRect: selRect, + hitEl, + hitRaw, + point: { x: 50, y: 30 }, + preferSelected: true, + }), + ).toBe(hitEl); + }); + + it("keeps the selected element even when the top hit is a (locked) layer — locking is the caller's concern", () => { + const lockedTop = { tag: "locked-top" }; + const selectedEl = { tag: "sel", contains: () => false }; + expect( + dragTargetForPointerDown({ + selectedEl, + selectedAlive: true, + selectedRect: selRect, + hitEl: lockedTop, + hitRaw: lockedTop, + point: { x: 50, y: 30 }, + preferSelected: true, + }), + ).toBe(selectedEl); + }); + + it("falls through when the selected box is zero-area (hidden element)", () => { + const hitRaw = { tag: "hit" }; + const selectedEl = { tag: "sel", contains: () => false }; + const hitEl = hitRaw; + expect( + dragTargetForPointerDown({ + selectedEl, + selectedAlive: true, + selectedRect: { + left: 10, + top: 10, + right: 10, + bottom: 10, + width: 0, + height: 0, + }, + hitEl, + hitRaw, + point: { x: 10, y: 10 }, + preferSelected: true, + }), + ).toBe(hitEl); + }); +}); + +describe("editor-chrome bridge — nextStackCandidate (Task 1b)", () => { + const stack = ["a:0", "b:1", "c:2", "d:3"]; + + it("returns the next candidate below the current one", () => { + expect(nextStackCandidate(stack, "b:1")).toBe("c:2"); + }); + + it("wraps from the bottom back to the top", () => { + expect(nextStackCandidate(stack, "d:3")).toBe("a:0"); + }); + + it("moves from the top hit to the one just below it", () => { + expect(nextStackCandidate(stack, "a:0")).toBe("b:1"); + }); + + it("returns null when the current selection is not in the stack", () => { + expect(nextStackCandidate(stack, "z:9")).toBeNull(); + }); + + it("returns null for an empty stack", () => { + expect(nextStackCandidate([], "a:0")).toBeNull(); + }); + + it("wraps to itself for a single-candidate stack", () => { + expect(nextStackCandidate(["only:0"], "only:0")).toBe("only:0"); + }); +}); + function loadSelectionTargetForHit(documentRoot: { body: Element; documentElement: Element; diff --git a/templates/design/app/hooks/useDesignHotkeys.ts b/templates/design/app/hooks/useDesignHotkeys.ts index 726d1b213b..4328f6b627 100644 --- a/templates/design/app/hooks/useDesignHotkeys.ts +++ b/templates/design/app/hooks/useDesignHotkeys.ts @@ -132,6 +132,8 @@ export interface UseDesignHotkeysProps { onSendToBack?: DesignHotkeyHandler; onEscape?: DesignHotkeyHandler; onEnter?: DesignHotkeyHandler; + /** Task 4b — Cmd/Ctrl+Enter opens the agent chat for the current selection. */ + onOpenAgentForSelection?: DesignHotkeyHandler; onSelectParent?: DesignHotkeyHandler; onTab?: DesignHotkeyTabHandler; onNextFrame?: DesignHotkeyHandler; @@ -455,6 +457,12 @@ export function handleDesignHotkey( if (event.key === "Escape") return run(props.onEscape); if (event.key === "Enter") { + // Task 4b — Cmd/Ctrl+Enter opens the agent for the current selection. + // Checked before the drill-in/parent traversal so the plain/Shift+Enter + // behavior is unchanged when the modifier isn't held. + if (primary && !event.shiftKey && props.onOpenAgentForSelection) { + return run(props.onOpenAgentForSelection); + } // Figma: Enter drills into the selection (selects its first child / // begins text editing); Shift+Enter is its sibling — select the // selection's PARENT. Checked before the plain onEnter fallback so diff --git a/templates/design/app/pages/DesignEditor.tsx b/templates/design/app/pages/DesignEditor.tsx index b438fc38e6..a4dab9fc46 100644 --- a/templates/design/app/pages/DesignEditor.tsx +++ b/templates/design/app/pages/DesignEditor.tsx @@ -221,6 +221,7 @@ import { DEFAULT_LINE_STROKE, DEFAULT_LINE_STROKE_WIDTH_PX, } from "@/components/design/canvas-primitive-style"; +import { CanvasAgentStateBadge } from "@/components/design/CanvasAgentStateBadge"; import { CanvasContextMenu, type CanvasContextMenuHandle, @@ -309,6 +310,7 @@ import { } from "@/components/design/ReviewCommentsPanel"; import type { ReviewPanelProps } from "@/components/design/ReviewPanel"; import { ReviewStatusControl } from "@/components/design/ReviewStatusControl"; +import { SelectionAgentAffordance } from "@/components/design/SelectionAgentAffordance"; import { TokensPanel } from "@/components/design/TokensPanel"; import type { CanvasLayerHitCandidate, @@ -652,6 +654,9 @@ import { pruneGeometryHistoryEntryForDeletedFiles, remapFileDeletionHistoryEntryIds, removeRecentUndoRedoOrderKinds, + type SelectionHistoryEntry, + selectionSnapshotsEqual, + shouldRecordSelectionHistory, } from "./design-editor/history"; import { getAbsolutePositioningForNodeInHtml, @@ -799,6 +804,10 @@ import { shouldIncludeScreenRenameContentOverride, shouldUseOverviewRuntimeReplacement, } from "./design-editor/selection-state"; +import { + type AlignmentGroup, + partitionSelectionForAlignment, +} from "./design-editor/selection-topology"; import { postShaderFillPreviewClearToPreviewIframes, removeElementFromHtml, @@ -2940,6 +2949,12 @@ function DesignEditor() { const suppressContentHistoryRef = useRef(false); const geometryUndoStackRef = useRef([]); const geometryRedoStackRef = useRef([]); + // Task 1c — selection-only undo/redo. Entries hold a {before, after} + // selection snapshot pair and a timestamp for burst coalescing. Pushed only + // from local selection handlers (never from peer/agent awareness), so remote + // selections structurally never enter this stack (Task 1e). + const selectionOnlyUndoStackRef = useRef([]); + const selectionOnlyRedoStackRef = useRef([]); // Figma-parity undo/redo selection restore (see GeometryHistorySelection): // synchronous mirrors of the selection state, kept current every render // (like activeFileIdForUndoRef just above) so a commit/undo/redo handler @@ -2968,6 +2983,7 @@ function DesignEditor() { contentRedoSelectionStackRef.current = []; localContentRedoStackRef.current = []; geometryRedoStackRef.current = []; + selectionOnlyRedoStackRef.current = []; fileCreationRedoStackRef.current = []; fileDeletionRedoStackRef.current = []; pendingVisualStyleRedoStackRef.current = []; @@ -3001,12 +3017,41 @@ function DesignEditor() { const restoreSelectionSnapshot = useCallback( (selection: GeometryHistorySelection | undefined) => { if (!selection) return; - if (viewModeRef.current !== "overview") return; - setOverviewSelectedScreenIds(selection.overviewSelectedScreenIds); - setSelectedLayerIdsState(selection.selectedLayerIds); - if (selection.activeFileId) { + if (viewModeRef.current === "overview") { + setOverviewSelectedScreenIds(selection.overviewSelectedScreenIds); + setSelectedLayerIdsState(selection.selectedLayerIds); + if (selection.activeFileId) { + setActiveFileId(selection.activeFileId); + } + return; + } + // Task 1c — single-screen mode restore: switch to the snapshot's file if + // it changed, restore the host-side layer selection, and re-drive the + // in-iframe selection overlay for the first restored layer (the common + // single-selection case). Iframe is resolved from the DOM here rather + // than through canvasIframeRef so this callback stays independent of + // that later-declared memo. + if ( + selection.activeFileId && + selection.activeFileId !== activeFileIdForUndoRef.current + ) { setActiveFileId(selection.activeFileId); } + setSelectedLayerIdsState(selection.selectedLayerIds); + const firstLayerId = selection.selectedLayerIds[0]; + if (firstLayerId && typeof document !== "undefined") { + const iframe = document.querySelector( + "iframe[data-design-preview-iframe]", + ); + iframe?.contentWindow?.postMessage( + { + type: "select-element", + nodeId: firstLayerId, + selector: `[data-agent-native-node-id="${firstLayerId.replace(/"/g, '\\"')}"]`, + }, + "*", + ); + } }, [], ); @@ -3037,6 +3082,8 @@ function DesignEditor() { Boolean(undoManager?.canUndo()) || hasLocalUndo || clipboardPasteUndoStackRef.current.length > 0 || + // Task 1c — selection-only undo is available in both view modes. + selectionOnlyUndoStackRef.current.length > 0 || (canUseOverviewHistory && (contentUndoStackRef.current.length > 0 || geometryUndoStackRef.current.length > 0 || @@ -3049,6 +3096,7 @@ function DesignEditor() { Boolean(undoManager?.canRedo()) || hasLocalRedo || clipboardPasteRedoStackRef.current.length > 0 || + selectionOnlyRedoStackRef.current.length > 0 || (canUseOverviewHistory && (contentRedoStackRef.current.length > 0 || geometryRedoStackRef.current.length > 0 || @@ -3214,6 +3262,8 @@ function DesignEditor() { localContentRedoStackRef.current = []; geometryUndoStackRef.current = []; geometryRedoStackRef.current = []; + selectionOnlyUndoStackRef.current = []; + selectionOnlyRedoStackRef.current = []; fileCreationUndoStackRef.current = []; fileCreationRedoStackRef.current = []; fileDeletionUndoStackRef.current = []; @@ -3349,6 +3399,24 @@ function DesignEditor() { null, ); const [generationIssue, setGenerationIssue] = useState(null); + // Task 4c — inputs to the canvas agent-state badge. lastRunCompletedAt drives + // the transient "done" state; isOffline drives the "warning" state. + const [lastRunCompletedAt, setLastRunCompletedAt] = useState( + null, + ); + const [isOffline, setIsOffline] = useState( + typeof navigator !== "undefined" && navigator.onLine === false, + ); + useEffect(() => { + const goOnline = () => setIsOffline(false); + const goOffline = () => setIsOffline(true); + window.addEventListener("online", goOnline); + window.addEventListener("offline", goOffline); + return () => { + window.removeEventListener("online", goOnline); + window.removeEventListener("offline", goOffline); + }; + }, []); const [promptDesignSystemId, setPromptDesignSystemId] = useState< string | null | undefined >(undefined); @@ -3457,6 +3525,9 @@ function DesignEditor() { } }, [clearGenerationCompleteTimer, id, rememberPendingGenerationForRetry, t]); const handleGenerationComplete = useCallback(() => { + // Task 4c — stamp the completion so the canvas badge shows a transient + // "done" state that auto-decays (the badge owns the decay timer). + setLastRunCompletedAt(Date.now()); clearGenerationCompleteTimer(); generationCompleteTimerRef.current = window.setTimeout(() => { generationCompleteTimerRef.current = null; @@ -7632,6 +7703,67 @@ function DesignEditor() { activeEditorDragRef.current = active; }, []); + // Task 1c — selection-only undo/redo. Recorded from the genuine + // user-selection funnels only (click/layers-panel/context-menu select, + // marquee commit, clear-selection). Edits, paste, and undo/redo change + // selection through other paths and never call these, so an edit's + // accompanying selection change is not double-counted into this stack. + // `suppressSelectionOnlyHistoryRef` blocks any in-flight scheduled record + // while undo/redo is applying a restore. + const suppressSelectionOnlyHistoryRef = useRef(false); + const recordSelectionOnlyHistory = useCallback( + (before: GeometryHistorySelection, after: GeometryHistorySelection) => { + if (suppressSelectionOnlyHistoryRef.current) return; + const stack = selectionOnlyUndoStackRef.current; + const lastEntry = stack.length > 0 ? stack[stack.length - 1] : null; + const now = Date.now(); + const decision = shouldRecordSelectionHistory({ + prev: before, + next: after, + lastEntry, + now, + gestureActive: activeEditorDragRef.current, + }); + if (decision === "skip") return; + if (decision === "record") { + selectionOnlyUndoStackRef.current = [ + ...stack.slice(-(MAX_DESIGN_UNDO_STACK - 1)), + { before, after, at: now }, + ]; + clearRedoStacks(); + historyOrderRef.current = [ + ...historyOrderRef.current.slice(-(MAX_DESIGN_UNDO_STACK - 1)), + "selection", + ]; + } else { + const top = stack[stack.length - 1]; + const merged = { before: top.before, after, at: now }; + if (selectionSnapshotsEqual(merged.before, merged.after)) { + // The burst returned to its origin — drop the now-empty entry and + // its order token so Cmd+Z doesn't stop on a no-op. + selectionOnlyUndoStackRef.current = stack.slice(0, -1); + historyOrderRef.current = removeRecentUndoRedoOrderKinds( + historyOrderRef.current, + "selection", + 1, + ); + } else { + selectionOnlyUndoStackRef.current = [...stack.slice(0, -1), merged]; + } + } + syncUndoRedoState(); + }, + [clearRedoStacks, syncUndoRedoState], + ); + const scheduleSelectionOnlyHistoryRecord = useCallback(() => { + if (suppressSelectionOnlyHistoryRef.current) return; + if (activeEditorDragRef.current) return; + const before = captureCurrentSelection(); + requestAnimationFrame(() => { + recordSelectionOnlyHistory(before, captureCurrentSelection()); + }); + }, [recordSelectionOnlyHistory]); + const cancelActiveEditorDrag = useCallback(() => { if (!activeEditorDragRef.current) return false; activeEditorDragRef.current = false; @@ -7965,6 +8097,46 @@ function DesignEditor() { [canvasIframeRef, resolveSelectorRectInIframe], ); + // Task 4a — anchor for the SelectionAgentAffordance chip. Recomputed (after + // layout) whenever the selection, zoom, or view mode changes. Coordinates are + // container-relative (the chip renders absolutely inside canvasContainerRef). + const [selectionAffordanceAnchor, setSelectionAffordanceAnchor] = useState<{ + anchor: { + left: number; + top: number; + right: number; + bottom: number; + width: number; + height: number; + } | null; + container: { width: number; height: number } | null; + }>({ anchor: null, container: null }); + useLayoutEffect(() => { + const selector = selectedElement?.selector; + const containerBox = + canvasContainerRef.current?.getBoundingClientRect() ?? null; + const selRect = selector ? resolveSelectorRect(selector) : null; + if (!selRect || !containerBox) { + setSelectionAffordanceAnchor((prev) => + prev.anchor === null && prev.container === null + ? prev + : { anchor: null, container: null }, + ); + return; + } + setSelectionAffordanceAnchor({ + anchor: { + left: selRect.left - containerBox.left, + top: selRect.top - containerBox.top, + right: selRect.right - containerBox.left, + bottom: selRect.bottom - containerBox.top, + width: selRect.width, + height: selRect.height, + }, + container: { width: containerBox.width, height: containerBox.height }, + }); + }, [selectedElement, zoom, viewMode, resolveSelectorRect]); + // Resolve a text quote to a viewport rect by walking the iframe body's text. const resolveTextQuoteRect = useCallback( (quote: string): DOMRect | null => @@ -11933,6 +12105,43 @@ function DesignEditor() { const mirroredSelectionIdRef = useRef(null); const sentSelectionIdRef = useRef(null); const composerContextHasOurKeyRef = useRef(true); + // Task 4a/4b — single source of truth for the "design:selected-element" chat + // context payload. The R69 mirror effect below and the SelectionAgentAffordance + // / Cmd+Enter activation both call this so the staged selection context is + // byte-identical no matter how the agent is opened. Returns null when there is + // no meaningful selection to attach. + const buildSelectionContextItem = useCallback((): { + title: string; + context: string; + } | null => { + if (!id || !shouldMirrorSelectedElementToAgentChat(selectedElement)) { + return null; + } + const labelSource = + selectedElement.textContent?.trim() || + selectedCodeLayerNode?.layerName || + selectedElement.id || + selectedElement.tagName.toLowerCase(); + const shortLabel = + labelSource.length > 28 ? `${labelSource.slice(0, 25)}...` : labelSource; + const contextLines = [ + `Selected design element in design "${design?.title ?? id}".`, + activeFile + ? `Active screen: ${activeFile.filename} (${activeFile.id}).` + : "", + `Element: <${selectedElement.tagName.toLowerCase()}> ${shortLabel}`, + `Selector: ${selectedElement.selector}`, + selectedElement.sourceId ? `Source id: ${selectedElement.sourceId}` : "", + selectedCodeLayerNode ? `Code layer id: ${selectedCodeLayerNode.id}` : "", + selectedElement.classes.length + ? `Classes: ${selectedElement.classes.join(" ")}` + : "", + selectedElement.textContent?.trim() + ? `Text: ${selectedElement.textContent.trim()}` + : "", + ].filter(Boolean); + return { title: shortLabel, context: contextLines.join("\n") }; + }, [activeFile, design?.title, id, selectedCodeLayerNode, selectedElement]); useEffect(() => { const key = "design:selected-element"; if (!isSignedIn) return; @@ -11969,34 +12178,12 @@ function DesignEditor() { } mirroredSelectionIdRef.current = selectionId; - const labelSource = - selectedElement.textContent?.trim() || - selectedCodeLayerNode?.layerName || - selectedElement.id || - selectedElement.tagName.toLowerCase(); - const shortLabel = - labelSource.length > 28 ? `${labelSource.slice(0, 25)}...` : labelSource; - const contextLines = [ - `Selected design element in design "${design?.title ?? id}".`, - activeFile - ? `Active screen: ${activeFile.filename} (${activeFile.id}).` - : "", - `Element: <${selectedElement.tagName.toLowerCase()}> ${shortLabel}`, - `Selector: ${selectedElement.selector}`, - selectedElement.sourceId ? `Source id: ${selectedElement.sourceId}` : "", - selectedCodeLayerNode ? `Code layer id: ${selectedCodeLayerNode.id}` : "", - selectedElement.classes.length - ? `Classes: ${selectedElement.classes.join(" ")}` - : "", - selectedElement.textContent?.trim() - ? `Text: ${selectedElement.textContent.trim()}` - : "", - ].filter(Boolean); - + const item = buildSelectionContextItem(); + if (!item) return; setAgentChatContextItem({ key, - title: shortLabel, - context: contextLines.join("\n"), + title: item.title, + context: item.context, openSidebar: false, // Mirror the selection into chat context without stealing focus: this // effect re-fires on every selection change and on each get-design poll @@ -12005,14 +12192,7 @@ function DesignEditor() { focus: false, }); composerContextHasOurKeyRef.current = true; - }, [ - activeFile, - design?.title, - id, - isSignedIn, - selectedCodeLayerNode, - selectedElement, - ]); + }, [buildSelectionContextItem, id, isSignedIn, selectedElement]); // Bookkeeping only — mirrors "does the shared composer context still carry // our key" into a ref for the effect above to read. This is intentionally @@ -12028,6 +12208,30 @@ function DesignEditor() { composerContextItemsForBookkeeping.some((item) => item.key === key); }, [composerContextItemsForBookkeeping]); + // Task 4a/4b — open the agent chat for the current selection: stage the same + // selection context the R69 mirror builds, but this time open the sidebar and + // focus the composer. Reuses buildSelectionContextItem so no LLM call or new + // payload shape is introduced (the [Reprompt selection]/[Selection question] + // prefix contract in the agent still governs read-only vs mutating requests). + const openAgentForSelection = useCallback( + (mode?: "ask" | "change") => { + const item = buildSelectionContextItem(); + if (!item) return; + setAgentChatContextItem({ + key: "design:selected-element", + title: item.title, + context: + mode === "ask" + ? `${item.context}\n\n(The user opened this to ASK about the element — answer read-only unless they request a change.)` + : item.context, + openSidebar: true, + focus: true, + }); + composerContextHasOurKeyRef.current = true; + }, + [buildSelectionContextItem], + ); + useEffect(() => { const key = "design:design-system"; if (!isSignedIn) return; @@ -12173,6 +12377,9 @@ function DesignEditor() { breakpointWidthPx?: number; } = {}, ) => { + // Task 1c — record the selection change this user click produces so it is + // undoable on its own (rAF captures the post-select snapshot as "after"). + scheduleSelectionOnlyHistoryRecord(); const pendingLayerId = pendingOverviewLayerSelectionRef.current; const pendingScreenId = pendingOverviewScreenSelectionRef.current; const projection = getCodeLayerProjectionForScreen(screenId); @@ -12349,6 +12556,7 @@ function DesignEditor() { getScreenContent, handleBreakpointBarSelect, id, + scheduleSelectionOnlyHistoryRecord, selectedLayerIdsState, t, ], @@ -17269,21 +17477,99 @@ function DesignEditor() { const selectedRects = selectedNodes.map(rectFromCodeLayerNode); if (selectedRects.length >= 2) { - // Multi-selection: align to the selection's own combined bbox. - const bounds = getFrameGroupBounds(selectedRects); - if (!bounds) return; - const positions = computeAlignedPositions( - selectedRects, - { - x: bounds.left, - y: bounds.top, - width: bounds.width, - height: bounds.height, - }, - edge, + // Task 3a — partition into the smallest hierarchy-valid groups (by + // nearest meaningful parent) so alignment respects nesting: aligning + // three cards moves the card boxes, not the titles inside them. + // Aligning across different parents is also geometrically wrong — each + // node's left/top is relative to its own offset parent — which + // per-group alignment avoids by construction (each group shares a + // coordinate space). Positions from every group merge into one commit. + const groups = partitionSelectionForAlignment( + projection.nodes, + nodeIds, ); - if (positions.size === 0) return; - commitNodePositions(baseContent, positions); + const rectById = new Map( + selectedNodes.map((node) => [node.id, rectFromCodeLayerNode(node)]), + ); + const combined = new Map(); + let skippedAutoLayout = false; + for (const group of groups) { + const groupRects = group.nodeIds + .map((nodeId) => rectById.get(nodeId)) + .filter((rect): rect is AlignableRect => Boolean(rect)); + if (groupRects.length === 0) continue; + if (groupRects.length === 1) { + // A lone group member aligns within its own (direct) parent's + // content box, matching the single-selection branch below. + const node = nodesById.get(group.nodeIds[0]!); + const directParent = node?.parentId + ? nodesById.get(node.parentId) + : null; + if (!directParent) continue; + if ( + (directParent.layout.isFlexContainer || + directParent.layout.isGridContainer) && + node && + !isAbsoluteCodeLayerNode(node) + ) { + skippedAutoLayout = true; + continue; + } + const parentRect = rectFromCodeLayerNode(directParent); + const positions = computeAlignedPositions( + groupRects, + { + x: 0, + y: 0, + width: parentRect.width, + height: parentRect.height, + }, + edge, + ); + positions.forEach((position, id) => combined.set(id, position)); + continue; + } + // Multi-member group: if these are in-flow children of an auto-layout + // container, aligning would absolutize and fight the layout — honest + // no-op + toast instead of a silent absolutize. + const parentNode = group.parentId + ? nodesById.get(group.parentId) + : null; + if ( + parentNode && + (parentNode.layout.isFlexContainer || + parentNode.layout.isGridContainer) && + group.nodeIds.some((nodeId) => { + const node = nodesById.get(nodeId); + return node ? !isAbsoluteCodeLayerNode(node) : false; + }) + ) { + skippedAutoLayout = true; + continue; + } + const bounds = getFrameGroupBounds(groupRects); + if (!bounds) continue; + const positions = computeAlignedPositions( + groupRects, + { + x: bounds.left, + y: bounds.top, + width: bounds.width, + height: bounds.height, + }, + edge, + ); + positions.forEach((position, id) => combined.set(id, position)); + } + if (combined.size > 0) commitNodePositions(baseContent, combined); + if (skippedAutoLayout) { + toast.info( + t("designEditor.toasts.alignAutoLayoutSkipped", { + defaultValue: + "Auto-layout children follow their container — adjust the layout instead of aligning.", + }), + ); + } return; } @@ -19350,6 +19636,63 @@ function DesignEditor() { ], ); + // Task 1d — hide the in-iframe selection chrome while the selected element is + // being nudged by the keyboard, restoring it once the burst settles (Figma + // hides the bounds during keyboard moves so the outline doesn't chase the + // element frame-by-frame). The settle timer (re-armed on every nudge) is the + // authoritative restore and matches the ~800ms keyboard-coalesce window used + // by handleGeometryCommit; a window keyup on the arrow keys restores sooner + // when the canvas host itself holds focus. + const selectionChromeHiddenRef = useRef(false); + const selectionChromeSettleTimerRef = useRef(undefined); + const restoreSelectionChrome = useCallback(() => { + if (selectionChromeSettleTimerRef.current !== undefined) { + window.clearTimeout(selectionChromeSettleTimerRef.current); + selectionChromeSettleTimerRef.current = undefined; + } + if (!selectionChromeHiddenRef.current) return; + selectionChromeHiddenRef.current = false; + canvasIframeRef.current?.contentWindow?.postMessage( + { type: "set-selection-chrome-hidden", hidden: false }, + "*", + ); + }, [canvasIframeRef]); + const hideSelectionChromeForNudge = useCallback(() => { + if (!selectionChromeHiddenRef.current) { + selectionChromeHiddenRef.current = true; + canvasIframeRef.current?.contentWindow?.postMessage( + { type: "set-selection-chrome-hidden", hidden: true }, + "*", + ); + } + if (selectionChromeSettleTimerRef.current !== undefined) { + window.clearTimeout(selectionChromeSettleTimerRef.current); + } + selectionChromeSettleTimerRef.current = window.setTimeout(() => { + selectionChromeSettleTimerRef.current = undefined; + restoreSelectionChrome(); + }, 800); + }, [canvasIframeRef, restoreSelectionChrome]); + useEffect(() => { + const onKeyUp = (event: KeyboardEvent) => { + if ( + event.key === "ArrowUp" || + event.key === "ArrowDown" || + event.key === "ArrowLeft" || + event.key === "ArrowRight" + ) { + restoreSelectionChrome(); + } + }; + window.addEventListener("keyup", onKeyUp); + return () => { + window.removeEventListener("keyup", onKeyUp); + if (selectionChromeSettleTimerRef.current !== undefined) { + window.clearTimeout(selectionChromeSettleTimerRef.current); + } + }; + }, [restoreSelectionChrome]); + const handleNudgeSelection = useCallback( (direction: "up" | "right" | "down" | "left", largeStep: boolean) => { if (!canEditDesign) return; @@ -19402,6 +19745,7 @@ function DesignEditor() { } if (!selectedElement?.selector) return; + hideSelectionChromeForNudge(); const left = parseFloat(selectedElement.computedStyles.left || "0") || 0; const top = parseFloat(selectedElement.computedStyles.top || "0") || 0; commitVisualStyles(selectedElement.selector, { @@ -19419,6 +19763,7 @@ function DesignEditor() { canEditDesign, commitVisualStyles, handleGeometryCommit, + hideSelectionChromeForNudge, overviewScreens, overviewSelectedScreenIds, selectedElement, @@ -19888,6 +20233,34 @@ function DesignEditor() { restoreSelectionSnapshot(entry.selectionBefore); return true; }; + // Task 1c — undo a selection-only change: restore the entry's `before` + // snapshot and move it to the redo stack. suppressSelectionOnlyHistoryRef + // blocks the scheduled recorder from capturing this restore as a new edit. + const undoSelection = () => { + const entry = + selectionOnlyUndoStackRef.current[ + selectionOnlyUndoStackRef.current.length - 1 + ]; + if (!entry) return false; + selectionOnlyUndoStackRef.current = + selectionOnlyUndoStackRef.current.slice(0, -1); + selectionOnlyRedoStackRef.current = [ + ...selectionOnlyRedoStackRef.current.slice( + -(MAX_DESIGN_UNDO_STACK - 1), + ), + entry, + ]; + redoOrderRef.current = [ + ...redoOrderRef.current.slice(-(MAX_DESIGN_UNDO_STACK - 1)), + "selection", + ]; + suppressSelectionOnlyHistoryRef.current = true; + restoreSelectionSnapshot(entry.before); + requestAnimationFrame(() => { + suppressSelectionOnlyHistoryRef.current = false; + }); + return true; + }; // U12: undo a screen create/duplicate by soft-deleting the file it // created (performDeleteFiles already prunes any content/geometry undo // entries for that file, mirroring U2's screen-deletion cleanup). @@ -20040,6 +20413,11 @@ function DesignEditor() { (prunedUndoHistory > prunedBefore ? false : undoGeometry()) ); } + // Task 1c — a selection-only entry; if its stack is empty (desync), fall + // through to the ordinary chain so the loop can't dead-end. + if (preferred === "selection") { + return undoSelection() || undoContent() || undoGeometry(); + } return undoFileDeletion() || undoContent() || undoGeometry(); }; let didUndo = false; @@ -20050,7 +20428,9 @@ function DesignEditor() { if (didUndo || preferred === undefined) break; } } else { - didUndo = undoContent("local"); + // Single-screen mode has no chronological order list; undo local content + // first, then fall back to selection-only entries. + didUndo = undoContent("local") || undoSelection(); } if (didUndo || prunedUndoHistory > 0) { syncUndoRedoState(); @@ -20537,6 +20917,33 @@ function DesignEditor() { restoreSelectionSnapshot(entry.selectionAfter); return true; }; + // Task 1c — redo a selection-only change: re-apply the entry's `after` + // snapshot and move it back to the undo stack. + const redoSelection = () => { + const entry = + selectionOnlyRedoStackRef.current[ + selectionOnlyRedoStackRef.current.length - 1 + ]; + if (!entry) return false; + selectionOnlyRedoStackRef.current = + selectionOnlyRedoStackRef.current.slice(0, -1); + selectionOnlyUndoStackRef.current = [ + ...selectionOnlyUndoStackRef.current.slice( + -(MAX_DESIGN_UNDO_STACK - 1), + ), + entry, + ]; + historyOrderRef.current = [ + ...historyOrderRef.current.slice(-(MAX_DESIGN_UNDO_STACK - 1)), + "selection", + ]; + suppressSelectionOnlyHistoryRef.current = true; + restoreSelectionSnapshot(entry.after); + requestAnimationFrame(() => { + suppressSelectionOnlyHistoryRef.current = false; + }); + return true; + }; // U12: redo a screen create/duplicate by recreating the file with the // same filename/content/fileType and restoring its recorded geometry. // This is async (createFileMutation), unlike every other redo path here, @@ -20707,6 +21114,9 @@ function DesignEditor() { (prunedRedoHistory > prunedBefore ? false : redoGeometry()) ); } + if (preferred === "selection") { + return redoSelection() || redoContent() || redoGeometry(); + } return redoFileDeletion() || redoContent() || redoGeometry(); }; let didRedo = false; @@ -20717,7 +21127,7 @@ function DesignEditor() { if (didRedo || preferred === undefined) break; } } else { - didRedo = redoContent("local"); + didRedo = redoContent("local") || redoSelection(); } if (didRedo || prunedRedoHistory > 0) { syncUndoRedoState(); @@ -21957,6 +22367,8 @@ function DesignEditor() { : undefined, onEscape: handleEscapeHotkey, onEnter: handleEnterHotkey, + onOpenAgentForSelection: + isSignedIn && selectedElement ? () => openAgentForSelection() : undefined, onSelectParent: handleSelectParentLayer, onTab: ({ backwards }) => handleCycleSibling(backwards), onNextFrame: () => handleCycleFile(false), @@ -26411,6 +26823,10 @@ function DesignEditor() { selection: CanvasLayerMarqueeSelection[], intent: ElementSelectionIntent, ) => { + // Task 1c — one selection-only undo entry per completed marquee: the + // 800ms coalesce window collapses the per-mousemove-tick reports below + // into a single entry whose `before` is the pre-marquee selection. + scheduleSelectionOnlyHistoryRecord(); // PF10: MultiScreenCanvas reports the marquee hit-set on every // mousemove tick during a drag, not just on settle (see // reportLayerSelection in MultiScreenCanvas.tsx). Bail before any @@ -26517,6 +26933,7 @@ function DesignEditor() { clearPendingOverviewLayerSelectionTimer, focusDesignInspectorForSelection, getCodeLayerProjectionForScreen, + scheduleSelectionOnlyHistoryRecord, ], ); @@ -29285,6 +29702,35 @@ function DesignEditor() { }} /> )} + {/* Task 4a — contextual agent affordance near the selection. */} + {!embedded && isSignedIn && ( + 0 + } + onChange={() => openAgentForSelection("change")} + onAsk={() => openAgentForSelection("ask")} + /> + )} + {/* Task 4c — canvas agent-state badge (Figma-silent when ready). */} + {!embedded && !uiHidden && ( + + )} {/* Figma-style notice for viewers who can't edit this design. Only shown once accessRole has actually resolved to "viewer" to avoid flashing during load. */} diff --git a/templates/design/app/pages/design-editor/canvas-agent-state.test.ts b/templates/design/app/pages/design-editor/canvas-agent-state.test.ts new file mode 100644 index 0000000000..756847b44a --- /dev/null +++ b/templates/design/app/pages/design-editor/canvas-agent-state.test.ts @@ -0,0 +1,156 @@ +import { describe, expect, it } from "vitest"; + +import { + deriveCanvasAgentState, + type CanvasAgentState, + type CanvasAgentStateInputs, +} from "./canvas-agent-state"; + +const NOW = 1_000_000; + +function inputs( + overrides: Partial = {}, +): CanvasAgentStateInputs { + return { + generating: false, + generationIssue: false, + pendingQuestionCount: 0, + resolveNodeRewritePending: false, + offline: false, + lastRunCompletedAt: null, + ...overrides, + }; +} + +describe("deriveCanvasAgentState", () => { + describe("each signal in isolation resolves to its state", () => { + const cases: Array< + [string, Partial, CanvasAgentState] + > = [ + ["generationIssue", { generationIssue: true }, "failed"], + ["pendingQuestionCount", { pendingQuestionCount: 2 }, "needs-answer"], + [ + "resolveNodeRewritePending", + { resolveNodeRewritePending: true }, + "applying", + ], + ["generating", { generating: true }, "working"], + ["offline", { offline: true }, "warning"], + ["recent completion", { lastRunCompletedAt: NOW - 100 }, "done"], + ["nothing active", {}, "ready"], + ]; + for (const [label, override, expected] of cases) { + it(`${label} -> ${expected}`, () => { + expect(deriveCanvasAgentState(inputs(override), NOW)).toBe(expected); + }); + } + }); + + describe("pairwise priority conflicts (higher priority always wins)", () => { + it("failed beats needs-answer", () => { + expect( + deriveCanvasAgentState( + inputs({ generationIssue: true, pendingQuestionCount: 3 }), + NOW, + ), + ).toBe("failed"); + }); + + it("needs-answer beats applying", () => { + expect( + deriveCanvasAgentState( + inputs({ pendingQuestionCount: 1, resolveNodeRewritePending: true }), + NOW, + ), + ).toBe("needs-answer"); + }); + + it("applying beats working", () => { + expect( + deriveCanvasAgentState( + inputs({ resolveNodeRewritePending: true, generating: true }), + NOW, + ), + ).toBe("applying"); + }); + + it("working beats warning", () => { + expect( + deriveCanvasAgentState( + inputs({ generating: true, offline: true }), + NOW, + ), + ).toBe("working"); + }); + + it("warning beats done", () => { + expect( + deriveCanvasAgentState( + inputs({ offline: true, lastRunCompletedAt: NOW - 100 }), + NOW, + ), + ).toBe("warning"); + }); + + it("done beats ready", () => { + expect( + deriveCanvasAgentState(inputs({ lastRunCompletedAt: NOW - 100 }), NOW), + ).toBe("done"); + }); + }); + + describe("done window decay", () => { + it("reports done strictly inside the window", () => { + expect( + deriveCanvasAgentState( + inputs({ lastRunCompletedAt: NOW - 3999 }), + NOW, + 4000, + ), + ).toBe("done"); + }); + + it("returns ready exactly at the window boundary", () => { + expect( + deriveCanvasAgentState( + inputs({ lastRunCompletedAt: NOW - 4000 }), + NOW, + 4000, + ), + ).toBe("ready"); + }); + + it("returns ready after the window has elapsed", () => { + expect( + deriveCanvasAgentState( + inputs({ lastRunCompletedAt: NOW - 8000 }), + NOW, + 4000, + ), + ).toBe("ready"); + }); + + it("honors a custom window", () => { + expect( + deriveCanvasAgentState( + inputs({ lastRunCompletedAt: NOW - 500 }), + NOW, + 400, + ), + ).toBe("ready"); + expect( + deriveCanvasAgentState( + inputs({ lastRunCompletedAt: NOW - 300 }), + NOW, + 400, + ), + ).toBe("done"); + }); + + it("treats a null completion timestamp as ready", () => { + expect( + deriveCanvasAgentState(inputs({ lastRunCompletedAt: null }), NOW), + ).toBe("ready"); + }); + }); +}); diff --git a/templates/design/app/pages/design-editor/canvas-agent-state.ts b/templates/design/app/pages/design-editor/canvas-agent-state.ts new file mode 100644 index 0000000000..7a17b4d88b --- /dev/null +++ b/templates/design/app/pages/design-editor/canvas-agent-state.ts @@ -0,0 +1,50 @@ +export type CanvasAgentState = + | "ready" + | "working" + | "needs-answer" + | "warning" + | "applying" + | "done" + | "failed"; + +export interface CanvasAgentStateInputs { + generating: boolean; + generationIssue: boolean; + pendingQuestionCount: number; + resolveNodeRewritePending: boolean; + offline: boolean; + lastRunCompletedAt: number | null; +} + +const DEFAULT_DONE_WINDOW_MS = 4000; + +/** + * Pure reducer for the canvas agent-state badge. + * + * Priority (highest wins): + * failed > needs-answer > applying > working > warning > done > ready + * + * "done" auto-decays without a timer: it is reported only while + * `now - lastRunCompletedAt < doneWindowMs`. Once that window has elapsed + * (`now - lastRunCompletedAt >= doneWindowMs`) the reducer returns "ready". + * The consuming component owns the re-render/decay tick; this reducer holds no + * timers and touches no DOM. + */ +export function deriveCanvasAgentState( + inputs: CanvasAgentStateInputs, + now: number, + doneWindowMs: number = DEFAULT_DONE_WINDOW_MS, +): CanvasAgentState { + if (inputs.generationIssue) return "failed"; + if (inputs.pendingQuestionCount > 0) return "needs-answer"; + if (inputs.resolveNodeRewritePending) return "applying"; + if (inputs.generating) return "working"; + if (inputs.offline) return "warning"; + if ( + inputs.lastRunCompletedAt != null && + now - inputs.lastRunCompletedAt < doneWindowMs + ) { + return "done"; + } + return "ready"; +} diff --git a/templates/design/app/pages/design-editor/editor-state.ts b/templates/design/app/pages/design-editor/editor-state.ts index 5e70042358..90af5cf936 100644 --- a/templates/design/app/pages/design-editor/editor-state.ts +++ b/templates/design/app/pages/design-editor/editor-state.ts @@ -359,7 +359,11 @@ export type UndoRedoOrderKind = | "file-content" | "geometry" | "file-created" - | "file-deleted"; + | "file-deleted" + // Task 1c — a selection-only change (no accompanying content/geometry edit). + // Interleaved chronologically with the other kinds so Cmd+Z walks selection + // changes and edits in the order they happened. + | "selection"; export function getUndoRedoPriorityOrder( preferred: UndoRedoOrderKind | undefined, diff --git a/templates/design/app/pages/design-editor/history.ts b/templates/design/app/pages/design-editor/history.ts index 7b0c9feb1c..88d1a7a5fb 100644 --- a/templates/design/app/pages/design-editor/history.ts +++ b/templates/design/app/pages/design-editor/history.ts @@ -18,6 +18,55 @@ export interface GeometryHistoryEntry { selectionAfter?: GeometryHistorySelection; } +/** Task 1c — one selection-only undo step: a selection that changed with no + * accompanying content/geometry edit. `at` is the epoch-ms timestamp used to + * coalesce a rapid burst of selection changes into a single undo entry. */ +export interface SelectionHistoryEntry { + before: GeometryHistorySelection; + after: GeometryHistorySelection; + at: number; +} + +/** Order-sensitive equality between two selection snapshots (selection arrays + * are maintained in a stable order, so index-wise comparison is exact). */ +export function selectionSnapshotsEqual( + a: GeometryHistorySelection, + b: GeometryHistorySelection, +): boolean { + const sameIds = (x: string[], y: string[]) => + x.length === y.length && x.every((value, index) => value === y[index]); + return ( + a.activeFileId === b.activeFileId && + sameIds(a.overviewSelectedScreenIds, b.overviewSelectedScreenIds) && + sameIds(a.selectedLayerIds, b.selectedLayerIds) + ); +} + +export const SELECTION_HISTORY_COALESCE_WINDOW_MS = 800; + +export type SelectionHistoryDecision = "skip" | "record" | "coalesce"; + +/** Pure decision for whether/how a selection change enters the selection-only + * undo stack. Skips no-ops and gesture temp-selections; collapses a burst of + * selection changes within the coalesce window into the last entry; otherwise + * records a fresh entry. The caller applies the decision to its stacks. */ +export function shouldRecordSelectionHistory(input: { + prev: GeometryHistorySelection; + next: GeometryHistorySelection; + lastEntry: SelectionHistoryEntry | null; + now: number; + gestureActive: boolean; + coalesceWindowMs?: number; +}): SelectionHistoryDecision { + if (input.gestureActive) return "skip"; + if (selectionSnapshotsEqual(input.prev, input.next)) return "skip"; + const window = input.coalesceWindowMs ?? SELECTION_HISTORY_COALESCE_WINDOW_MS; + if (input.lastEntry && input.now - input.lastEntry.at <= window) { + return "coalesce"; + } + return "record"; +} + export interface FileCreationHistoryEntry { filename: string; content: string; diff --git a/templates/design/app/pages/design-editor/paste-placement.test.ts b/templates/design/app/pages/design-editor/paste-placement.test.ts new file mode 100644 index 0000000000..dee96788a5 --- /dev/null +++ b/templates/design/app/pages/design-editor/paste-placement.test.ts @@ -0,0 +1,495 @@ +import type { FrameBounds } from "@shared/canvas-math"; +import { describe, expect, it } from "vitest"; + +import { + CASCADE_STEP_PX, + type PastePlacement, + type PastePlacementEntry, + type PastePlacementInput, + resolvePastePlacement, + SAME_SCREEN_NUDGE_PX, +} from "./paste-placement"; + +function makeBounds( + left: number, + top: number, + width: number, + height: number, +): FrameBounds { + return { + left, + top, + right: left + width, + bottom: top + height, + width, + height, + centerX: left + width / 2, + centerY: top + height / 2, + }; +} + +function makeEntry( + index: number, + overrides: Partial = {}, +): PastePlacementEntry { + return { + index, + sourceFileId: "screen-a", + sourcePosition: { x: 100, y: 100 }, + kind: "layer", + ...overrides, + }; +} + +function baseInput( + overrides: Partial = {}, +): PastePlacementInput { + return { + entries: [makeEntry(0)], + target: { fileId: "screen-a", isSourceScreen: true, frameBounds: null }, + selection: null, + viewport: { visibleCanvasBounds: makeBounds(0, 0, 1000, 1000), zoom: 100 }, + explicitPoint: null, + origin: "same-screen", + cascadeCount: 0, + ...overrides, + }; +} + +/** Narrow a placement to its absolute position or fail loudly. */ +function pos(placement: PastePlacement): { x: number; y: number } { + if (placement.mode !== "absolute") { + throw new Error(`expected absolute placement, got ${placement.mode}`); + } + return placement.position; +} + +describe("Rule 1 — flow-after", () => { + it("pastes as flow sibling when a flow-container is selected (no position)", () => { + const result = resolvePastePlacement( + baseInput({ + entries: [makeEntry(0), makeEntry(1)], + selection: { selector: "#hero", isFlowContainer: true }, + }), + ); + expect(result.placements).toEqual([ + { mode: "flow-after", anchorSelector: "#hero" }, + { mode: "flow-after", anchorSelector: "#hero" }, + ]); + // Flow-after emits no absolute coordinates. + expect(result.placements.every((p) => p.mode === "flow-after")).toBe(true); + }); + + it("treats any selected element with a selector as a flow sibling", () => { + const result = resolvePastePlacement( + baseInput({ selection: { selector: ".card", isFlowContainer: false } }), + ); + expect(result.placements[0]).toEqual({ + mode: "flow-after", + anchorSelector: ".card", + }); + }); + + it("falls through to absolute placement when the selector is null", () => { + const result = resolvePastePlacement( + baseInput({ selection: { selector: null, isFlowContainer: true } }), + ); + expect(result.placements[0].mode).toBe("absolute"); + }); + + it("does not flow-after when an explicit point is provided", () => { + const result = resolvePastePlacement( + baseInput({ + selection: { selector: "#hero", isFlowContainer: true }, + explicitPoint: { x: 40, y: 40 }, + }), + ); + expect(result.placements[0].mode).toBe("absolute"); + }); + + it("does not flow-after for non-layer (screen/image) entries", () => { + const result = resolvePastePlacement( + baseInput({ + entries: [makeEntry(0, { kind: "screen" })], + selection: { selector: "#hero", isFlowContainer: true }, + }), + ); + expect(result.placements[0].mode).toBe("absolute"); + }); +}); + +describe("Rule 2 — same container", () => { + it("places at source + nudge with no cascade on the first paste", () => { + const result = resolvePastePlacement( + baseInput({ + entries: [makeEntry(0, { sourcePosition: { x: 100, y: 200 } })], + }), + ); + expect(pos(result.placements[0])).toEqual({ + x: 100 + SAME_SCREEN_NUDGE_PX, + y: 200 + SAME_SCREEN_NUDGE_PX, + }); + expect(result.ensureVisible).toBe(false); + }); + + it("preserves each entry's own source coords (uniform nudge)", () => { + const result = resolvePastePlacement( + baseInput({ + entries: [ + makeEntry(0, { sourcePosition: { x: 100, y: 100 } }), + makeEntry(1, { sourcePosition: { x: 300, y: 180 } }), + ], + }), + ); + const a = pos(result.placements[0]); + const b = pos(result.placements[1]); + expect(a).toEqual({ x: 110, y: 110 }); + expect(b).toEqual({ x: 310, y: 190 }); + // Spacing between the two entries is identical before and after. + expect(b.x - a.x).toBe(200); + expect(b.y - a.y).toBe(80); + }); + + it("applies the cascade offset uniformly to the group", () => { + const result = resolvePastePlacement( + baseInput({ + entries: [ + makeEntry(0, { sourcePosition: { x: 100, y: 100 } }), + makeEntry(1, { sourcePosition: { x: 300, y: 100 } }), + ], + cascadeCount: 2, + }), + ); + const offset = SAME_SCREEN_NUDGE_PX + 2 * CASCADE_STEP_PX; + expect(pos(result.placements[0])).toEqual({ + x: 100 + offset, + y: 100 + offset, + }); + expect(pos(result.placements[1])).toEqual({ + x: 300 + offset, + y: 100 + offset, + }); + }); +}); + +describe("Rule 3 — cross-screen relative", () => { + const destFrame = makeBounds(500, 500, 400, 400); + + it("anchors the group top-left to the destination frame origin", () => { + const result = resolvePastePlacement( + baseInput({ + origin: "cross-screen", + target: { + fileId: "screen-b", + isSourceScreen: false, + frameBounds: destFrame, + }, + entries: [makeEntry(0, { sourcePosition: { x: 100, y: 100 } })], + viewport: { + visibleCanvasBounds: makeBounds(400, 400, 800, 800), + zoom: 100, + }, + }), + ); + expect(pos(result.placements[0])).toEqual({ x: 500, y: 500 }); + expect(result.ensureVisible).toBe(false); + }); + + it("preserves internal spacing of a multi-entry group", () => { + const entries = [ + makeEntry(0, { sourcePosition: { x: 100, y: 100 } }), + makeEntry(1, { sourcePosition: { x: 260, y: 190 } }), + ]; + const beforeDx = 260 - 100; + const beforeDy = 190 - 100; + const result = resolvePastePlacement( + baseInput({ + origin: "cross-screen", + target: { + fileId: "screen-b", + isSourceScreen: false, + frameBounds: destFrame, + }, + entries, + viewport: { + visibleCanvasBounds: makeBounds(400, 400, 800, 800), + zoom: 100, + }, + }), + ); + const a = pos(result.placements[0]); + const b = pos(result.placements[1]); + expect(a).toEqual({ x: 500, y: 500 }); + // Two-entry spacing is identical before and after the cross-screen paste. + expect(b.x - a.x).toBe(beforeDx); + expect(b.y - a.y).toBe(beforeDy); + }); + + it("applies cascade offset to the whole group when kept visible", () => { + const run = (cascadeCount: number) => + resolvePastePlacement( + baseInput({ + origin: "cross-screen", + target: { + fileId: "screen-b", + isSourceScreen: false, + frameBounds: destFrame, + }, + entries: [makeEntry(0, { sourcePosition: { x: 100, y: 100 } })], + viewport: { + visibleCanvasBounds: makeBounds(400, 400, 2000, 2000), + zoom: 100, + }, + cascadeCount, + }), + ); + expect(pos(run(0).placements[0])).toEqual({ x: 500, y: 500 }); + expect(pos(run(1).placements[0])).toEqual({ x: 516, y: 516 }); + expect(pos(run(2).placements[0])).toEqual({ x: 532, y: 532 }); + }); +}); + +describe("Rule 4 — off-screen visibility", () => { + const destFrame = makeBounds(0, 0, 400, 400); + + function crossInput( + bounds: FrameBounds, + entries: PastePlacementEntry[], + ): PastePlacementInput { + return baseInput({ + origin: "cross-screen", + target: { + fileId: "screen-b", + isSourceScreen: false, + frameBounds: destFrame, + }, + entries, + viewport: { visibleCanvasBounds: bounds, zoom: 100 }, + }); + } + + it("centers the group when it is fully off-screen", () => { + // Group lands at frame origin (0,0); visible region is far away. + const bounds = makeBounds(2000, 2000, 1000, 1000); + const result = resolvePastePlacement( + crossInput(bounds, [ + makeEntry(0, { sourcePosition: { x: 100, y: 100 } }), + ]), + ); + expect(result.ensureVisible).toBe(true); + // Single-entry group center lands exactly on the viewport center. + expect(pos(result.placements[0])).toEqual({ + x: bounds.centerX, + y: bounds.centerY, + }); + }); + + it("centers when less than 50% of the group's AABB is visible", () => { + // Group AABB spans (0,0)-(400,400) = 160000 area. Visible starts at + // (200,200) so only a 200x200 corner (40000 = 25%) overlaps. + const entries = [ + makeEntry(0, { sourcePosition: { x: 100, y: 100 } }), + makeEntry(1, { sourcePosition: { x: 500, y: 500 } }), + ]; + const bounds = makeBounds(200, 200, 800, 800); + const result = resolvePastePlacement(crossInput(bounds, entries)); + expect(result.ensureVisible).toBe(true); + const a = pos(result.placements[0]); + const b = pos(result.placements[1]); + // Whole group translated by one vector: internal spacing preserved. + expect(b.x - a.x).toBe(400); + expect(b.y - a.y).toBe(400); + // Group center matches viewport center after the single translation. + expect((a.x + b.x) / 2).toBe(bounds.centerX); + expect((a.y + b.y) / 2).toBe(bounds.centerY); + }); + + it("does not center when the group is mostly visible", () => { + // Group AABB (0,0)-(400,400) fully inside a large viewport → 100% visible. + const entries = [ + makeEntry(0, { sourcePosition: { x: 100, y: 100 } }), + makeEntry(1, { sourcePosition: { x: 500, y: 500 } }), + ]; + const bounds = makeBounds(0, 0, 2000, 2000); + const result = resolvePastePlacement(crossInput(bounds, entries)); + expect(result.ensureVisible).toBe(false); + expect(pos(result.placements[0])).toEqual({ x: 0, y: 0 }); + }); + + it("centers under a zoomed-in (small) viewport that excludes the group", () => { + // Highly zoomed-in: the visible canvas region is a small window that does + // not contain the group placed at the destination frame origin. + const entries = [ + makeEntry(0, { sourcePosition: { x: 100, y: 100 } }), + makeEntry(1, { sourcePosition: { x: 300, y: 250 } }), + ]; + const bounds = makeBounds(1200, 900, 200, 150); + const result = resolvePastePlacement(crossInput(bounds, entries)); + expect(result.ensureVisible).toBe(true); + const a = pos(result.placements[0]); + const b = pos(result.placements[1]); + expect(b.x - a.x).toBe(200); + expect(b.y - a.y).toBe(150); + expect((a.x + b.x) / 2).toBe(bounds.centerX); + expect((a.y + b.y) / 2).toBe(bounds.centerY); + }); +}); + +describe("Rule 5 — explicit point", () => { + it("places the group top-left at the explicit point, offsets preserved", () => { + const entries = [ + makeEntry(0, { sourcePosition: { x: 100, y: 100 } }), + makeEntry(1, { sourcePosition: { x: 240, y: 170 } }), + ]; + const result = resolvePastePlacement( + baseInput({ explicitPoint: { x: 40, y: 60 }, entries }), + ); + const a = pos(result.placements[0]); + const b = pos(result.placements[1]); + expect(a).toEqual({ x: 40, y: 60 }); + // Relative spacing preserved from the source group. + expect(b.x - a.x).toBe(140); + expect(b.y - a.y).toBe(70); + expect(result.ensureVisible).toBe(false); + }); + + it("staggers by index when the group has no source positions", () => { + const entries = [ + makeEntry(0, { sourcePosition: null, kind: "image" }), + makeEntry(1, { sourcePosition: null, kind: "image" }), + ]; + const result = resolvePastePlacement( + baseInput({ explicitPoint: { x: 40, y: 60 }, entries }), + ); + expect(pos(result.placements[0])).toEqual({ x: 40, y: 60 }); + expect(pos(result.placements[1])).toEqual({ + x: 40 + CASCADE_STEP_PX, + y: 60 + CASCADE_STEP_PX, + }); + }); +}); + +describe("Rule 6 — last resort stagger", () => { + it("staggers from the viewport center when there are no source positions", () => { + const entries = [ + makeEntry(0, { sourcePosition: null, kind: "image", sourceFileId: null }), + makeEntry(1, { sourcePosition: null, kind: "image", sourceFileId: null }), + ]; + const bounds = makeBounds(0, 0, 1000, 800); + const result = resolvePastePlacement( + baseInput({ + origin: "external", + entries, + viewport: { visibleCanvasBounds: bounds, zoom: 100 }, + }), + ); + expect(pos(result.placements[0])).toEqual({ + x: bounds.centerX, + y: bounds.centerY, + }); + expect(pos(result.placements[1])).toEqual({ + x: bounds.centerX + CASCADE_STEP_PX, + y: bounds.centerY + CASCADE_STEP_PX, + }); + expect(result.ensureVisible).toBe(true); + }); + + it("falls back to {120,120} when no viewport is available", () => { + const result = resolvePastePlacement( + baseInput({ + origin: "assets", + entries: [makeEntry(0, { sourcePosition: null, kind: "image" })], + viewport: null, + }), + ); + expect(pos(result.placements[0])).toEqual({ x: 120, y: 120 }); + expect(result.ensureVisible).toBe(true); + }); +}); + +describe("Rule 7 — cascade", () => { + it("increments cascade for same-screen keyboard pastes (V,V,V → 0,16,32)", () => { + const source = { x: 100, y: 100 }; + let cascadeCount = 0; + const offsets: number[] = []; + for (let i = 0; i < 3; i++) { + const result = resolvePastePlacement( + baseInput({ + entries: [makeEntry(0, { sourcePosition: source })], + cascadeCount, + }), + ); + offsets.push( + pos(result.placements[0]).x - source.x - SAME_SCREEN_NUDGE_PX, + ); + cascadeCount = result.nextCascadeCount; + } + expect(offsets).toEqual([0, CASCADE_STEP_PX, 2 * CASCADE_STEP_PX]); + // Monotonically increasing group offset. + expect(offsets[1]).toBeGreaterThan(offsets[0]); + expect(offsets[2]).toBeGreaterThan(offsets[1]); + }); + + it("increments cascade for cross-screen keyboard pastes", () => { + const result = resolvePastePlacement( + baseInput({ + origin: "cross-screen", + target: { + fileId: "screen-b", + isSourceScreen: false, + frameBounds: makeBounds(0, 0, 400, 400), + }, + explicitPoint: null, + }), + ); + expect(result.nextCascadeCount).toBe(1); + }); + + it("does NOT increment for explicit-point (mouse) pastes", () => { + const result = resolvePastePlacement( + baseInput({ explicitPoint: { x: 10, y: 10 }, cascadeCount: 3 }), + ); + expect(result.nextCascadeCount).toBe(3); + }); + + it("does NOT increment for non-keyboard origins (assets/figma/external)", () => { + for (const origin of ["assets", "figma", "external"] as const) { + const result = resolvePastePlacement( + baseInput({ + origin, + entries: [makeEntry(0, { sourcePosition: null, kind: "image" })], + cascadeCount: 5, + }), + ); + expect(result.nextCascadeCount).toBe(5); + } + }); +}); + +describe("Screen entries", () => { + it("treats screen-kind entries like layers (they always carry positions)", () => { + const result = resolvePastePlacement( + baseInput({ + origin: "cross-screen", + target: { + fileId: "screen-b", + isSourceScreen: false, + frameBounds: makeBounds(500, 500, 400, 400), + }, + entries: [ + makeEntry(0, { kind: "screen", sourcePosition: { x: 100, y: 100 } }), + makeEntry(1, { kind: "screen", sourcePosition: { x: 300, y: 100 } }), + ], + viewport: { + visibleCanvasBounds: makeBounds(400, 400, 2000, 2000), + zoom: 100, + }, + }), + ); + const a = pos(result.placements[0]); + const b = pos(result.placements[1]); + expect(a).toEqual({ x: 500, y: 500 }); + // Screen spacing preserved in the destination frame. + expect(b.x - a.x).toBe(200); + }); +}); diff --git a/templates/design/app/pages/design-editor/paste-placement.ts b/templates/design/app/pages/design-editor/paste-placement.ts new file mode 100644 index 0000000000..32cdb9a498 --- /dev/null +++ b/templates/design/app/pages/design-editor/paste-placement.ts @@ -0,0 +1,285 @@ +import type { FrameBounds } from "@shared/canvas-math"; + +/** + * Pure decision module for context-aware paste placement (Figma-parity). + * + * Given the pasted entries, the destination screen/frame, the current + * selection, the visible viewport, an optional explicit "Paste here" point, + * and the paste origin, it decides where each entry lands: either as an + * in-flow sibling after the selection ("flow-after") or at an absolute canvas + * position. It also reports the next cascade counter and whether the caller + * should recenter the camera on the result. + * + * Module boundary: this resolver owns *placement* for `layer`, `screen`, and + * `image` paste entries only. It deliberately does NOT own paste-to-replace, + * duplicate-with-replay, or server-side asset/Figma insertion — those carry + * non-paste semantics (target replacement, motion-track replay, remote import) + * and are handled by their own code paths. Keep this file pure: no DOM, no + * React, no I/O. + */ + +/** Cascade step so repeated keyboard pastes don't stack exactly. */ +export const CASCADE_STEP_PX = 16; +/** Small nudge applied to a same-screen paste (the "+10" in the legacy rule). */ +export const SAME_SCREEN_NUDGE_PX = 10; +/** Fallback viewport center when no viewport bounds are available. */ +const FALLBACK_VIEWPORT_CENTER = { x: 120, y: 120 } as const; +/** Minimum fraction of the pasted group's AABB that must stay visible. */ +const MIN_VISIBLE_FRACTION = 0.5; + +export type PastePlacementEntry = { + index: number; + sourceFileId: string | null; + sourcePosition: { x: number; y: number } | null; // from extractLayerPosition + kind: "layer" | "screen" | "image"; +}; + +export type PastePlacementInput = { + entries: PastePlacementEntry[]; + target: { + fileId: string; + isSourceScreen: boolean; + frameBounds: FrameBounds | null; + }; + selection: { selector: string | null; isFlowContainer: boolean } | null; + viewport: { visibleCanvasBounds: FrameBounds; zoom: number } | null; + explicitPoint: { x: number; y: number } | null; // "Paste here" + origin: "same-screen" | "cross-screen" | "assets" | "figma" | "external"; + cascadeCount: number; +}; + +export type PastePlacement = + | { mode: "flow-after"; anchorSelector: string } + | { mode: "absolute"; position: { x: number; y: number } }; + +export type PastePlacementResult = { + placements: PastePlacement[]; // index-aligned with input.entries + nextCascadeCount: number; + ensureVisible: boolean; // caller fires a fitBounds cameraCommand +}; + +type Point = { x: number; y: number }; + +function absolute(position: Point): PastePlacement { + return { mode: "absolute", position }; +} + +function everyEntryPositioned(entries: PastePlacementEntry[]): boolean { + return ( + entries.length > 0 && + entries.every((entry) => entry.sourcePosition !== null) + ); +} + +function everyEntryIsLayer(entries: PastePlacementEntry[]): boolean { + return entries.length > 0 && entries.every((entry) => entry.kind === "layer"); +} + +/** Group's top-left corner across every entry that has a source position. */ +function minSourceCorner(entries: PastePlacementEntry[]): Point { + const positioned = entries + .map((entry) => entry.sourcePosition) + .filter((source): source is Point => source !== null); + if (positioned.length === 0) return { x: 0, y: 0 }; + return { + x: Math.min(...positioned.map((source) => source.x)), + y: Math.min(...positioned.map((source) => source.y)), + }; +} + +function centerOfBounds(bounds: FrameBounds): Point { + return { x: bounds.centerX, y: bounds.centerY }; +} + +/** + * A flow-after paste applies when a selection exists that represents a valid + * in-flow target: either the selection is itself a flow container or it is a + * flow sibling to insert after. Either way we need a concrete anchor selector. + */ +function flowAfterApplies( + input: PastePlacementInput, +): input is PastePlacementInput & { + selection: { selector: string; isFlowContainer: boolean }; +} { + const { selection, explicitPoint, entries } = input; + if (explicitPoint !== null) return false; + if (selection === null || selection.selector === null) return false; + if (!everyEntryIsLayer(entries)) return false; + return selection.isFlowContainer || selection.selector !== null; +} + +/** + * Fraction of the group's axis-aligned bounding box (built from the placement + * points) that overlaps the visible viewport. A degenerate group (single point + * or zero-area line) falls back to a point-in-bounds test. + */ +function groupVisibleFraction(positions: Point[], bounds: FrameBounds): number { + const xs = positions.map((point) => point.x); + const ys = positions.map((point) => point.y); + const left = Math.min(...xs); + const right = Math.max(...xs); + const top = Math.min(...ys); + const bottom = Math.max(...ys); + const groupArea = (right - left) * (bottom - top); + + const overlapW = Math.max( + 0, + Math.min(right, bounds.right) - Math.max(left, bounds.left), + ); + const overlapH = Math.max( + 0, + Math.min(bottom, bounds.bottom) - Math.max(top, bounds.top), + ); + const overlapArea = overlapW * overlapH; + + if (groupArea <= 0) { + const cx = (left + right) / 2; + const cy = (top + bottom) / 2; + const inside = + cx >= bounds.left && + cx <= bounds.right && + cy >= bounds.top && + cy <= bounds.bottom; + return inside ? 1 : 0; + } + return overlapArea / groupArea; +} + +/** Center of the group's AABB across placement points. */ +function groupCenter(positions: Point[]): Point { + const xs = positions.map((point) => point.x); + const ys = positions.map((point) => point.y); + return { + x: (Math.min(...xs) + Math.max(...xs)) / 2, + y: (Math.min(...ys) + Math.max(...ys)) / 2, + }; +} + +export function resolvePastePlacement( + input: PastePlacementInput, +): PastePlacementResult { + const { entries, target, viewport, explicitPoint, origin, cascadeCount } = + input; + + // Rule 7: only keyboard pastes (same-screen / cross-screen with no explicit + // point) advance the cascade counter. This is independent of which placement + // branch fires below. + const isKeyboardPaste = + (origin === "same-screen" || origin === "cross-screen") && + explicitPoint === null; + const nextCascadeCount = isKeyboardPaste ? cascadeCount + 1 : cascadeCount; + // Cascade offset is applied to the group as a unit, never per-index, so + // multi-selection spacing survives repeated pastes. + const cascadeOffset = cascadeCount * CASCADE_STEP_PX; + + if (entries.length === 0) { + return { placements: [], nextCascadeCount, ensureVisible: false }; + } + + // Rule 5: explicit "Paste here". Group top-left lands at the point, relative + // offsets preserved; a group with no source positions staggers by index. + if (explicitPoint !== null) { + const minSource = minSourceCorner(entries); + const positioned = everyEntryPositioned(entries); + const placements = entries.map((entry, index): PastePlacement => { + if (positioned && entry.sourcePosition) { + return absolute({ + x: explicitPoint.x + (entry.sourcePosition.x - minSource.x), + y: explicitPoint.y + (entry.sourcePosition.y - minSource.y), + }); + } + return absolute({ + x: explicitPoint.x + index * CASCADE_STEP_PX, + y: explicitPoint.y + index * CASCADE_STEP_PX, + }); + }); + return { placements, nextCascadeCount, ensureVisible: false }; + } + + // Rule 1: flow-after. Every clone becomes an in-flow sibling after the + // selection; no absolute position is emitted. + if (flowAfterApplies(input)) { + const anchorSelector = input.selection.selector; + const placements = entries.map( + (): PastePlacement => ({ mode: "flow-after", anchorSelector }), + ); + return { placements, nextCascadeCount, ensureVisible: false }; + } + + // Rule 2: same container, no explicit point. Reuse each entry's own source + // coords plus a uniform nudge and cascade offset — already Figma-correct. + if (origin === "same-screen" && everyEntryPositioned(entries)) { + const placements = entries.map((entry): PastePlacement => { + const source = entry.sourcePosition as Point; + return absolute({ + x: source.x + SAME_SCREEN_NUDGE_PX + cascadeOffset, + y: source.y + SAME_SCREEN_NUDGE_PX + cascadeOffset, + }); + }); + return { placements, nextCascadeCount, ensureVisible: false }; + } + + // Rule 3: different compatible frame. Preserve the group's relative layout by + // anchoring the group's top-left to the destination frame origin; internal + // spacing is untouched. Rule 4's visibility check runs on the whole group. + if ( + origin === "cross-screen" && + target.frameBounds !== null && + everyEntryPositioned(entries) + ) { + const frameOrigin = { + x: target.frameBounds.left, + y: target.frameBounds.top, + }; + const minSource = minSourceCorner(entries); + let positions = entries.map((entry): Point => { + const source = entry.sourcePosition as Point; + return { + x: frameOrigin.x + (source.x - minSource.x) + cascadeOffset, + y: frameOrigin.y + (source.y - minSource.y) + cascadeOffset, + }; + }); + + // Rule 4: if the group lands substantially outside the visible viewport + // (<50% of its AABB visible), translate the WHOLE group by one vector so + // its center matches the viewport center. One translation, never per-index. + let ensureVisible = false; + if (viewport !== null) { + const fraction = groupVisibleFraction( + positions, + viewport.visibleCanvasBounds, + ); + if (fraction < MIN_VISIBLE_FRACTION) { + const target2 = centerOfBounds(viewport.visibleCanvasBounds); + const current = groupCenter(positions); + const delta = { x: target2.x - current.x, y: target2.y - current.y }; + positions = positions.map((point) => ({ + x: point.x + delta.x, + y: point.y + delta.y, + })); + ensureVisible = true; + } + } + + return { + placements: positions.map(absolute), + nextCascadeCount, + ensureVisible, + }; + } + + // Rule 6: last resort — no explicit point and no usable source positions. + // Stagger from the viewport center by index and ask the caller to recenter. + const center = + viewport !== null + ? centerOfBounds(viewport.visibleCanvasBounds) + : FALLBACK_VIEWPORT_CENTER; + const placements = entries.map( + (_, index): PastePlacement => + absolute({ + x: center.x + index * CASCADE_STEP_PX, + y: center.y + index * CASCADE_STEP_PX, + }), + ); + return { placements, nextCascadeCount, ensureVisible: true }; +} diff --git a/templates/design/app/pages/design-editor/selection-affordance-placement.test.ts b/templates/design/app/pages/design-editor/selection-affordance-placement.test.ts new file mode 100644 index 0000000000..9108e0b2b4 --- /dev/null +++ b/templates/design/app/pages/design-editor/selection-affordance-placement.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "vitest"; + +import { + placeAffordance, + type AffordancePlacement, + type Rect, +} from "./selection-affordance-placement"; + +function rect(left: number, top: number, width: number, height: number): Rect { + return { + left, + top, + width, + height, + right: left + width, + bottom: top + height, + }; +} + +function isFullyInside( + placement: AffordancePlacement, + viewport: { width: number; height: number }, + size: { width: number; height: number }, +): boolean { + return ( + placement.left >= 0 && + placement.top >= 0 && + placement.left + size.width <= viewport.width && + placement.top + size.height <= viewport.height + ); +} + +describe("placeAffordance", () => { + const size = { width: 32, height: 32 }; + + it("places at the selection's upper-right outer corner when there is room", () => { + const viewport = { width: 1000, height: 800 }; + const placement = placeAffordance(rect(200, 300, 120, 80), viewport, size); + expect(placement).toEqual({ left: 328, top: 300, corner: "top-right" }); + expect(isFullyInside(placement, viewport, size)).toBe(true); + }); + + it("flips to the left of the selection when the right edge would overflow", () => { + const viewport = { width: 400, height: 800 }; + const placement = placeAffordance(rect(300, 200, 90, 60), viewport, size); + expect(placement.corner).toBe("top-left"); + expect(placement.left).toBe(260); + expect(placement.top).toBe(200); + expect(isFullyInside(placement, viewport, size)).toBe(true); + }); + + it("clamps down and flips to a bottom corner when the selection is above the top edge", () => { + const viewport = { width: 1000, height: 800 }; + const placement = placeAffordance(rect(200, -40, 120, 80), viewport, size); + expect(placement.top).toBe(0); + expect(placement.corner).toBe("bottom-right"); + expect(isFullyInside(placement, viewport, size)).toBe(true); + }); + + it("clamps the top so the box stays inside when the selection is near the bottom", () => { + const viewport = { width: 1000, height: 400 }; + const placement = placeAffordance(rect(200, 390, 120, 80), viewport, size); + expect(placement.top).toBe(368); + expect(isFullyInside(placement, viewport, size)).toBe(true); + }); + + it("stays fully inside a tiny viewport that forces both flips", () => { + const viewport = { width: 48, height: 48 }; + const smallSize = { width: 40, height: 40 }; + const placement = placeAffordance( + rect(6, -10, 36, 20), + viewport, + smallSize, + ); + expect(isFullyInside(placement, viewport, smallSize)).toBe(true); + expect(placement.corner).toBe("bottom-left"); + }); +}); diff --git a/templates/design/app/pages/design-editor/selection-affordance-placement.ts b/templates/design/app/pages/design-editor/selection-affordance-placement.ts new file mode 100644 index 0000000000..f4b7a8dbc1 --- /dev/null +++ b/templates/design/app/pages/design-editor/selection-affordance-placement.ts @@ -0,0 +1,62 @@ +export interface Rect { + left: number; + top: number; + right: number; + bottom: number; + width: number; + height: number; +} + +export interface AffordancePlacement { + left: number; + top: number; + corner: "top-right" | "top-left" | "bottom-right" | "bottom-left"; +} + +const DEFAULT_GAP = 8; + +function clamp(value: number, min: number, max: number): number { + if (max < min) return min; + return Math.min(max, Math.max(min, value)); +} + +/** + * Place a small floating affordance (chip/button of `affordanceSize`) just + * outside a selection rect, defaulting to the selection's upper-right OUTER + * corner and flipping/clamping so the whole box stays inside `viewport`. Pure: + * no DOM, no timers. + * + * Order of decisions: + * 1. Try upper-right: left = anchor.right + gap, top = anchor.top. + * 2. If that overflows the right edge, flip to the left of the selection: + * left = anchor.left - gap - width; horizontal corner becomes "left". + * 3. Clamp top into [0, viewport.height - height]. If the clamp pushed the + * box below the anchor's top, the vertical corner becomes "bottom". + * 4. Last resort: clamp left into [0, viewport.width - width] so the box is + * always fully inside the viewport. `corner` reflects the side of the + * selection the box ended up on. + */ +export function placeAffordance( + anchorRect: Rect, + viewport: { width: number; height: number }, + affordanceSize: { width: number; height: number }, + gap: number = DEFAULT_GAP, +): AffordancePlacement { + const maxLeft = viewport.width - affordanceSize.width; + const maxTop = viewport.height - affordanceSize.height; + + let horizontal: "right" | "left" = "right"; + let rawLeft = anchorRect.right + gap; + if (rawLeft + affordanceSize.width > viewport.width) { + horizontal = "left"; + rawLeft = anchorRect.left - gap - affordanceSize.width; + } + + const rawTop = anchorRect.top; + const top = clamp(rawTop, 0, maxTop); + const vertical: "top" | "bottom" = top > anchorRect.top ? "bottom" : "top"; + + const left = clamp(rawLeft, 0, maxLeft); + + return { left, top, corner: `${vertical}-${horizontal}` }; +} diff --git a/templates/design/app/pages/design-editor/selection-history.test.ts b/templates/design/app/pages/design-editor/selection-history.test.ts new file mode 100644 index 0000000000..4535aa1645 --- /dev/null +++ b/templates/design/app/pages/design-editor/selection-history.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, it } from "vitest"; + +import type { GeometryHistorySelection } from "./history"; +import { + SELECTION_HISTORY_COALESCE_WINDOW_MS, + selectionSnapshotsEqual, + shouldRecordSelectionHistory, + type SelectionHistoryEntry, +} from "./history"; + +function sel( + overview: string[], + layers: string[], + activeFileId: string | null, +): GeometryHistorySelection { + return { + overviewSelectedScreenIds: overview, + selectedLayerIds: layers, + activeFileId, + }; +} + +const A = sel(["s1"], [], "s1"); +const B = sel(["s2"], [], "s2"); + +describe("selectionSnapshotsEqual", () => { + it("is true for structurally identical snapshots", () => { + expect( + selectionSnapshotsEqual( + sel(["s1"], ["l1"], "s1"), + sel(["s1"], ["l1"], "s1"), + ), + ).toBe(true); + }); + it("is order-sensitive on ids", () => { + expect( + selectionSnapshotsEqual( + sel(["a", "b"], [], "s1"), + sel(["b", "a"], [], "s1"), + ), + ).toBe(false); + }); + it("distinguishes activeFileId", () => { + expect(selectionSnapshotsEqual(sel([], [], "s1"), sel([], [], "s2"))).toBe( + false, + ); + }); + it("distinguishes layer ids", () => { + expect( + selectionSnapshotsEqual(sel([], ["l1"], "s1"), sel([], ["l2"], "s1")), + ).toBe(false); + }); +}); + +describe("shouldRecordSelectionHistory", () => { + it("skips while a pointer gesture is active (drag temp-selection)", () => { + expect( + shouldRecordSelectionHistory({ + prev: A, + next: B, + lastEntry: null, + now: 1000, + gestureActive: true, + }), + ).toBe("skip"); + }); + + it("skips a no-op selection change", () => { + expect( + shouldRecordSelectionHistory({ + prev: A, + next: sel(["s1"], [], "s1"), + lastEntry: null, + now: 1000, + gestureActive: false, + }), + ).toBe("skip"); + }); + + it("records a fresh entry when there is no prior entry", () => { + expect( + shouldRecordSelectionHistory({ + prev: A, + next: B, + lastEntry: null, + now: 1000, + gestureActive: false, + }), + ).toBe("record"); + }); + + it("coalesces a change within the 800ms window of the last entry", () => { + const lastEntry: SelectionHistoryEntry = { before: A, after: B, at: 1000 }; + expect( + shouldRecordSelectionHistory({ + prev: B, + next: sel(["s3"], [], "s3"), + lastEntry, + now: 1000 + SELECTION_HISTORY_COALESCE_WINDOW_MS - 1, + gestureActive: false, + }), + ).toBe("coalesce"); + }); + + it("records a fresh entry once the coalesce window has elapsed", () => { + const lastEntry: SelectionHistoryEntry = { before: A, after: B, at: 1000 }; + expect( + shouldRecordSelectionHistory({ + prev: B, + next: sel(["s3"], [], "s3"), + lastEntry, + now: 1000 + SELECTION_HISTORY_COALESCE_WINDOW_MS + 1, + gestureActive: false, + }), + ).toBe("record"); + }); + + it("still skips a no-op even inside the coalesce window", () => { + const lastEntry: SelectionHistoryEntry = { before: A, after: B, at: 1000 }; + expect( + shouldRecordSelectionHistory({ + prev: B, + next: sel(["s2"], [], "s2"), + lastEntry, + now: 1100, + gestureActive: false, + }), + ).toBe("skip"); + }); + + it("honors a custom coalesce window", () => { + const lastEntry: SelectionHistoryEntry = { before: A, after: B, at: 0 }; + expect( + shouldRecordSelectionHistory({ + prev: B, + next: A, + lastEntry, + now: 50, + gestureActive: false, + coalesceWindowMs: 100, + }), + ).toBe("coalesce"); + expect( + shouldRecordSelectionHistory({ + prev: B, + next: A, + lastEntry, + now: 150, + gestureActive: false, + coalesceWindowMs: 100, + }), + ).toBe("record"); + }); +}); diff --git a/templates/design/app/pages/design-editor/selection-topology.test.ts b/templates/design/app/pages/design-editor/selection-topology.test.ts new file mode 100644 index 0000000000..85246cb137 --- /dev/null +++ b/templates/design/app/pages/design-editor/selection-topology.test.ts @@ -0,0 +1,297 @@ +import type { CodeLayerNode, LayoutContext } from "@shared/code-layer"; +import { describe, expect, it } from "vitest"; + +import { + nearestMeaningfulParentId, + partitionSelectionForAlignment, +} from "./selection-topology"; + +interface NodeOpts { + parentId?: string; + children?: string[]; + isFlexContainer?: boolean; + isGridContainer?: boolean; +} + +function makeNode(id: string, opts: NodeOpts = {}): CodeLayerNode { + const layout: LayoutContext = { + parentId: opts.parentId, + siblingIndex: 0, + nthOfType: 0, + isFlexContainer: opts.isFlexContainer ?? false, + isGridContainer: opts.isGridContainer ?? false, + }; + return { + id, + tag: "div", + layerName: id, + layerNameSource: "tag", + selector: `#${id}`, + selectors: [`#${id}`], + path: id, + attributes: {}, + dataAttributes: {}, + classes: [], + textSnippet: null, + style: {}, + styleTokens: [], + parentId: opts.parentId, + children: opts.children ?? [], + layout, + capabilities: [], + confidence: 1, + source: null, + }; +} + +function mapOf(nodes: CodeLayerNode[]): Map { + return new Map(nodes.map((node) => [node.id, node])); +} + +/** + * Golden fixture: a root container holding three cards, each card holding a + * title + body. Used across the sibling / titles / mixed-ancestry cases. + */ +function nestedCardsFixture(): CodeLayerNode[] { + return [ + makeNode("root", { children: ["cardA", "cardB", "cardC"] }), + makeNode("cardA", { parentId: "root", children: ["titleA", "bodyA"] }), + makeNode("titleA", { parentId: "cardA" }), + makeNode("bodyA", { parentId: "cardA" }), + makeNode("cardB", { parentId: "root", children: ["titleB", "bodyB"] }), + makeNode("titleB", { parentId: "cardB" }), + makeNode("bodyB", { parentId: "cardB" }), + makeNode("cardC", { parentId: "root", children: ["titleC", "bodyC"] }), + makeNode("titleC", { parentId: "cardC" }), + makeNode("bodyC", { parentId: "cardC" }), + ]; +} + +describe("nearestMeaningfulParentId", () => { + it("returns null when the node has no parent", () => { + const nodes = mapOf([makeNode("root", { children: [] })]); + expect(nearestMeaningfulParentId(nodes, "root")).toBeNull(); + }); + + it("returns the raw parent when it is multi-child", () => { + const nodes = mapOf(nestedCardsFixture()); + expect(nearestMeaningfulParentId(nodes, "titleA")).toBe("cardA"); + }); + + it("keeps a flex container parent (not collapsed even with one child)", () => { + const nodes = mapOf([ + makeNode("root", { children: ["flex"] }), + makeNode("flex", { + parentId: "root", + children: ["only"], + isFlexContainer: true, + }), + makeNode("only", { parentId: "flex" }), + ]); + expect(nearestMeaningfulParentId(nodes, "only")).toBe("flex"); + }); + + it("keeps a grid container parent", () => { + const nodes = mapOf([ + makeNode("root", { children: ["grid"] }), + makeNode("grid", { + parentId: "root", + children: ["only"], + isGridContainer: true, + }), + makeNode("only", { parentId: "grid" }), + ]); + expect(nearestMeaningfulParentId(nodes, "only")).toBe("grid"); + }); + + it("collapses single-child pass-through wrappers up to the real parent", () => { + const nodes = mapOf([ + makeNode("real", { children: ["wrapper1", "wrapper2"] }), + makeNode("wrapper1", { parentId: "real", children: ["node1"] }), + makeNode("node1", { parentId: "wrapper1" }), + makeNode("wrapper2", { parentId: "real", children: ["node2"] }), + makeNode("node2", { parentId: "wrapper2" }), + ]); + expect(nearestMeaningfulParentId(nodes, "node1")).toBe("real"); + expect(nearestMeaningfulParentId(nodes, "node2")).toBe("real"); + }); + + it("collapses a chain of stacked pass-through wrappers", () => { + const nodes = mapOf([ + makeNode("real", { children: ["w1", "sibling"] }), + makeNode("sibling", { parentId: "real" }), + makeNode("w1", { parentId: "real", children: ["w2"] }), + makeNode("w2", { parentId: "w1", children: ["leaf"] }), + makeNode("leaf", { parentId: "w2" }), + ]); + expect(nearestMeaningfulParentId(nodes, "leaf")).toBe("real"); + }); + + it("returns null when pass-through wrappers climb off the top", () => { + const nodes = mapOf([ + makeNode("wrapper", { children: ["leaf"] }), + makeNode("leaf", { parentId: "wrapper" }), + ]); + expect(nearestMeaningfulParentId(nodes, "leaf")).toBeNull(); + }); + + it("returns null for an unknown node id", () => { + const nodes = mapOf(nestedCardsFixture()); + expect(nearestMeaningfulParentId(nodes, "ghost")).toBeNull(); + }); + + it("treats an unresolvable parent id as an opaque boundary", () => { + const nodes = mapOf([makeNode("child", { parentId: "detached" })]); + expect(nearestMeaningfulParentId(nodes, "child")).toBe("detached"); + }); +}); + +describe("partitionSelectionForAlignment — sibling cards", () => { + it("groups three sibling cards under their shared parent", () => { + const groups = partitionSelectionForAlignment(nestedCardsFixture(), [ + "cardA", + "cardB", + "cardC", + ]); + expect(groups).toHaveLength(1); + expect(groups[0]!.parentId).toBe("root"); + expect(groups[0]!.nodeIds).toEqual(["cardA", "cardB", "cardC"]); + }); +}); + +describe("partitionSelectionForAlignment — titles in different cards", () => { + it("splits three titles into three single-member groups by parent", () => { + const groups = partitionSelectionForAlignment(nestedCardsFixture(), [ + "titleA", + "titleB", + "titleC", + ]); + expect(groups).toHaveLength(3); + expect(groups.map((g) => g.parentId)).toEqual(["cardA", "cardB", "cardC"]); + expect(groups.map((g) => g.nodeIds)).toEqual([ + ["titleA"], + ["titleB"], + ["titleC"], + ]); + }); +}); + +describe("partitionSelectionForAlignment — mixed ancestry drops descendant", () => { + it("drops a child whose ancestor is also selected", () => { + const groups = partitionSelectionForAlignment(nestedCardsFixture(), [ + "cardA", + "titleA", + ]); + expect(groups).toHaveLength(1); + expect(groups[0]!.parentId).toBe("root"); + expect(groups[0]!.nodeIds).toEqual(["cardA"]); + }); + + it("drops multiple deep descendants of one selected ancestor", () => { + const groups = partitionSelectionForAlignment(nestedCardsFixture(), [ + "cardA", + "titleA", + "bodyA", + "cardB", + ]); + expect(groups).toHaveLength(1); + expect(groups[0]!.parentId).toBe("root"); + expect(groups[0]!.nodeIds).toEqual(["cardA", "cardB"]); + }); +}); + +describe("partitionSelectionForAlignment — pass-through wrapper collapse", () => { + it("groups nodes under distinct wrappers by their shared real parent", () => { + const nodes = [ + makeNode("real", { children: ["wrapper1", "wrapper2"] }), + makeNode("wrapper1", { parentId: "real", children: ["node1"] }), + makeNode("node1", { parentId: "wrapper1" }), + makeNode("wrapper2", { parentId: "real", children: ["node2"] }), + makeNode("node2", { parentId: "wrapper2" }), + ]; + const groups = partitionSelectionForAlignment(nodes, ["node1", "node2"]); + expect(groups).toHaveLength(1); + expect(groups[0]!.parentId).toBe("real"); + expect(groups[0]!.nodeIds).toEqual(["node1", "node2"]); + }); +}); + +describe("partitionSelectionForAlignment — flex container parent", () => { + it("groups flow children by their flex parent id (not collapsed)", () => { + const nodes = [ + makeNode("root", { children: ["flex"] }), + makeNode("flex", { + parentId: "root", + children: ["fc1", "fc2"], + isFlexContainer: true, + }), + makeNode("fc1", { parentId: "flex" }), + makeNode("fc2", { parentId: "flex" }), + ]; + const groups = partitionSelectionForAlignment(nodes, ["fc1", "fc2"]); + expect(groups).toHaveLength(1); + expect(groups[0]!.parentId).toBe("flex"); + expect(groups[0]!.nodeIds).toEqual(["fc1", "fc2"]); + }); +}); + +describe("partitionSelectionForAlignment — top-level nodes", () => { + it("groups top-level nodes under the null parent key", () => { + const nodes = [ + makeNode("screenA", { children: [] }), + makeNode("screenB", { children: [] }), + ]; + const groups = partitionSelectionForAlignment(nodes, [ + "screenA", + "screenB", + ]); + expect(groups).toHaveLength(1); + expect(groups[0]!.parentId).toBeNull(); + expect(groups[0]!.nodeIds).toEqual(["screenA", "screenB"]); + }); +}); + +describe("partitionSelectionForAlignment — golden mixed scene", () => { + it("keeps first-appearance ordering across parents and within a group", () => { + // titleC appears first (key cardC), then two siblings that both key to root + // preserving their input order. + const groups = partitionSelectionForAlignment(nestedCardsFixture(), [ + "titleC", + "cardA", + "cardB", + ]); + expect(groups.map((g) => g.parentId)).toEqual(["cardC", "root"]); + expect(groups.map((g) => g.nodeIds)).toEqual([ + ["titleC"], + ["cardA", "cardB"], + ]); + }); +}); + +describe("partitionSelectionForAlignment — degenerate inputs", () => { + it("ignores unknown selected ids", () => { + const groups = partitionSelectionForAlignment(nestedCardsFixture(), [ + "cardA", + "ghost", + ]); + expect(groups).toHaveLength(1); + expect(groups[0]!.parentId).toBe("root"); + expect(groups[0]!.nodeIds).toEqual(["cardA"]); + }); + + it("returns an empty array for an empty selection", () => { + expect(partitionSelectionForAlignment(nestedCardsFixture(), [])).toEqual( + [], + ); + }); + + it("de-duplicates repeated selected ids", () => { + const groups = partitionSelectionForAlignment(nestedCardsFixture(), [ + "cardA", + "cardA", + "cardB", + ]); + expect(groups).toHaveLength(1); + expect(groups[0]!.nodeIds).toEqual(["cardA", "cardB"]); + }); +}); diff --git a/templates/design/app/pages/design-editor/selection-topology.ts b/templates/design/app/pages/design-editor/selection-topology.ts new file mode 100644 index 0000000000..19e495cc53 --- /dev/null +++ b/templates/design/app/pages/design-editor/selection-topology.ts @@ -0,0 +1,81 @@ +import type { CodeLayerNode } from "@shared/code-layer"; + +export interface AlignmentGroup { + parentId: string | null; + nodeIds: string[]; +} + +/** + * Collapse single-child pass-through wrappers: walk UP from `nodeId` while the + * parent has exactly one child and no layout role (`!isFlexContainer && + * !isGridContainer`), returning the id of the nearest meaningful parent (or the + * original parentId if none). Returns null when there is no parent or the walk + * climbs off the top. + */ +export function nearestMeaningfulParentId( + nodesById: Map, + nodeId: string, +): string | null { + const node = nodesById.get(nodeId); + if (!node) return null; + + let currentId: string | undefined = node.parentId; + while (currentId !== undefined) { + const parent = nodesById.get(currentId); + // An unresolvable parent is an opaque boundary — it can't be collapsed, so + // treat its id as the nearest meaningful parent. + if (!parent) return currentId; + + const isPassThroughWrapper = + parent.children.length === 1 && + !parent.layout.isFlexContainer && + !parent.layout.isGridContainer; + if (!isPassThroughWrapper) return currentId; + + currentId = parent.parentId; + } + return null; +} + +/** Partition a multi-selection into the smallest valid alignment groups. */ +export function partitionSelectionForAlignment( + nodes: CodeLayerNode[], + selectedIds: string[], +): AlignmentGroup[] { + const nodesById = new Map( + nodes.map((node) => [node.id, node]), + ); + + const selectedExisting: string[] = []; + const selectedSet = new Set(); + for (const id of selectedIds) { + if (!nodesById.has(id) || selectedSet.has(id)) continue; + selectedSet.add(id); + selectedExisting.push(id); + } + + const hasSelectedAncestor = (id: string): boolean => { + let currentId = nodesById.get(id)?.parentId; + while (currentId !== undefined) { + if (selectedSet.has(currentId)) return true; + currentId = nodesById.get(currentId)?.parentId; + } + return false; + }; + + const groups = new Map(); + for (const id of selectedExisting) { + if (hasSelectedAncestor(id)) continue; + const key = nearestMeaningfulParentId(nodesById, id); + const members = groups.get(key); + if (members) members.push(id); + else groups.set(key, [id]); + } + + const result: AlignmentGroup[] = []; + for (const [parentId, nodeIds] of groups) { + if (nodeIds.length === 0) continue; + result.push({ parentId, nodeIds }); + } + return result; +} diff --git a/templates/design/server/db/schema.ts b/templates/design/server/db/schema.ts index 88265aa665..3223992aaf 100644 --- a/templates/design/server/db/schema.ts +++ b/templates/design/server/db/schema.ts @@ -109,6 +109,15 @@ export const designVersions = table("design_versions", { designId: text("design_id").notNull(), label: text("label"), snapshot: text("snapshot").notNull(), + // Task 5a — attribution + recoverability. All additive/nullable so existing + // rows and the framework's additive-only migration contract are honored. + /** User id that created the checkpoint, or "agent" for agent-run snapshots. */ + createdBy: text("created_by"), + /** Checkpoint kind: manual | pre-agent-run | pre-structural | pre-branch | + * pre-migration | pre-restore. */ + kind: text("kind"), + /** Free-text trigger provenance: originating action name or agent run id. */ + trigger: text("trigger"), createdAt: text("created_at").default(now()), }); diff --git a/templates/design/server/lib/checkpoint-pruning.test.ts b/templates/design/server/lib/checkpoint-pruning.test.ts new file mode 100644 index 0000000000..04e5faec5e --- /dev/null +++ b/templates/design/server/lib/checkpoint-pruning.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from "vitest"; + +import { + DEFAULT_CHECKPOINT_KEEP, + selectCheckpointsToPrune, + type PrunableCheckpointRow, +} from "./checkpoint-pruning"; +import { parseCheckpointSnapshotFiles } from "./design-checkpoint"; + +function row( + id: string, + kind: string | null, + createdAt: string | null, +): PrunableCheckpointRow { + return { id, kind, createdAt }; +} + +describe("selectCheckpointsToPrune", () => { + it("keeps everything when at or under the retention limit", () => { + const rows = [ + row("a", "pre-agent-run", "2026-01-01T00:00:00Z"), + row("b", "pre-agent-run", "2026-01-02T00:00:00Z"), + ]; + expect(selectCheckpointsToPrune(rows, "pre-agent-run", 20)).toEqual([]); + }); + + it("prunes the oldest rows of the kind beyond the newest N", () => { + const rows = [ + row("old", "pre-agent-run", "2026-01-01T00:00:00Z"), + row("mid", "pre-agent-run", "2026-01-02T00:00:00Z"), + row("new", "pre-agent-run", "2026-01-03T00:00:00Z"), + ]; + // keep newest 1 -> the two older ones are pruned + expect(selectCheckpointsToPrune(rows, "pre-agent-run", 1).sort()).toEqual([ + "mid", + "old", + ]); + }); + + it("never prunes rows of a different kind", () => { + const rows = [ + row("manual1", "manual", "2026-01-01T00:00:00Z"), + row("manual2", "manual", "2026-01-02T00:00:00Z"), + row("auto1", "pre-agent-run", "2026-01-01T00:00:00Z"), + row("auto2", "pre-agent-run", "2026-01-02T00:00:00Z"), + ]; + expect(selectCheckpointsToPrune(rows, "pre-agent-run", 1)).toEqual([ + "auto1", + ]); + }); + + it("never prunes null-kind rows", () => { + const rows = [ + row("legacy1", null, "2026-01-01T00:00:00Z"), + row("legacy2", null, "2026-01-02T00:00:00Z"), + ]; + expect(selectCheckpointsToPrune(rows, "pre-agent-run", 0)).toEqual([]); + }); + + it("breaks timestamp ties deterministically by id (keeps higher id)", () => { + const rows = [ + row("a", "pre-agent-run", "2026-01-01T00:00:00Z"), + row("b", "pre-agent-run", "2026-01-01T00:00:00Z"), + row("c", "pre-agent-run", "2026-01-01T00:00:00Z"), + ]; + // keep newest 1 -> "c" survives (highest id), a & b pruned + expect(selectCheckpointsToPrune(rows, "pre-agent-run", 1).sort()).toEqual([ + "a", + "b", + ]); + }); + + it("treats a negative keep as zero (prunes all of the kind)", () => { + const rows = [row("a", "pre-agent-run", "2026-01-01T00:00:00Z")]; + expect(selectCheckpointsToPrune(rows, "pre-agent-run", -5)).toEqual(["a"]); + }); + + it("exposes a sane default retention", () => { + expect(DEFAULT_CHECKPOINT_KEEP).toBeGreaterThan(0); + }); +}); + +describe("parseCheckpointSnapshotFiles", () => { + it("extracts valid file entries from a snapshot", () => { + const snapshot = JSON.stringify({ + designId: "d1", + files: [ + { id: "f1", filename: "a.html", content: "", fileType: "html" }, + { id: "f2", filename: "b.html", content: "" }, + ], + }); + const files = parseCheckpointSnapshotFiles(snapshot); + expect(files.map((f) => f.id)).toEqual(["f1", "f2"]); + expect(files[0].content).toBe(""); + }); + + it("drops malformed entries (missing id/content/filename)", () => { + const snapshot = JSON.stringify({ + files: [ + { id: "ok", filename: "a.html", content: "x" }, + { id: "no-content", filename: "b.html" }, + { filename: "no-id.html", content: "y" }, + null, + "nope", + ], + }); + expect(parseCheckpointSnapshotFiles(snapshot).map((f) => f.id)).toEqual([ + "ok", + ]); + }); + + it("returns [] for invalid or fileless JSON", () => { + expect(parseCheckpointSnapshotFiles("not json")).toEqual([]); + expect(parseCheckpointSnapshotFiles(JSON.stringify({}))).toEqual([]); + expect(parseCheckpointSnapshotFiles(JSON.stringify({ files: 5 }))).toEqual( + [], + ); + }); +}); diff --git a/templates/design/server/lib/checkpoint-pruning.ts b/templates/design/server/lib/checkpoint-pruning.ts new file mode 100644 index 0000000000..861e824c57 --- /dev/null +++ b/templates/design/server/lib/checkpoint-pruning.ts @@ -0,0 +1,41 @@ +/** + * Task 5a — bounded auto-pruning for automatically-created design checkpoints. + * + * `pre-agent-run` and other auto-created checkpoints accumulate one row per + * trigger. We keep only the newest N per design *of that kind* and delete the + * rest. Pruning is a delete of rows the framework created automatically + * (bounded, additive-safe) — never of manual/user snapshots. Pure so the + * selection rule is unit-tested without a database. + */ + +export interface PrunableCheckpointRow { + id: string; + kind: string | null; + createdAt: string | null; +} + +/** The default retention for auto-created `pre-agent-run` checkpoints. */ +export const DEFAULT_CHECKPOINT_KEEP = 20; + +/** + * Returns the ids of checkpoints of `kind` that fall outside the newest + * `keepNewest` (by ISO `createdAt`, newest first). Rows of other kinds and + * rows with a null kind are never selected. Ties break by id descending so the + * result is deterministic when timestamps collide. + */ +export function selectCheckpointsToPrune( + rows: readonly PrunableCheckpointRow[], + kind: string, + keepNewest: number = DEFAULT_CHECKPOINT_KEEP, +): string[] { + if (keepNewest < 0) keepNewest = 0; + const ofKind = rows + .filter((row) => row.kind === kind) + .sort((a, b) => { + const at = a.createdAt ?? ""; + const bt = b.createdAt ?? ""; + if (at !== bt) return bt.localeCompare(at); + return b.id.localeCompare(a.id); + }); + return ofKind.slice(keepNewest).map((row) => row.id); +} diff --git a/templates/design/server/lib/design-checkpoint.ts b/templates/design/server/lib/design-checkpoint.ts new file mode 100644 index 0000000000..cd97d23ec7 --- /dev/null +++ b/templates/design/server/lib/design-checkpoint.ts @@ -0,0 +1,116 @@ +/** + * Task 5 — shared design-checkpoint helpers used by create-design-checkpoint + * and restore-design-version. A checkpoint is a `design_versions` row whose + * `snapshot` captures the full current file set; restore writes those contents + * back through the normal file path (SQL + collab re-seed) after first + * checkpointing the pre-restore state so restore is itself undoable. + */ + +import { eq, inArray } from "drizzle-orm"; +import { nanoid } from "nanoid"; + +import { getDb, schema } from "../db/index.js"; +import { + DEFAULT_CHECKPOINT_KEEP, + selectCheckpointsToPrune, +} from "./checkpoint-pruning.js"; + +export interface CheckpointSnapshotFile { + id: string; + filename: string; + content: string; + fileType?: string; + bytes?: number; +} + +/** + * Snapshot every file of a design into a new `design_versions` row. When + * `prune` is set, auto-created checkpoints of the same `kind` beyond the newest + * N are deleted (bounded auto-pruning — see checkpoint-pruning.ts). + */ +export async function writeDesignCheckpoint(opts: { + designId: string; + kind: string; + createdBy: string; + trigger?: string | null; + label?: string | null; + prune?: boolean; +}): Promise<{ versionId: string; filesCaptured: number; pruned: number }> { + const db = getDb(); + const files = await db + .select({ + id: schema.designFiles.id, + filename: schema.designFiles.filename, + content: schema.designFiles.content, + fileType: schema.designFiles.fileType, + }) + .from(schema.designFiles) + .where(eq(schema.designFiles.designId, opts.designId)); + const now = new Date().toISOString(); + const versionId = `dv_${nanoid(12)}`; + const snapshot = JSON.stringify({ + designId: opts.designId, + snapshotKind: opts.kind, + files: files.map((file) => ({ + id: file.id, + filename: file.filename, + content: file.content, + fileType: file.fileType ?? "html", + bytes: file.content?.length ?? 0, + })), + capturedAt: now, + }); + await db.insert(schema.designVersions).values({ + id: versionId, + designId: opts.designId, + label: opts.label ?? `${opts.kind} checkpoint — ${now}`, + snapshot, + createdBy: opts.createdBy, + kind: opts.kind, + trigger: opts.trigger ?? null, + createdAt: now, + }); + let pruned = 0; + if (opts.prune) { + const rows = await db + .select({ + id: schema.designVersions.id, + kind: schema.designVersions.kind, + createdAt: schema.designVersions.createdAt, + }) + .from(schema.designVersions) + .where(eq(schema.designVersions.designId, opts.designId)); + const toPrune = selectCheckpointsToPrune( + rows, + opts.kind, + DEFAULT_CHECKPOINT_KEEP, + ); + if (toPrune.length > 0) { + await db + .delete(schema.designVersions) + .where(inArray(schema.designVersions.id, toPrune)); + pruned = toPrune.length; + } + } + return { versionId, filesCaptured: files.length, pruned }; +} + +/** Pure: extract the restorable file entries from a snapshot JSON string. */ +export function parseCheckpointSnapshotFiles( + snapshot: string, +): CheckpointSnapshotFile[] { + try { + const parsed = JSON.parse(snapshot) as { files?: unknown }; + if (!Array.isArray(parsed.files)) return []; + return parsed.files.filter( + (file): file is CheckpointSnapshotFile => + !!file && + typeof file === "object" && + typeof (file as { id?: unknown }).id === "string" && + typeof (file as { content?: unknown }).content === "string" && + typeof (file as { filename?: unknown }).filename === "string", + ); + } catch { + return []; + } +} diff --git a/templates/design/server/plugins/db.ts b/templates/design/server/plugins/db.ts index cfefaa0fc8..ac145c78e3 100644 --- a/templates/design/server/plugins/db.ts +++ b/templates/design/server/plugins/db.ts @@ -384,6 +384,14 @@ CREATE INDEX IF NOT EXISTS design_templates_owner_org_updated_idx ON design_temp CREATE INDEX IF NOT EXISTS design_template_shares_resource_principal_idx ON design_template_shares (resource_id, principal_type, principal_id); CREATE INDEX IF NOT EXISTS design_template_files_template_idx ON design_template_files (template_id, updated_at)`, }, + { + version: 24, + name: "design-version-attribution", + // Task 5a — attributable, recoverable checkpoints. Additive only. + sql: `ALTER TABLE design_versions ADD COLUMN IF NOT EXISTS created_by TEXT; +ALTER TABLE design_versions ADD COLUMN IF NOT EXISTS kind TEXT; +ALTER TABLE design_versions ADD COLUMN IF NOT EXISTS trigger TEXT`, + }, ], { table: "design_migrations" }, ); From bfa8f13a4cff861afafe562925844cc106829449 Mon Sep 17 00:00:00 2001 From: sidmohanty11 Date: Fri, 24 Jul 2026 16:16:09 +0200 Subject: [PATCH 3/4] changes --- scripts/i18n-raw-literal-baseline.txt | 1 + .../bridge/editor-chrome.generated.ts | 3 +- .../actions/create-design-checkpoint.ts | 11 +- .../design/actions/restore-design-version.ts | 13 +-- .../design/CanvasAgentStateBadge.tsx | 6 +- .../app/components/design/DesignCanvas.tsx | 15 +-- .../design/SelectionAgentAffordance.tsx | 32 ++++-- .../design/bridge/editor-chrome.bridge.ts | 73 ++++--------- .../design/editor-chrome-bridge.snap.test.ts | 8 +- .../design/app/hooks/useDesignHotkeys.ts | 7 +- templates/design/app/pages/DesignEditor.tsx | 103 ++++++------------ .../pages/design-editor/canvas-agent-state.ts | 14 +-- .../app/pages/design-editor/editor-state.ts | 5 +- .../design/app/pages/design-editor/history.ts | 13 +-- .../pages/design-editor/paste-placement.ts | 64 ++++------- .../selection-affordance-placement.ts | 19 +--- .../pages/design-editor/selection-topology.ts | 13 +-- templates/design/server/db/schema.ts | 7 +- .../design/server/lib/checkpoint-pruning.ts | 19 +--- .../design/server/lib/design-checkpoint.ts | 14 +-- templates/design/server/plugins/db.ts | 2 +- 21 files changed, 156 insertions(+), 286 deletions(-) diff --git a/scripts/i18n-raw-literal-baseline.txt b/scripts/i18n-raw-literal-baseline.txt index ffdcaad6a3..5289d04249 100644 --- a/scripts/i18n-raw-literal-baseline.txt +++ b/scripts/i18n-raw-literal-baseline.txt @@ -24,6 +24,7 @@ templates/design/app/components/design/code-workbench/workspace/types.ts|` or `l templates/design/app/components/design/edit-panel/effects-properties.tsx|, elementKey: string, nextLayers: readonly Pick templates/design/app/components/design/multi-screen/screen-content-cache.ts|, existingScreenIds: ReadonlySet templates/design/app/components/design/multi-screen/screen-content-cache.ts|, liveScreenIds: ReadonlySet +templates/design/app/pages/DesignEditor.tsx|Auto-layout children follow their container — adjust the layout instead of aligning. templates/design/app/pages/DesignEditor.tsx|Could not open component source templates/design/app/pages/DesignEditor.tsx|Migration failed templates/design/app/pages/design-editor/pending-edits.ts|, primaryInfo?: Pick diff --git a/templates/design/.generated/bridge/editor-chrome.generated.ts b/templates/design/.generated/bridge/editor-chrome.generated.ts index ba865f1ca9..cbd734ec13 100644 --- a/templates/design/.generated/bridge/editor-chrome.generated.ts +++ b/templates/design/.generated/bridge/editor-chrome.generated.ts @@ -6257,8 +6257,7 @@ export const editorChromeBridgeScript: string = `"use strict"; height: dragElStartHeight }, snapCandidateRects, - // Task 3c — screen-space base converted to content px (1/zoom) so - // the snap tolerance feels constant on screen at any zoom. + // Convert the screen-space base to content px (1/zoom). SNAP_THRESHOLD_PX * chromeLineScale() ) : { dx: 0, dy: 0, guideV: null, guideH: null }; nextLeft += snapResult.dx; diff --git a/templates/design/actions/create-design-checkpoint.ts b/templates/design/actions/create-design-checkpoint.ts index ab72e9787d..4a49afcb05 100644 --- a/templates/design/actions/create-design-checkpoint.ts +++ b/templates/design/actions/create-design-checkpoint.ts @@ -1,15 +1,6 @@ /** - * create-design-checkpoint — Task 5a. - * * Creates a durable, attributable `design_versions` snapshot of the full - * current file set so it can be restored later via `restore-design-version`. - * Use before risky operations (an agent generation, a screen delete, a bulk - * import) or as an explicit user checkpoint. Auto-created kinds - * (`pre-agent-run`, `pre-structural`) are bounded: only the newest N per design - * are kept and older auto-checkpoints are pruned (a delete of rows this system - * created automatically — never of manual/user snapshots). - * - * Additive only: writes a new versions row; never alters or drops file data. + * current file set, restorable later via restore-design-version. */ import { defineAction } from "@agent-native/core"; diff --git a/templates/design/actions/restore-design-version.ts b/templates/design/actions/restore-design-version.ts index ec14dcd4f9..3f7c4a6963 100644 --- a/templates/design/actions/restore-design-version.ts +++ b/templates/design/actions/restore-design-version.ts @@ -1,15 +1,6 @@ /** - * restore-design-version — Task 5b. - * - * Restores a design to a previously captured checkpoint (design_versions - * snapshot). Restore is itself undoable: it first writes a fresh `pre-restore` - * checkpoint of the current state, then writes each snapshot file's content - * back — updating existing files and recreating any that were since deleted — - * and re-seeds the live collaboration document so open editors converge on the - * restored content. Destructive by intent; the UI gates it behind a confirm. - * - * Additive to schema: only writes/updates file rows and versions rows; never - * alters or drops columns. + * Restores a design to a previously captured checkpoint. Restore is itself + * undoable: it first writes a `pre-restore` checkpoint of the current state. */ import { defineAction } from "@agent-native/core"; diff --git a/templates/design/app/components/design/CanvasAgentStateBadge.tsx b/templates/design/app/components/design/CanvasAgentStateBadge.tsx index 136b70a308..115df71a17 100644 --- a/templates/design/app/components/design/CanvasAgentStateBadge.tsx +++ b/templates/design/app/components/design/CanvasAgentStateBadge.tsx @@ -32,21 +32,21 @@ interface BadgeStyle { const BADGE_STYLES: Record, BadgeStyle> = { working: { icon: IconLoader2, - label: "Working…", + label: "Working…" /* i18n-ignore */, spin: true, className: "border-blue-500/30 bg-blue-500/10 text-blue-600 dark:text-blue-300", }, applying: { icon: IconLoader2, - label: "Applying…", + label: "Applying…" /* i18n-ignore */, spin: true, className: "border-blue-500/30 bg-blue-500/10 text-blue-600 dark:text-blue-300", }, "needs-answer": { icon: IconMessageQuestion, - label: "Needs answer", + label: "Needs answer" /* i18n-ignore */, spin: false, className: "border-amber-500/30 bg-amber-500/10 text-amber-600 dark:text-amber-300", diff --git a/templates/design/app/components/design/DesignCanvas.tsx b/templates/design/app/components/design/DesignCanvas.tsx index cd01291cc0..22ab9beb50 100644 --- a/templates/design/app/components/design/DesignCanvas.tsx +++ b/templates/design/app/components/design/DesignCanvas.tsx @@ -315,16 +315,11 @@ ${editorChromeBridgeScript} const LIVE_REFLOW_ENABLED = true; /** - * Task 1a rollout gate (Editor Interaction Parity). When on, a pointerdown - * whose point lands inside the current selection's box keeps the SELECTED - * element as the drag target even when an overlapping non-descendant sibling - * wins the hit test — a selected object stays draggable from under another - * layer (Figma parity). Baked into the bridge as - * `__SELECTED_LAYER_DRAG_PRIORITY__`, same in-file bridge-flag pattern as - * `LIVE_REFLOW_ENABLED`; flip to `false` to restore the previous - * descendant-only behavior without a revert. The pure decision - * (`dragTargetForPointerDown`) is truth-table tested in - * editor-chrome-bridge.snap.test.ts regardless of this gate. + * Rollout gate: when on, a pointerdown inside the current selection's box keeps + * the selected element as the drag target even when an overlapping + * non-descendant sibling wins the hit test. Baked into the bridge as + * `__SELECTED_LAYER_DRAG_PRIORITY__`; flip to `false` for descendant-only + * behavior. */ const SELECTED_LAYER_DRAG_PRIORITY_ENABLED = true; diff --git a/templates/design/app/components/design/SelectionAgentAffordance.tsx b/templates/design/app/components/design/SelectionAgentAffordance.tsx index f2a5ff2d8b..b318c902ca 100644 --- a/templates/design/app/components/design/SelectionAgentAffordance.tsx +++ b/templates/design/app/components/design/SelectionAgentAffordance.tsx @@ -50,29 +50,41 @@ interface StatusDot { function statusDot(state: CanvasAgentState): StatusDot | null { switch (state) { case "working": - return { spinner: true, dotClass: "", title: "Agent is working" }; + return { + spinner: true, + dotClass: "", + title: "Agent is working" /* i18n-ignore */, + }; case "applying": - return { spinner: true, dotClass: "", title: "Applying changes" }; + return { + spinner: true, + dotClass: "", + title: "Applying changes" /* i18n-ignore */, + }; case "needs-answer": return { spinner: false, dotClass: "bg-amber-500", - title: "Agent needs an answer", + title: "Agent needs an answer" /* i18n-ignore */, }; case "warning": return { spinner: false, dotClass: "bg-amber-500", - title: "Agent is offline", + title: "Agent is offline" /* i18n-ignore */, }; case "done": return { spinner: false, dotClass: "bg-emerald-500", - title: "Agent finished", + title: "Agent finished" /* i18n-ignore */, }; case "failed": - return { spinner: false, dotClass: "bg-red-500", title: "Agent failed" }; + return { + spinner: false, + dotClass: "bg-red-500", + title: "Agent failed" /* i18n-ignore */, + }; default: return null; } @@ -197,20 +209,20 @@ export function SelectionAgentAffordance({ type="button" size="sm" className="h-6 gap-1 rounded-full px-2.5 text-[11px]" - aria-label="Change this element with the agent" + aria-label={"Change this element with the agent" /* i18n-ignore */} onClick={onChange} > - Change this… + Change this…{/* i18n-ignore */} {dot ? ( ", fileType: "html" }, - { id: "f2", filename: "b.html", content: "" }, - ], - }); - const files = parseCheckpointSnapshotFiles(snapshot); - expect(files.map((f) => f.id)).toEqual(["f1", "f2"]); - expect(files[0].content).toBe(""); - }); - - it("drops malformed entries (missing id/content/filename)", () => { - const snapshot = JSON.stringify({ - files: [ - { id: "ok", filename: "a.html", content: "x" }, - { id: "no-content", filename: "b.html" }, - { filename: "no-id.html", content: "y" }, - null, - "nope", - ], - }); - expect(parseCheckpointSnapshotFiles(snapshot).map((f) => f.id)).toEqual([ - "ok", - ]); - }); - - it("returns [] for invalid or fileless JSON", () => { - expect(parseCheckpointSnapshotFiles("not json")).toEqual([]); - expect(parseCheckpointSnapshotFiles(JSON.stringify({}))).toEqual([]); - expect(parseCheckpointSnapshotFiles(JSON.stringify({ files: 5 }))).toEqual( - [], - ); - }); -}); diff --git a/templates/design/server/lib/checkpoint-pruning.ts b/templates/design/server/lib/checkpoint-pruning.ts deleted file mode 100644 index 8ca8c55ccc..0000000000 --- a/templates/design/server/lib/checkpoint-pruning.ts +++ /dev/null @@ -1,34 +0,0 @@ -/** - * Bounded auto-pruning for auto-created design checkpoints: keep only the - * newest N per design of a given kind. Never prunes manual/user snapshots. - * Pure so the selection rule is unit-tested without a database. - */ - -export interface PrunableCheckpointRow { - id: string; - kind: string | null; - createdAt: string | null; -} - -/** The default retention for auto-created `pre-agent-run` checkpoints. */ -export const DEFAULT_CHECKPOINT_KEEP = 20; - -/** Returns ids of `kind` checkpoints outside the newest `keepNewest` (by ISO - * `createdAt`). Other kinds and null-kind rows are never selected; ties break - * by id descending for determinism. */ -export function selectCheckpointsToPrune( - rows: readonly PrunableCheckpointRow[], - kind: string, - keepNewest: number = DEFAULT_CHECKPOINT_KEEP, -): string[] { - if (keepNewest < 0) keepNewest = 0; - const ofKind = rows - .filter((row) => row.kind === kind) - .sort((a, b) => { - const at = a.createdAt ?? ""; - const bt = b.createdAt ?? ""; - if (at !== bt) return bt.localeCompare(at); - return b.id.localeCompare(a.id); - }); - return ofKind.slice(keepNewest).map((row) => row.id); -} diff --git a/templates/design/server/lib/design-checkpoint.ts b/templates/design/server/lib/design-checkpoint.ts deleted file mode 100644 index b9f4e9cd78..0000000000 --- a/templates/design/server/lib/design-checkpoint.ts +++ /dev/null @@ -1,112 +0,0 @@ -/** - * Shared design-checkpoint helpers for create-design-checkpoint and - * restore-design-version. A checkpoint is a `design_versions` row whose - * `snapshot` captures the full current file set. - */ - -import { eq, inArray } from "drizzle-orm"; -import { nanoid } from "nanoid"; - -import { getDb, schema } from "../db/index.js"; -import { - DEFAULT_CHECKPOINT_KEEP, - selectCheckpointsToPrune, -} from "./checkpoint-pruning.js"; - -export interface CheckpointSnapshotFile { - id: string; - filename: string; - content: string; - fileType?: string; - bytes?: number; -} - -/** Snapshot every file of a design into a new `design_versions` row. When - * `prune` is set, auto-created checkpoints of the same `kind` beyond the newest - * N are pruned. */ -export async function writeDesignCheckpoint(opts: { - designId: string; - kind: string; - createdBy: string; - trigger?: string | null; - label?: string | null; - prune?: boolean; -}): Promise<{ versionId: string; filesCaptured: number; pruned: number }> { - const db = getDb(); - const files = await db - .select({ - id: schema.designFiles.id, - filename: schema.designFiles.filename, - content: schema.designFiles.content, - fileType: schema.designFiles.fileType, - }) - .from(schema.designFiles) - .where(eq(schema.designFiles.designId, opts.designId)); - const now = new Date().toISOString(); - const versionId = `dv_${nanoid(12)}`; - const snapshot = JSON.stringify({ - designId: opts.designId, - snapshotKind: opts.kind, - files: files.map((file) => ({ - id: file.id, - filename: file.filename, - content: file.content, - fileType: file.fileType ?? "html", - bytes: file.content?.length ?? 0, - })), - capturedAt: now, - }); - await db.insert(schema.designVersions).values({ - id: versionId, - designId: opts.designId, - label: opts.label ?? `${opts.kind} checkpoint — ${now}`, - snapshot, - createdBy: opts.createdBy, - kind: opts.kind, - trigger: opts.trigger ?? null, - createdAt: now, - }); - let pruned = 0; - if (opts.prune) { - const rows = await db - .select({ - id: schema.designVersions.id, - kind: schema.designVersions.kind, - createdAt: schema.designVersions.createdAt, - }) - .from(schema.designVersions) - .where(eq(schema.designVersions.designId, opts.designId)); - const toPrune = selectCheckpointsToPrune( - rows, - opts.kind, - DEFAULT_CHECKPOINT_KEEP, - ); - if (toPrune.length > 0) { - await db - .delete(schema.designVersions) - .where(inArray(schema.designVersions.id, toPrune)); - pruned = toPrune.length; - } - } - return { versionId, filesCaptured: files.length, pruned }; -} - -/** Pure: extract the restorable file entries from a snapshot JSON string. */ -export function parseCheckpointSnapshotFiles( - snapshot: string, -): CheckpointSnapshotFile[] { - try { - const parsed = JSON.parse(snapshot) as { files?: unknown }; - if (!Array.isArray(parsed.files)) return []; - return parsed.files.filter( - (file): file is CheckpointSnapshotFile => - !!file && - typeof file === "object" && - typeof (file as { id?: unknown }).id === "string" && - typeof (file as { content?: unknown }).content === "string" && - typeof (file as { filename?: unknown }).filename === "string", - ); - } catch { - return []; - } -} diff --git a/templates/design/server/plugins/db.ts b/templates/design/server/plugins/db.ts index 528d7c58ed..cfefaa0fc8 100644 --- a/templates/design/server/plugins/db.ts +++ b/templates/design/server/plugins/db.ts @@ -384,14 +384,6 @@ CREATE INDEX IF NOT EXISTS design_templates_owner_org_updated_idx ON design_temp CREATE INDEX IF NOT EXISTS design_template_shares_resource_principal_idx ON design_template_shares (resource_id, principal_type, principal_id); CREATE INDEX IF NOT EXISTS design_template_files_template_idx ON design_template_files (template_id, updated_at)`, }, - { - version: 24, - name: "design-version-attribution", - // Attributable, recoverable checkpoints. Additive only. - sql: `ALTER TABLE design_versions ADD COLUMN IF NOT EXISTS created_by TEXT; -ALTER TABLE design_versions ADD COLUMN IF NOT EXISTS kind TEXT; -ALTER TABLE design_versions ADD COLUMN IF NOT EXISTS trigger TEXT`, - }, ], { table: "design_migrations" }, );