Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/design-whiteboard.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@reddb-io/redcode": minor
---

Design mode: Mermaid diagrams open as whiteboards

Every rendered diagram in a `.mermaid` (or `data-redcode-mermaid`) container gets an Excalidraw whiteboard beside it, and a Fullscreen action that opens the same one over the page: converted from the Mermaid source, drawn on and rearranged, autosaved beside the review's own state, and queued as one ordinary note carrying a summary of what changed (added, removed, moved, relabeled, drawn) plus the edited scene and a PNG preview on disk. The agent edits the Mermaid source in response; nothing is ever converted back. A scene saved for an older version of a diagram is never merged silently: the person chooses between re-converting and keeping their edits. The frames run sandboxed with no origin and no server access; the review page does every read and write, and only for a frame that proved a channel token minted for this prototype and its descent from the prototype frame. The bundle (Excalidraw, the converter with its exactly pinned Mermaid, React) is not in the binary: a release ships it as `redcode-whiteboard-<version>.tar.gz`, fetched into the data directory the first time a review needs it (`REDCODE_DISABLE_WHITEBOARD_DOWNLOAD=1` to never fetch, `REDCODE_WHITEBOARD_DIR` to point at a build); a source checkout builds it with `bun run build:whiteboard`. Until it is there, diagrams stay as they are.
1 change: 1 addition & 0 deletions packages/redcode/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"bench:test": "bun run script/bench-test-suite.ts",
"profile:test": "bun run script/profile-test-files.ts",
"build": "bun run script/build.ts",
"build:whiteboard": "bun run script/whiteboard-bundle.ts",
"dev": "bun run ./src/index.ts",
"dev:temporary": "bun run ./src/temporary.ts"
},
Expand Down
4 changes: 4 additions & 0 deletions packages/redcode/script/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,10 @@ if (Script.release) {
archives.push(`dist/${key}.zip`)
}
}
// The whiteboard bundle rides on the release as its own tarball: not in any binary, fetched by
// the server the first time a diagram is opened as a whiteboard.
await $`bun script/whiteboard-bundle.ts --archive`.env({ ...process.env, REDCODE_VERSION: Script.version })
archives.push(`dist/redcode-whiteboard-${Script.version}.tar.gz`)
const checksums: string[] = []
for (const archive of archives.sort()) {
const hasher = new Bun.CryptoHasher("sha256")
Expand Down
88 changes: 88 additions & 0 deletions packages/redcode/script/whiteboard-bundle.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
#!/usr/bin/env bun
/**
* The whiteboard bundle: Excalidraw, the Mermaid→Excalidraw converter with its exactly pinned
* Mermaid, React, and the frame page's own code, as one script and one stylesheet, plus the fonts
* Excalidraw fetches on demand. It is not part of the binary — several megabytes nobody needs
* until a diagram is opened as a whiteboard — so a release ships it as a tarball the server
* downloads on first use. `bun run build:whiteboard` produces dist/whiteboard/ for a checkout.
*
* Built in its own little project under node_modules/.cache, with its own install: React and Excalidraw are
* not dependencies of redcode, and pulling them into the workspace would tangle them with the
* React the terminal UI's dependencies expect. The pins are exact; the converter reaches into
* Mermaid's internals and newer Mermaid versions silently degrade class, ER and state diagrams
* to image fallbacks (mermaid-to-excalidraw#108), so a bump must be a deliberate re-probe.
*/
import { $ } from "bun"
import path from "path"
import { promises as fs } from "node:fs"

export const PINS = {
"@excalidraw/excalidraw": "0.18.1",
"@excalidraw/mermaid-to-excalidraw": "2.2.2",
mermaid: "11.12.1",
react: "18.2.0",
"react-dom": "18.2.0",
} as const

const root = path.resolve(import.meta.dirname, "..")
const out = path.join(root, "dist", "whiteboard")
// Under node_modules/.cache, not dist/: dist/*/package.json is what the publish script reads as
// the binary packages, and this project is not one of them.
const work = path.join(root, "node_modules", ".cache", "redcode-whiteboard-build")
const version = process.env["REDCODE_VERSION"] ?? (await import("../package.json")).default.version

// The frame's sources, copied beside the install so bare imports resolve there and nowhere else.
await fs.mkdir(work, { recursive: true })
await Bun.write(
path.join(work, "package.json"),
JSON.stringify({ name: "redcode-whiteboard-build", private: true, type: "module", dependencies: PINS }, null, 2),
)
const frame = await fs.readFile(path.join(root, "src/design/whiteboard-frame/frame.js"), "utf8")
await Bun.write(path.join(work, "frame.js"), frame.replace('"../vendor/whiteboard-core.js"', '"./vendor/whiteboard-core.js"'))
await fs.copyFile(path.join(root, "src/design/whiteboard-frame/frame.css"), path.join(work, "frame.css"))
await fs.mkdir(path.join(work, "vendor"), { recursive: true })
await fs.copyFile(path.join(root, "src/design/vendor/whiteboard-core.js"), path.join(work, "vendor", "whiteboard-core.js"))
await $`bun install --silent`.cwd(work)

for (const [name, pinned] of Object.entries(PINS)) {
const installed = (await import(path.join(work, "node_modules", name, "package.json"))).default.version
if (installed !== pinned) throw new Error(`${name} resolved to ${installed}, not the pinned ${pinned}`)
}

await fs.rm(out, { recursive: true, force: true })
await fs.mkdir(out, { recursive: true })

const result = await Bun.build({
entrypoints: [path.join(work, "frame.js")],
outdir: out,
naming: "whiteboard.[ext]",
target: "browser",
format: "iife",
minify: true,
conditions: ["production", "browser"],
define: {
"process.env.NODE_ENV": '"production"',
"process.env.IS_PREACT": '"false"',
},
})
if (!result.success) {
for (const log of result.logs) console.error(log)
process.exit(1)
}

// Excalidraw lazily fetches canvas fonts from `EXCALIDRAW_ASSET_PATH/fonts/`. Every family but
// Xiaolai (12 MB of CJK glyphs) ships; that one falls back to the system font when missing.
const families = ["Assistant", "Cascadia", "ComicShanns", "Excalifont", "Liberation", "Lilita", "Nunito", "Virgil"]
const fonts = path.join(work, "node_modules/@excalidraw/excalidraw/dist/prod/fonts")
await fs.mkdir(path.join(out, "fonts"), { recursive: true })
for (const family of families) {
await fs.cp(path.join(fonts, family), path.join(out, "fonts", family), { recursive: true })
}
await Bun.write(path.join(out, "VERSION"), `${version}\n`)

if (process.argv.includes("--archive")) {
const archive = path.join(root, "dist", `redcode-whiteboard-${version}.tar.gz`)
await $`tar -czf ${archive} -C ${out} .`
console.log(`wrote ${archive}`)
}
console.log(`built ${out}`)
86 changes: 86 additions & 0 deletions packages/redcode/src/design/client/artifact.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ export interface ArtifactConfig {
readonly load: number
/** The image limits the server enforces, so the card refuses early and says why. */
readonly attachments?: { readonly maxCount: number; readonly maxBytes: number; readonly accepted: readonly string[] }
/** Where a diagram's whiteboard frame lives, when the bundle is on this machine. */
readonly whiteboard?: { readonly frame: string }
}

export type HelperTable = {
Expand Down Expand Up @@ -871,6 +873,18 @@ export function artifactMain(config: ArtifactConfig, h: HelperTable) {
// race): run again and publish even if nothing changed.
audit.schedule(true)
return
case "suspendWhiteboard": {
// The shell is editing this diagram full screen: park the inline frame so two editors
// never autosave one scene. Resume reboots it from the latest saved scene.
const entry = whiteboardByIndex(payload.diagramIndex)
if (entry) entry.iframe.src = "about:blank"
return
}
case "resumeWhiteboard": {
const entry = whiteboardByIndex(payload.diagramIndex)
if (entry) entry.iframe.src = whiteboardSrc(entry)
return
}
case "attachmentResult":
// Only from the shell, and only for this document: a result for a chip of the previous
// document must not mark a new chip ready with the wrong image.
Expand Down Expand Up @@ -903,6 +917,78 @@ export function artifactMain(config: ArtifactConfig, h: HelperTable) {
scheduleReviewStateReport()
})

// --- whiteboards ------------------------------------------------------------------------------
// Each rendered diagram in a `.mermaid` (or `data-redcode-mermaid`) container is joined, at view
// time only, by a sibling frame hosting the Excalidraw whiteboard; the file keeps its Mermaid
// source and still renders plain when opened standalone or exported. The container's index
// among containers in document order is the diagram's identity; the server recovers the
// matching source from the file.
const CONTAINER = ".mermaid,[data-redcode-mermaid],[data-lavish-mermaid]"
const whiteboards = new Map<any, { iframe: HTMLIFrameElement; index: number; diagramId: string }>()
let enhanceTimer = 0
const containerIndex = (container: any) => Array.from(document.querySelectorAll(CONTAINER)).indexOf(container)
const whiteboardSrc = (entry: { index: number; diagramId: string }) =>
config.whiteboard!.frame + "?" + new URLSearchParams({ index: String(entry.index), diagramId: entry.diagramId }).toString()
const whiteboardByIndex = (index: unknown) =>
Array.from(whiteboards.values()).find((entry) => entry.iframe.isConnected && entry.index === Number(index)) || null
const whiteboardHeight = (rect: DOMRect) => {
const min = 360
const max = Math.max(min, Math.round((window.innerHeight || 800) * 0.8))
return Math.max(min, Math.min(Math.round(rect.height) + 96, max))
}
const scheduleEnhance = () => {
if (enhanceTimer) return
enhanceTimer = window.setTimeout(() => {
enhanceTimer = 0
enhance()
}, 100)
}
const embedWhiteboard = (svg: any) => {
const container = svg.closest(CONTAINER)
if (!container) return
const existing = whiteboards.get(container)
if (existing && existing.iframe.isConnected) {
existing.index = containerIndex(container)
return
}
const index = containerIndex(container)
if (index < 0) return
const rect = svg.getBoundingClientRect()
// Mermaid renders asynchronously; a flat rect means this svg has no layout yet. Ask again
// shortly, because finishing layout does not necessarily mutate the DOM.
if (rect.height < 40) {
window.setTimeout(scheduleEnhance, 150)
return
}
const entry = { iframe: document.createElement("iframe"), index, diagramId: String(svg.id || "") }
entry.iframe.setAttribute("data-redcode-ui", "whiteboard-inline")
entry.iframe.setAttribute("title", "Whiteboard")
// Stricter than, and independent of, this document's own sandbox.
entry.iframe.setAttribute("sandbox", "allow-scripts allow-popups")
entry.iframe.src = whiteboardSrc(entry)
entry.iframe.style.cssText =
"display:block;width:100%;height:" +
whiteboardHeight(rect) +
"px;border:1px solid rgba(128,128,128,.35);border-radius:12px;background:transparent"
// The page may re-render Mermaid inside the container on a theme change, so the frame is a
// sibling: a re-render stays harmless inside the hidden container instead of killing the editor.
container.style.display = "none"
container.insertAdjacentElement("afterend", entry.iframe)
whiteboards.set(container, entry)
}
const enhance = () => {
for (const svg of Array.from(document.querySelectorAll("svg"))) {
if (isUi(svg) || !h.isMermaidSvg(svg)) continue
embedWhiteboard(svg)
}
}
if (config.whiteboard && config.whiteboard.frame) {
scheduleEnhance()
window.addEventListener("load", scheduleEnhance, { once: true })
if (typeof MutationObserver !== "undefined")
new MutationObserver(scheduleEnhance).observe(document.documentElement, { childList: true, subtree: true })
}

// --- the passive layout audit, and the one fatal path -----------------------------------------
const audit = h.artifactAudit({ h, post, isUi, selector, load: config.load })
// A local subresource the prototype declares but the server cannot serve makes the review
Expand Down
17 changes: 16 additions & 1 deletion packages/redcode/src/design/feedback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
*/

import { DesignLayoutWarnings } from "./layout-warnings"
import { normalizeExcalidrawSceneTarget, type ExcalidrawSceneTarget } from "./vendor/whiteboard-core.js"

/** Long enough for a real remark, short enough that a page cannot flood a turn. */
export const LIMITS = {
Expand Down Expand Up @@ -70,7 +71,10 @@ export interface MermaidNodeTarget {
/** A batch of layout warnings the person queued for repair, as the inbox described them. */
export type LayoutWarningsTarget = DesignLayoutWarnings.PromptTarget

export type Target = TextRangeTarget | TableCellTarget | MermaidNodeTarget | LayoutWarningsTarget
/** A whiteboard's edits: where the scene and its preview are on disk, and what changed, counted. */
export type WhiteboardTarget = ExcalidrawSceneTarget

export type Target = TextRangeTarget | TableCellTarget | MermaidNodeTarget | LayoutWarningsTarget | WhiteboardTarget

export interface Annotation {
/** Where in the prototype, as a CSS path. */
Expand Down Expand Up @@ -193,6 +197,16 @@ export function target(raw: unknown): Target | undefined {
const normalized = DesignLayoutWarnings.normalizeTarget(r)
return normalized.warnings.length ? normalized : undefined
}
case "excalidraw-scene": {
const normalized = normalizeExcalidrawSceneTarget(r)
return {
...normalized,
diagramId: clamp(normalized.diagramId, LIMITS.label),
sourceHash: clamp(normalized.sourceHash, 32),
scenePath: clamp(normalized.scenePath, LIMITS.selector),
previewPath: clamp(normalized.previewPath, LIMITS.selector),
}
}
default:
return undefined
}
Expand Down Expand Up @@ -256,6 +270,7 @@ export function where(item: Annotation): string {
return `node ${t.label ? `"${t.label}"` : ""}${ids ? ` ${ids}` : ""}`.replace(/\s+/g, " ").trim()
}
if (t?.type === "layout-warnings") return `layout issues: ${t.warnings.length} queued for repair`
if (t?.type === "excalidraw-scene") return `whiteboard: diagram ${t.diagramIndex + 1}${t.diagramId ? ` (${t.diagramId})` : ""}`
if (item.tag === "message") return ""
return item.label || item.selector || ""
}
Expand Down
52 changes: 51 additions & 1 deletion packages/redcode/src/design/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { EventV2Bridge } from "@/event-v2-bridge"
import { Session } from "@/session/session"
import { Config } from "@/config/config"
import { Context, Effect, Fiber, Layer, PubSub, Semaphore, Stream } from "effect"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { createHash, randomBytes } from "node:crypto"
import { promises as nodeFs } from "node:fs"
import path from "path"
Expand All @@ -17,6 +18,7 @@ import { DesignLayoutWarnings } from "./layout-warnings"
import { DesignManifest } from "./manifest"
import { DesignState } from "./state"
import { DesignWatch } from "./watch"
import { DesignWhiteboard } from "./whiteboard"

/**
* Which directories are currently reachable as prototypes, and by whom.
Expand Down Expand Up @@ -79,6 +81,13 @@ export interface Load {

export type LoadBegun = { readonly revision: number; readonly token: string; readonly stale?: "out-of-order" }

/** The whiteboard bundle on this machine, if it is here, and the secret its channels are signed with. */
export interface Whiteboard {
readonly dir?: string
readonly secret: Buffer
readonly status: "ready" | "fetching" | "unavailable"
}

/** What a review page was configured to do about layout. */
export interface Settings {
readonly viewports: readonly DesignLayoutWarnings.ViewportClass[]
Expand Down Expand Up @@ -142,6 +151,8 @@ export interface Interface {
readonly settings: () => Effect.Effect<Settings>
/** The export's size caps, from config. */
readonly exportCaps: () => Effect.Effect<DesignExport.Options>
/** Where the whiteboard bundle is. The first ask on a machine without it starts the download. */
readonly whiteboard: () => Effect.Effect<Whiteboard>
/** A shell is about to load the frame: mint the token that ties that document's passes to it. */
readonly beginLoad: (id: string, input: { client: string; sequence: number }) => Effect.Effect<LoadBegun | undefined>
/** Is this the token of a load still current for this prototype? */
Expand Down Expand Up @@ -240,8 +251,46 @@ const layer = Layer.effect(
const fs = yield* FSUtil.Service
const sessions = yield* Session.Service
const config = yield* Config.Service
const flags = yield* RuntimeFlags.Service
const lock = yield* Semaphore.make(1)

// One secret per process: a channel token outlives nothing, and a restart mints fresh ones.
const wb: { dir?: string; attempted: boolean; failed: boolean; readonly secret: Buffer } = {
attempted: false,
failed: false,
secret: randomBytes(32),
}
const whiteboard = Effect.fn("DesignRegistry.whiteboard")(function* () {
if (wb.dir) return { dir: wb.dir, secret: wb.secret, status: "ready" as const }
const located = yield* Effect.promise(() => DesignWhiteboard.locate())
if (located) {
wb.dir = located
return { dir: located, secret: wb.secret, status: "ready" as const }
}
if (!wb.attempted && !flags.disableWhiteboardDownload) {
wb.attempted = true
yield* Effect.forkDetach(
Effect.promise(() => DesignWhiteboard.download()).pipe(
Effect.tap((result) =>
Effect.sync(() => {
if (result !== "ready") wb.failed = true
}),
),
Effect.flatMap((result) =>
result === "ready"
? Effect.promise(() => DesignWhiteboard.locate()).pipe(
Effect.tap((dir) => Effect.sync(() => (wb.dir = dir))),
)
: Effect.logInfo("design: whiteboard bundle not fetched", { result }),
),
Effect.catchCause((cause) => Effect.logWarning("design: whiteboard bundle download failed", { cause })),
),
)
}
const status = wb.attempted && !wb.failed && !flags.disableWhiteboardDownload ? ("fetching" as const) : ("unavailable" as const)
return { secret: wb.secret, status }
})

interface State {
readonly data: Map<string, Prototype>
readonly hubs: Map<string, PubSub.PubSub<LiveEvent>>
Expand Down Expand Up @@ -800,6 +849,7 @@ const layer = Layer.effect(
exclusive,
settings,
exportCaps,
whiteboard,
beginLoad,
verifyLoad,
diagnostics,
Expand All @@ -814,7 +864,7 @@ const layer = Layer.effect(
export const node = LayerNode.make({
service: Service,
layer,
deps: [EventV2Bridge.node, FSUtil.node, Session.node, Config.node],
deps: [EventV2Bridge.node, FSUtil.node, Session.node, Config.node, RuntimeFlags.node],
})

export * as DesignRegistry from "./registry"
Loading
Loading