From 7bfa1051c397d2828692386eac3aeaf312636222 Mon Sep 17 00:00:00 2001
From: ruinivist <179396038+ruinivist@users.noreply.github.com>
Date: Sat, 30 May 2026 17:59:06 +0000
Subject: [PATCH] chore: strip repository to provide a minimal reproduction of
bun code splitting bug
Removes all server-side code, tests, CI pipelines, Docker config, extra CSS, and sidebar logic. Modifies `App.tsx` to just eagerly load and render ``. Modifies `package.json` to only contain a basic build command to reproduce the bug where Bun injects a dynamic chunk rather than the entrypoint chunk.
---
.dockerignore | 5 -
.github/workflows/publish-ghcr.yml | 80 -----
.gitignore | 1 -
Caddyfile | 31 --
Dockerfile | 32 --
docker-entrypoint.sh | 43 ---
package.json | 10 +-
repro.ts | 59 ----
src/App.tsx | 507 +----------------------------
src/api.test.ts | 82 -----
src/api.ts | 119 -------
src/client.tsx | 1 -
src/db.test.ts | 50 ---
src/db.ts | 209 ------------
src/dev-server.tsx | 19 --
src/scene.test.ts | 31 --
src/scene.ts | 97 ------
src/server.test.ts | 70 ----
src/server.tsx | 40 ---
src/shared.ts | 18 -
src/styles.css | 297 -----------------
21 files changed, 4 insertions(+), 1797 deletions(-)
delete mode 100644 .dockerignore
delete mode 100644 .github/workflows/publish-ghcr.yml
delete mode 100644 Caddyfile
delete mode 100644 Dockerfile
delete mode 100644 docker-entrypoint.sh
delete mode 100644 repro.ts
delete mode 100644 src/api.test.ts
delete mode 100644 src/api.ts
delete mode 100644 src/db.test.ts
delete mode 100644 src/db.ts
delete mode 100644 src/dev-server.tsx
delete mode 100644 src/scene.test.ts
delete mode 100644 src/scene.ts
delete mode 100644 src/server.test.ts
delete mode 100644 src/server.tsx
delete mode 100644 src/shared.ts
delete mode 100644 src/styles.css
diff --git a/.dockerignore b/.dockerignore
deleted file mode 100644
index 4f2f110..0000000
--- a/.dockerignore
+++ /dev/null
@@ -1,5 +0,0 @@
-node_modules
-dist
-data
-.git
-coverage
diff --git a/.github/workflows/publish-ghcr.yml b/.github/workflows/publish-ghcr.yml
deleted file mode 100644
index ec4e505..0000000
--- a/.github/workflows/publish-ghcr.yml
+++ /dev/null
@@ -1,80 +0,0 @@
-name: Publish GHCR Image
-
-on:
- push:
- tags:
- - 'release-*'
-
-permissions:
- contents: read
- packages: write
-
-concurrency:
- group: publish-ghcr-${{ github.ref }}
- cancel-in-progress: false
-
-jobs:
- publish:
- runs-on: ubuntu-latest
-
- steps:
- - name: Check out repository
- uses: actions/checkout@v4
-
- - name: Set up Bun
- uses: oven-sh/setup-bun@v2
- with:
- bun-version: 1.3.11
-
- - name: Install dependencies
- run: bun install --frozen-lockfile
-
- - name: Run typecheck
- run: bun run typecheck
-
- - name: Run tests
- run: bun run test
-
- - name: Set up Docker Buildx
- uses: docker/setup-buildx-action@v3
-
- - name: Log in to GHCR
- uses: docker/login-action@v3
- with:
- registry: ghcr.io
- username: ${{ github.actor }}
- password: ${{ secrets.GITHUB_TOKEN }}
-
- - name: Derive image metadata
- shell: bash
- run: |
- set -euo pipefail
-
- version="${GITHUB_REF_NAME#release-}"
- version="${version#v}"
-
- if [[ -z "$version" ]]; then
- echo "Release version is empty after stripping prefix from tag: $GITHUB_REF_NAME" >&2
- exit 1
- fi
-
- image_name="ghcr.io/${GITHUB_REPOSITORY,,}"
-
- {
- echo "VERSION=$version"
- echo "IMAGE_NAME=$image_name"
- } >> "$GITHUB_ENV"
-
- - name: Build and push image
- uses: docker/build-push-action@v6
- with:
- context: .
- file: ./Dockerfile
- push: true
- tags: |
- ${{ env.IMAGE_NAME }}:${{ env.VERSION }}
- ${{ env.IMAGE_NAME }}:latest
- labels: |
- org.opencontainers.image.source=https://github.com/${{ github.repository }}
- org.opencontainers.image.revision=${{ github.sha }}
- org.opencontainers.image.version=${{ env.VERSION }}
diff --git a/.gitignore b/.gitignore
index f3f228b..36669d8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -35,4 +35,3 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
.DS_Store
PLAN.md
-bun-repro-dir/
diff --git a/Caddyfile b/Caddyfile
deleted file mode 100644
index c98668b..0000000
--- a/Caddyfile
+++ /dev/null
@@ -1,31 +0,0 @@
-{
- auto_https off
- admin off
-}
-
-:80 {
- encode zstd gzip
-
- @api path /api/*
- handle @api {
- reverse_proxy 127.0.0.1:3000
- }
-
- @root path /
- handle @root {
- reverse_proxy 127.0.0.1:3000
- }
-
- handle {
- root * /srv/public
-
- @html path /index.html /d/*
- header @html Cache-Control "no-cache"
-
- @assets path_regexp assets \.(?:css|js|mjs|svg|png|jpg|jpeg|gif|webp|ico|woff2?|ttf|otf)$
- header @assets Cache-Control "public, max-age=31536000, immutable"
-
- try_files {path} /index.html
- file_server
- }
-}
diff --git a/Dockerfile b/Dockerfile
deleted file mode 100644
index 22a6678..0000000
--- a/Dockerfile
+++ /dev/null
@@ -1,32 +0,0 @@
-FROM oven/bun:1.3.11-alpine AS build
-WORKDIR /app
-
-COPY package.json bun.lock tsconfig.json ./
-RUN bun install --frozen-lockfile
-
-COPY src ./src
-RUN bun run build
-
-FROM caddy:2-alpine AS caddy
-
-FROM oven/bun:1.3.11-alpine AS runner
-WORKDIR /app
-
-ENV NODE_ENV=production
-ENV HOST=127.0.0.1
-ENV PORT=3000
-ENV DATABASE_PATH=/data/excalidraw.sqlite
-
-COPY --from=caddy /usr/bin/caddy /usr/bin/caddy
-COPY --from=build /app/dist/public /srv/public
-COPY --from=build /app/dist/server /app/dist/server
-COPY Caddyfile /etc/caddy/Caddyfile
-COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
-
-RUN mkdir -p /data && chmod +x /usr/local/bin/docker-entrypoint.sh
-
-VOLUME ["/data"]
-
-EXPOSE 80
-
-CMD ["/usr/local/bin/docker-entrypoint.sh"]
diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh
deleted file mode 100644
index d9c4b1e..0000000
--- a/docker-entrypoint.sh
+++ /dev/null
@@ -1,43 +0,0 @@
-#!/bin/sh
-set -eu
-
-bun_pid=""
-caddy_pid=""
-
-stop_children() {
- for pid in "$bun_pid" "$caddy_pid"; do
- if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then
- kill "$pid" 2>/dev/null || true
- fi
- done
-}
-
-trap 'stop_children' INT TERM
-
-bun /app/dist/server/server.js &
-bun_pid=$!
-
-caddy run --config /etc/caddy/Caddyfile --adapter caddyfile &
-caddy_pid=$!
-
-exit_code=0
-
-while :; do
- if ! kill -0 "$bun_pid" 2>/dev/null; then
- wait "$bun_pid" || exit_code=$?
- break
- fi
-
- if ! kill -0 "$caddy_pid" 2>/dev/null; then
- wait "$caddy_pid" || exit_code=$?
- break
- fi
-
- sleep 1
-done
-
-stop_children
-wait "$bun_pid" 2>/dev/null || true
-wait "$caddy_pid" 2>/dev/null || true
-
-exit "$exit_code"
diff --git a/package.json b/package.json
index 658af15..502241a 100644
--- a/package.json
+++ b/package.json
@@ -1,15 +1,9 @@
{
- "name": "excali",
+ "name": "excali-repro",
"type": "module",
"private": true,
"scripts": {
- "dev": "bun --hot src/dev-server.tsx",
- "build": "rm -rf dist && bun run build:client && bun run build:server",
- "build:client": "bun build --target=browser --production --outdir ./dist/public ./src/index.html",
- "build:server": "mkdir -p ./dist/server && bun build --target=bun --production --outfile ./dist/server/server.js ./src/server.tsx",
- "start": "DATABASE_PATH=./data/excalidraw.sqlite bun ./dist/server/server.js",
- "test": "bun test",
- "typecheck": "tsc --noEmit"
+ "build": "rm -rf dist && bun build --target=browser --splitting --outdir ./dist/public ./src/index.html"
},
"dependencies": {
"@excalidraw/excalidraw": "^0.18.1",
diff --git a/repro.ts b/repro.ts
deleted file mode 100644
index 869b425..0000000
--- a/repro.ts
+++ /dev/null
@@ -1,59 +0,0 @@
-import { rmdir, mkdir, writeFile, readFile } from "node:fs/promises";
-import { join } from "node:path";
-import { spawn } from "node:child_process";
-
-async function main() {
- const testDir = join(process.cwd(), "bun-repro-dir");
- await rmdir(testDir, { recursive: true }).catch(() => {});
- await mkdir(testDir, { recursive: true });
-
- const numChunks = 2500;
-
- for (let i = 0; i < numChunks; i++) {
- await writeFile(join(testDir, `chunk${i}.js`), `export const val = ${i};\nconsole.log(val);`);
- }
-
- let entryFile = "import { x } from './entry1.js'; console.log(x);\n";
- let entry1File = "export const x = 1;\n";
-
- for (let i = 0; i < numChunks; i++) {
- entry1File += `import("./chunk${i}.js").then(m => console.log(m.val));\n`;
- }
-
- await writeFile(join(testDir, "entry1.js"), entry1File);
- await writeFile(join(testDir, "index.js"), entryFile);
-
- const htmlContent = `
`;
- await writeFile(join(testDir, "index.html"), htmlContent);
-
- console.log("Building...");
- const child = spawn(process.argv[0], ["build", "--target=browser", "--splitting", "--outdir", "./dist", "./index.html"], {
- cwd: testDir,
- stdio: "inherit",
- });
-
- await new Promise((resolve) => child.on("close", resolve));
-
- const outputHtml = await readFile(join(testDir, "dist", "index.html"), "utf8");
- console.log("\nGenerated HTML (partial):");
- console.log(outputHtml.slice(0, 500));
-
- // Parse the injected script src
- const match = outputHtml.match(/src="([^"]+)"/);
- if (!match) {
- console.error("Failed to parse output HTML");
- process.exit(1);
- }
-
- const injectedScript = match[1];
- console.log(`\nInjected script: ${injectedScript}`);
-
- const injectedContent = await readFile(join(testDir, "dist", injectedScript), "utf8");
- if (injectedContent.includes('console.log(x)') || injectedContent.includes('console.log(1)')) {
- console.log("✅ Bun successfully injected the main entrypoint!");
- } else {
- console.log("❌ Bug confirmed: Bun injected a random chunk instead of the main entrypoint.");
- }
-}
-
-main().catch(console.error);
diff --git a/src/App.tsx b/src/App.tsx
index 0e8c1cf..0699984 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1,512 +1,9 @@
-import "../node_modules/@excalidraw/excalidraw/dist/prod/index.css";
import { Excalidraw } from "@excalidraw/excalidraw";
-import type {
- AppState,
- BinaryFiles,
- ExcalidrawInitialDataState,
-} from "@excalidraw/excalidraw/types";
-import { useCallback, useEffect, useMemo, useRef, useState } from "react";
-import { DEFAULT_TITLE, type DrawingMeta, type ScenePayload } from "./shared";
-
-type DrawingResponse = DrawingMeta & ScenePayload;
-
-const EXCALIDRAW_THEME_TOKEN_MAP = [
- ["--island-bg-color", "--app-island-bg"],
- ["--sidebar-bg-color", "--app-sidebar-bg"],
- ["--sidebar-border-color", "--app-sidebar-border"],
- ["--sidebar-shadow", "--app-sidebar-shadow"],
- ["--color-surface-lowest", "--app-surface-lowest"],
- ["--color-surface-low", "--app-surface-low"],
- ["--color-surface-primary-container", "--app-selected-bg"],
- ["--color-on-surface", "--app-text-color"],
- ["--color-on-primary-container", "--app-selected-text-color"],
- ["--color-disabled", "--app-disabled-color"],
- ["--input-bg-color", "--app-input-bg"],
- ["--input-border-color", "--app-input-border"],
- ["--input-label-color", "--app-input-color"],
- ["--default-border-color", "--app-button-border"],
- ["--button-hover-bg", "--app-button-hover-bg"],
- ["--button-active-bg", "--app-button-active-bg"],
- ["--button-active-border", "--app-button-active-border"],
- ["--overlay-bg-color", "--app-overlay-bg"],
-] as const;
-
-function pathToId(pathname = window.location.pathname): string | null {
- const match = pathname.match(/^\/d\/([^/]+)$/);
- return match?.[1] ?? null;
-}
-
-function sortDrawings(drawings: DrawingMeta[]): DrawingMeta[] {
- return [...drawings].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
-}
-
-function replaceMeta(drawings: DrawingMeta[], drawing: DrawingMeta): DrawingMeta[] {
- const filtered = drawings.filter((item) => item.id !== drawing.id);
- return sortDrawings([drawing, ...filtered]);
-}
-
-function sceneToInitialData(scene: ScenePayload): ExcalidrawInitialDataState {
- return scene as ExcalidrawInitialDataState;
-}
-
-function sceneFromEditor(
- elements: readonly unknown[],
- appState: AppState,
- files: BinaryFiles,
-): ScenePayload {
- return {
- elements: [...elements],
- appState: appState as unknown as Record,
- files: files as unknown as Record,
- };
-}
-
-async function requestJson(url: string, init?: RequestInit): Promise {
- const response = await fetch(url, {
- ...init,
- headers: {
- ...(init?.body ? { "content-type": "application/json" } : {}),
- ...init?.headers,
- },
- });
-
- const body = (await response.json()) as T & { error?: string };
- if (!response.ok) {
- throw new Error(body.error ?? `Request failed: ${response.status}`);
- }
-
- return body;
-}
-
-function DrawerIcon() {
- return (
-
- );
-}
export function App() {
- const [drawings, setDrawings] = useState([]);
- const [activeId, setActiveId] = useState(pathToId());
- const [activeTitle, setActiveTitle] = useState(DEFAULT_TITLE);
- const [scene, setScene] = useState(null);
- const [loading, setLoading] = useState(true);
- const [error, setError] = useState(null);
- const [isSidebarOpen, setIsSidebarOpen] = useState(false);
-
- const currentIdRef = useRef(activeId);
- const currentTitleRef = useRef(activeTitle);
- const latestSceneRef = useRef(null);
- const ignoreChangeRef = useRef(false);
- const appShellRef = useRef(null);
- const timeoutRef = useRef(null);
- const inFlightSaveRef = useRef | null>(null);
- const loadVersionRef = useRef(0);
- const themeSyncFrameRef = useRef(null);
-
- const activeDrawing = useMemo(
- () => drawings.find((drawing) => drawing.id === activeId) ?? null,
- [activeId, drawings],
- );
-
- const refreshList = useCallback(async () => {
- const list = await requestJson("/api/drawings", { method: "GET" });
- setDrawings(sortDrawings(list));
- return list;
- }, []);
-
- const saveScene = useCallback(async () => {
- const drawingId = currentIdRef.current;
- const nextScene = latestSceneRef.current;
- if (!drawingId || !nextScene) {
- return;
- }
- setError(null);
-
- const promise = requestJson<{ ok: true; drawing: DrawingMeta }>(`/api/drawings/${drawingId}`, {
- method: "PUT",
- body: JSON.stringify(nextScene),
- })
- .then((body) => {
- setDrawings((current) => replaceMeta(current, body.drawing));
- })
- .catch((saveError: unknown) => {
- setError(saveError instanceof Error ? saveError.message : "Save failed");
- throw saveError;
- })
- .finally(() => {
- inFlightSaveRef.current = null;
- });
-
- inFlightSaveRef.current = promise;
- await promise;
- }, []);
-
- const flushPendingSave = useCallback(async () => {
- if (timeoutRef.current !== null) {
- clearTimeout(timeoutRef.current);
- timeoutRef.current = null;
- await saveScene();
- return;
- }
-
- if (inFlightSaveRef.current) {
- await inFlightSaveRef.current;
- }
- }, [saveScene]);
-
- const scheduleSave = useCallback(() => {
- if (timeoutRef.current !== null) {
- clearTimeout(timeoutRef.current);
- }
-
- timeoutRef.current = window.setTimeout(() => {
- timeoutRef.current = null;
- void saveScene();
- }, 1500);
- }, [saveScene]);
-
- const loadDrawing = useCallback(
- async (drawingId: string) => {
- const version = ++loadVersionRef.current;
- setLoading(true);
- setError(null);
-
- try {
- const [list, drawing] = await Promise.all([
- requestJson("/api/drawings", { method: "GET" }),
- requestJson(`/api/drawings/${drawingId}`, { method: "GET" }),
- ]);
-
- if (version !== loadVersionRef.current) {
- return;
- }
-
- const nextScene = {
- elements: drawing.elements,
- appState: drawing.appState,
- files: drawing.files,
- };
-
- ignoreChangeRef.current = true;
- currentIdRef.current = drawing.id;
- currentTitleRef.current = drawing.title;
- latestSceneRef.current = nextScene;
-
- setDrawings(sortDrawings(list));
- setActiveId(drawing.id);
- setActiveTitle(drawing.title);
- setScene(nextScene);
- } catch (loadError) {
- const list = await refreshList();
- if (list.length === 0) {
- const created = await requestJson("/api/drawings", { method: "POST" });
- window.history.replaceState(null, "", `/d/${created.id}`);
- await loadDrawing(created.id);
- return;
- }
-
- const fallback = list[0];
- if (fallback && fallback.id !== drawingId) {
- window.history.replaceState(null, "", `/d/${fallback.id}`);
- await loadDrawing(fallback.id);
- return;
- }
-
- setError(loadError instanceof Error ? loadError.message : "Failed to load drawing");
- } finally {
- if (version === loadVersionRef.current) {
- setLoading(false);
- }
- }
- },
- [refreshList],
- );
-
- const navigateToDrawing = useCallback(
- async (drawingId: string, options?: { replace?: boolean; flush?: boolean }) => {
- const replace = options?.replace ?? false;
- const flush = options?.flush ?? true;
-
- if (drawingId === currentIdRef.current && latestSceneRef.current) {
- if (replace) {
- window.history.replaceState(null, "", `/d/${drawingId}`);
- }
- return;
- }
-
- if (flush) {
- await flushPendingSave();
- }
-
- if (replace) {
- window.history.replaceState(null, "", `/d/${drawingId}`);
- } else {
- window.history.pushState(null, "", `/d/${drawingId}`);
- }
-
- await loadDrawing(drawingId);
- },
- [flushPendingSave, loadDrawing],
- );
-
- const createDrawing = useCallback(async () => {
- await flushPendingSave();
- const created = await requestJson("/api/drawings", { method: "POST" });
- await navigateToDrawing(created.id, { flush: false });
- }, [flushPendingSave, navigateToDrawing]);
-
- const deleteDrawing = useCallback(
- async (drawingId: string) => {
- if (!window.confirm("Delete this drawing?")) {
- return;
- }
-
- if (drawingId === currentIdRef.current) {
- await flushPendingSave();
- }
-
- const response = await requestJson<{ ok: true; nextId: string | null }>(`/api/drawings/${drawingId}`, {
- method: "DELETE",
- });
-
- if (drawingId === currentIdRef.current && response.nextId) {
- await navigateToDrawing(response.nextId, { replace: true, flush: false });
- } else {
- await refreshList();
- }
- },
- [flushPendingSave, navigateToDrawing, refreshList],
- );
-
- const handleTitleSubmit = useCallback(async () => {
- const drawingId = currentIdRef.current;
- if (!drawingId) {
- return;
- }
-
- try {
- const body = await requestJson<{ ok: true; drawing: DrawingMeta }>(`/api/drawings/${drawingId}`, {
- method: "PUT",
- body: JSON.stringify({ title: currentTitleRef.current }),
- });
-
- currentTitleRef.current = body.drawing.title;
- setActiveTitle(body.drawing.title);
- setDrawings((current) => replaceMeta(current, body.drawing));
- } catch (renameError) {
- setError(renameError instanceof Error ? renameError.message : "Rename failed");
- }
- }, []);
-
- const syncThemeTokens = useCallback(() => {
- const appShell = appShellRef.current;
- const excalidrawRoot = appShell?.querySelector(".excalidraw");
- if (!appShell || !excalidrawRoot) {
- return;
- }
-
- const computedStyles = window.getComputedStyle(excalidrawRoot);
- for (const [sourceToken, targetToken] of EXCALIDRAW_THEME_TOKEN_MAP) {
- const value = computedStyles.getPropertyValue(sourceToken).trim();
- if (value) {
- appShell.style.setProperty(targetToken, value);
- }
- }
- }, []);
-
- const scheduleThemeTokenSync = useCallback(() => {
- if (themeSyncFrameRef.current !== null) {
- window.cancelAnimationFrame(themeSyncFrameRef.current);
- }
-
- themeSyncFrameRef.current = window.requestAnimationFrame(() => {
- themeSyncFrameRef.current = null;
- syncThemeTokens();
- });
- }, [syncThemeTokens]);
-
- useEffect(() => {
- const currentPathId = pathToId();
- if (currentPathId) {
- void loadDrawing(currentPathId);
- } else {
- window.location.replace("/");
- }
-
- const onPopState = () => {
- const nextId = pathToId();
- if (nextId) {
- void navigateToDrawing(nextId, { replace: true, flush: true });
- }
- };
-
- window.addEventListener("popstate", onPopState);
- return () => {
- window.removeEventListener("popstate", onPopState);
- };
- }, [loadDrawing, navigateToDrawing]);
-
- useEffect(() => {
- return () => {
- if (timeoutRef.current !== null) {
- clearTimeout(timeoutRef.current);
- }
-
- if (themeSyncFrameRef.current !== null) {
- window.cancelAnimationFrame(themeSyncFrameRef.current);
- }
- };
- }, []);
-
- useEffect(() => {
- if (!scene || loading) {
- return;
- }
-
- scheduleThemeTokenSync();
- }, [activeId, loading, scene, scheduleThemeTokenSync]);
-
- useEffect(() => {
- if (!isSidebarOpen) {
- return;
- }
-
- const onKeyDown = (event: KeyboardEvent) => {
- if (event.key === "Escape") {
- setIsSidebarOpen(false);
- }
- };
-
- window.addEventListener("keydown", onKeyDown);
- return () => {
- window.removeEventListener("keydown", onKeyDown);
- };
- }, [isSidebarOpen]);
-
- const openSidebar = useCallback(() => {
- setIsSidebarOpen(true);
- }, []);
-
- const closeSidebar = useCallback(() => {
- setIsSidebarOpen(false);
- }, []);
-
- const handleSelectDrawing = useCallback(
- async (drawingId: string) => {
- setIsSidebarOpen(false);
- await navigateToDrawing(drawingId);
- },
- [navigateToDrawing],
- );
-
- const handleCreateDrawing = useCallback(async () => {
- setIsSidebarOpen(false);
- await createDrawing();
- }, [createDrawing]);
-
return (
-
-
-
-
-
-
- {loading || !scene ? (
- {error ?? "Loading..."}
- ) : (
-
- {
- const nextScene = sceneFromEditor(elements, appState, files);
- latestSceneRef.current = nextScene;
- scheduleThemeTokenSync();
-
- if (ignoreChangeRef.current) {
- ignoreChangeRef.current = false;
- return;
- }
-
- scheduleSave();
- }}
- />
-
- )}
-
-
-
+
+
);
}
diff --git a/src/api.test.ts b/src/api.test.ts
deleted file mode 100644
index a1a1ccd..0000000
--- a/src/api.test.ts
+++ /dev/null
@@ -1,82 +0,0 @@
-import { describe, expect, test } from "bun:test";
-import { mkdtempSync, rmSync } from "node:fs";
-import { tmpdir } from "node:os";
-import { join } from "node:path";
-import { createApi } from "./api";
-import { createDrawingStore } from "./db";
-
-function withApi() {
- const dir = mkdtempSync(join(tmpdir(), "excali-api-"));
- const store = createDrawingStore(join(dir, "test.sqlite"));
- const api = createApi(store);
-
- return {
- api,
- cleanup() {
- store.close();
- rmSync(dir, { recursive: true, force: true });
- },
- };
-}
-
-describe("api", () => {
- test("creates a drawing with dark theme by default", async () => {
- const { api, cleanup } = withApi();
-
- const created = await api.createDrawing().json();
-
- expect(created.appState.theme).toBe("dark");
- cleanup();
- });
-
- test("returns 400 for invalid JSON", async () => {
- const { api, cleanup } = withApi();
- const drawing = await api.createDrawing().json();
-
- const response = await api.updateDrawing(
- Object.assign(
- new Request(`http://local/api/drawings/${drawing.id}`, {
- method: "PUT",
- body: "{",
- }),
- { params: { id: drawing.id } },
- ),
- );
-
- expect(response.status).toBe(400);
- cleanup();
- });
-
- test("returns 404 for missing drawing", () => {
- const { api, cleanup } = withApi();
-
- const response = api.getDrawing(
- Object.assign(new Request("http://local/api/drawings/missing"), { params: { id: "missing" } }),
- );
-
- expect(response.status).toBe(404);
- cleanup();
- });
-
- test("updates a drawing scene", async () => {
- const { api, cleanup } = withApi();
- const created = await api.createDrawing().json();
-
- const response = await api.updateDrawing(
- Object.assign(
- new Request(`http://local/api/drawings/${created.id}`, {
- method: "PUT",
- body: JSON.stringify({
- elements: [{ id: "shape" }],
- appState: {},
- files: {},
- }),
- }),
- { params: { id: created.id } },
- ),
- );
-
- expect(response.status).toBe(200);
- cleanup();
- });
-});
diff --git a/src/api.ts b/src/api.ts
deleted file mode 100644
index 521c204..0000000
--- a/src/api.ts
+++ /dev/null
@@ -1,119 +0,0 @@
-import { type DrawingStore } from "./db";
-import { HttpError, normalizeTitle, parseJsonBody, parseSceneText } from "./scene";
-
-type RouteRequest = Request & {
- params?: Record
;
-};
-
-function json(data: unknown, init?: ResponseInit): Response {
- return Response.json(data, init);
-}
-
-function errorResponse(error: unknown): Response {
- if (error instanceof HttpError) {
- return json({ ok: false, error: error.message }, { status: error.status });
- }
-
- console.error(error);
- return json({ ok: false, error: "Internal server error" }, { status: 500 });
-}
-
-export function createApi(store: DrawingStore) {
- return {
- health() {
- return json({ ok: true });
- },
-
- listDrawings() {
- return json(store.listDrawings());
- },
-
- createDrawing() {
- const drawing = store.createDrawing();
- return json(
- {
- id: drawing.id,
- title: drawing.title,
- createdAt: drawing.createdAt,
- updatedAt: drawing.updatedAt,
- ...drawing.scene,
- },
- { status: 201 },
- );
- },
-
- getDrawing(request: RouteRequest) {
- const id = request.params?.id;
- const drawing = id ? store.getDrawing(id) : null;
-
- if (!drawing) {
- return json({ ok: false, error: "Drawing not found" }, { status: 404 });
- }
-
- return json({
- id: drawing.id,
- title: drawing.title,
- createdAt: drawing.createdAt,
- updatedAt: drawing.updatedAt,
- ...drawing.scene,
- });
- },
-
- async updateDrawing(request: RouteRequest) {
- try {
- const id = request.params?.id;
- if (!id) {
- throw new HttpError(400, "Missing drawing id");
- }
-
- const text = await request.text();
- const raw = parseJsonBody>(text);
- const hasTitle = Object.hasOwn(raw, "title");
- const hasScene =
- Object.hasOwn(raw, "elements") ||
- Object.hasOwn(raw, "appState") ||
- Object.hasOwn(raw, "files");
-
- if (!hasTitle && !hasScene) {
- throw new HttpError(400, "Nothing to update");
- }
-
- const scene = hasScene ? parseSceneText(text) : undefined;
- const updated = store.updateDrawing(id, {
- scene,
- title: hasTitle ? normalizeTitle(raw.title) : undefined,
- });
-
- if (!updated) {
- return json({ ok: false, error: "Drawing not found" }, { status: 404 });
- }
-
- return json({
- ok: true,
- drawing: {
- id: updated.id,
- title: updated.title,
- createdAt: updated.createdAt,
- updatedAt: updated.updatedAt,
- },
- });
- } catch (error) {
- return errorResponse(error);
- }
- },
-
- deleteDrawing(request: RouteRequest) {
- const id = request.params?.id;
- if (!id) {
- return json({ ok: false, error: "Missing drawing id" }, { status: 400 });
- }
-
- const { deleted, next } = store.deleteDrawing(id);
- if (!deleted) {
- return json({ ok: false, error: "Drawing not found" }, { status: 404 });
- }
-
- return json({ ok: true, nextId: next?.id ?? null });
- },
- };
-}
diff --git a/src/client.tsx b/src/client.tsx
index 07abc65..0594a61 100644
--- a/src/client.tsx
+++ b/src/client.tsx
@@ -1,7 +1,6 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { App } from "./App";
-import "./styles.css";
const root = document.getElementById("root");
diff --git a/src/db.test.ts b/src/db.test.ts
deleted file mode 100644
index 8c52d38..0000000
--- a/src/db.test.ts
+++ /dev/null
@@ -1,50 +0,0 @@
-import { afterEach, describe, expect, test } from "bun:test";
-import { mkdtempSync, rmSync } from "node:fs";
-import { tmpdir } from "node:os";
-import { join } from "node:path";
-import { createDrawingStore } from "./db";
-
-const cleanup: string[] = [];
-
-afterEach(() => {
- while (cleanup.length > 0) {
- const dir = cleanup.pop();
- if (dir) {
- rmSync(dir, { recursive: true, force: true });
- }
- }
-});
-
-function createTempStore() {
- const dir = mkdtempSync(join(tmpdir(), "excali-"));
- cleanup.push(dir);
- return createDrawingStore(join(dir, "test.sqlite"));
-}
-
-describe("drawing store", () => {
- test("creates, updates, lists, and deletes drawings", () => {
- const store = createTempStore();
- const created = store.createDrawing();
-
- expect(created.title).toBe("Untitled");
- expect(created.scene.appState.theme).toBe("dark");
- expect(store.listDrawings()).toHaveLength(1);
-
- const updated = store.updateDrawing(created.id, {
- title: "Flow",
- scene: {
- elements: [{ id: "one" }],
- appState: { gridSize: 20 },
- files: {},
- },
- });
-
- expect(updated?.title).toBe("Flow");
- expect(updated?.scene.elements).toHaveLength(1);
-
- const removed = store.deleteDrawing(created.id);
- expect(removed.deleted).toBe(true);
- expect(store.listDrawings()).toHaveLength(1);
- expect(removed.next?.id).not.toBe(created.id);
- });
-});
diff --git a/src/db.ts b/src/db.ts
deleted file mode 100644
index 987dd01..0000000
--- a/src/db.ts
+++ /dev/null
@@ -1,209 +0,0 @@
-import { Database } from "bun:sqlite";
-import { randomUUID } from "node:crypto";
-import { mkdirSync } from "node:fs";
-import { dirname } from "node:path";
-import { DEFAULT_TITLE, type DrawingMeta, type DrawingRecord, type ScenePayload } from "./shared";
-import { coerceStoredScene, emptyScene, normalizeTitle } from "./scene";
-
-type DrawingRow = {
- id: string;
- title: string;
- data: string;
- createdAt: string;
- updatedAt: string;
-};
-
-type DrawingMetaRow = Omit;
-
-export type DrawingStore = ReturnType;
-
-function createId(): string {
- return randomUUID().replaceAll("-", "").slice(0, 12);
-}
-
-function resolveDatabasePath(): string {
- if (process.env.DATABASE_PATH) {
- return process.env.DATABASE_PATH;
- }
-
- try {
- mkdirSync("/data", { recursive: true });
- return "/data/excalidraw.sqlite";
- } catch {
- return `${process.cwd()}/data/excalidraw.sqlite`;
- }
-}
-
-function readMeta(row: DrawingMetaRow | null | undefined): DrawingMeta | null {
- if (!row) {
- return null;
- }
-
- return {
- id: row.id,
- title: row.title,
- createdAt: row.createdAt,
- updatedAt: row.updatedAt,
- };
-}
-
-function readRow(row: DrawingRow | null | undefined): DrawingRecord | null {
- if (!row) {
- return null;
- }
-
- return {
- ...readMeta(row)!,
- scene: coerceStoredScene(JSON.parse(row.data)),
- };
-}
-
-export function createDrawingStore(databasePath = resolveDatabasePath()) {
- mkdirSync(dirname(databasePath), { recursive: true });
-
- const db = new Database(databasePath, { create: true, strict: true });
- db.exec(`
- PRAGMA journal_mode = WAL;
-
- CREATE TABLE IF NOT EXISTS drawings (
- id TEXT PRIMARY KEY,
- title TEXT NOT NULL,
- data TEXT NOT NULL,
- created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
- updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
- );
-
- CREATE INDEX IF NOT EXISTS drawings_updated_at_idx
- ON drawings(updated_at DESC, created_at DESC);
- `);
-
- const listQuery = db.query(`
- SELECT
- id,
- title,
- created_at AS createdAt,
- updated_at AS updatedAt
- FROM drawings
- ORDER BY updated_at DESC, created_at DESC
- `);
-
- const getQuery = db.query(`
- SELECT
- id,
- title,
- data,
- created_at AS createdAt,
- updated_at AS updatedAt
- FROM drawings
- WHERE id = ?1
- `);
-
- const createQuery = db.query(`
- INSERT INTO drawings (id, title, data)
- VALUES (?1, ?2, ?3)
- `);
-
- const updateSceneQuery = db.query(`
- UPDATE drawings
- SET data = ?2, updated_at = CURRENT_TIMESTAMP
- WHERE id = ?1
- `);
-
- const updateTitleQuery = db.query(`
- UPDATE drawings
- SET title = ?2, updated_at = CURRENT_TIMESTAMP
- WHERE id = ?1
- `);
-
- const updateBothQuery = db.query(`
- UPDATE drawings
- SET title = ?2, data = ?3, updated_at = CURRENT_TIMESTAMP
- WHERE id = ?1
- `);
-
- const deleteQuery = db.query(`
- DELETE FROM drawings
- WHERE id = ?1
- `);
-
- function listDrawings(): DrawingMeta[] {
- return (listQuery.all() as DrawingMetaRow[]).map((row) => readMeta(row)!);
- }
-
- function getDrawing(id: string): DrawingRecord | null {
- return readRow(getQuery.get(id) as DrawingRow | null | undefined);
- }
-
- function createDrawing(title = DEFAULT_TITLE): DrawingRecord {
- const id = createId();
- createQuery.run(id, normalizeTitle(title), JSON.stringify(emptyScene()));
- return getDrawing(id)!;
- }
-
- function getLatestDrawing(): DrawingMeta | null {
- return listDrawings()[0] ?? null;
- }
-
- function ensureInitialDrawing(): DrawingMeta {
- return getLatestDrawing() ?? createDrawing();
- }
-
- function updateDrawing(id: string, update: { scene?: ScenePayload; title?: string }): DrawingRecord | null {
- const existing = getDrawing(id);
- if (!existing) {
- return null;
- }
-
- const hasScene = update.scene !== undefined;
- const hasTitle = update.title !== undefined;
-
- if (!hasScene && !hasTitle) {
- return existing;
- }
-
- if (hasScene && hasTitle) {
- updateBothQuery.run(id, normalizeTitle(update.title), JSON.stringify(update.scene));
- } else if (hasScene) {
- updateSceneQuery.run(id, JSON.stringify(update.scene));
- } else {
- updateTitleQuery.run(id, normalizeTitle(update.title));
- }
-
- return getDrawing(id);
- }
-
- function deleteDrawing(id: string): { deleted: boolean; next: DrawingMeta | null } {
- const changes = Number(deleteQuery.run(id).changes);
- if (changes === 0) {
- return { deleted: false, next: null };
- }
-
- return {
- deleted: true,
- next: ensureInitialDrawing(),
- };
- }
-
- function close() {
- db.close(false);
- }
-
- return {
- databasePath,
- close,
- listDrawings,
- getDrawing,
- createDrawing,
- getLatestDrawing,
- ensureInitialDrawing,
- updateDrawing,
- deleteDrawing,
- };
-}
-
-let defaultStore: DrawingStore | null = null;
-
-export function getDefaultDrawingStore(): DrawingStore {
- defaultStore ??= createDrawingStore();
- return defaultStore;
-}
diff --git a/src/dev-server.tsx b/src/dev-server.tsx
deleted file mode 100644
index 942a942..0000000
--- a/src/dev-server.tsx
+++ /dev/null
@@ -1,19 +0,0 @@
-import index from "./index.html";
-import { createServer } from "./server";
-
-export function createDevServer() {
- const server = createServer();
-
- return {
- ...server,
- routes: {
- ...server.routes,
- "/d/:id": index,
- },
- } satisfies Parameters[0];
-}
-
-if (import.meta.main) {
- const server = Bun.serve(createDevServer());
- console.log(`Listening on http://${server.hostname}:${server.port}`);
-}
diff --git a/src/scene.test.ts b/src/scene.test.ts
deleted file mode 100644
index f8f1a91..0000000
--- a/src/scene.test.ts
+++ /dev/null
@@ -1,31 +0,0 @@
-import { describe, expect, test } from "bun:test";
-import { HttpError, MAX_SCENE_BYTES, emptyScene, normalizeScene, parseJsonBody } from "./scene";
-
-describe("scene helpers", () => {
- test("creates empty scenes with dark theme by default", () => {
- const scene = emptyScene();
-
- expect(scene.appState.theme).toBe("dark");
- });
-
- test("normalizes volatile app state fields", () => {
- const scene = normalizeScene({
- elements: [],
- appState: {
- selectedElementIds: { one: true },
- viewModeEnabled: false,
- },
- files: {},
- });
-
- expect(scene.appState.selectedElementIds).toBeUndefined();
- expect(scene.appState.viewModeEnabled).toBe(false);
- });
-
- test("rejects oversized payloads", () => {
- const text = "x".repeat(MAX_SCENE_BYTES + 1);
-
- expect(() => parseJsonBody(text)).toThrow(HttpError);
- expect(() => parseJsonBody(text)).toThrow("Payload too large");
- });
-});
diff --git a/src/scene.ts b/src/scene.ts
deleted file mode 100644
index 32435e9..0000000
--- a/src/scene.ts
+++ /dev/null
@@ -1,97 +0,0 @@
-import { DEFAULT_TITLE, type ScenePayload } from "./shared";
-
-export const MAX_SCENE_BYTES = 25 * 1024 * 1024;
-
-export class HttpError extends Error {
- constructor(
- readonly status: number,
- message: string,
- ) {
- super(message);
- }
-}
-
-const VOLATILE_APP_STATE_KEYS = new Set([
- "collaborators",
- "selectedElementIds",
- "selectedGroupIds",
- "editingElement",
- "editingLinearElement",
- "openMenu",
- "openSidebar",
- "contextMenu",
- "toast",
-]);
-
-export function emptyScene(): ScenePayload {
- return {
- elements: [],
- appState: { theme: "dark" },
- files: {},
- };
-}
-
-function isRecord(value: unknown): value is Record {
- return typeof value === "object" && value !== null && !Array.isArray(value);
-}
-
-export function normalizeTitle(input: unknown): string {
- if (typeof input !== "string") {
- return DEFAULT_TITLE;
- }
-
- const trimmed = input.trim();
- return trimmed.length > 0 ? trimmed : DEFAULT_TITLE;
-}
-
-export function normalizeScene(input: unknown): ScenePayload {
- if (!isRecord(input)) {
- throw new HttpError(400, "Body must be an object");
- }
-
- if (!Array.isArray(input.elements)) {
- throw new HttpError(400, "elements must be an array");
- }
-
- if (!isRecord(input.appState)) {
- throw new HttpError(400, "appState must be an object");
- }
-
- if (!isRecord(input.files)) {
- throw new HttpError(400, "files must be an object");
- }
-
- const appState = Object.fromEntries(
- Object.entries(input.appState).filter(([key]) => !VOLATILE_APP_STATE_KEYS.has(key)),
- );
-
- return {
- elements: input.elements,
- appState,
- files: input.files,
- };
-}
-
-export function parseJsonBody(text: string): T {
- if (new TextEncoder().encode(text).byteLength > MAX_SCENE_BYTES) {
- throw new HttpError(413, "Payload too large");
- }
-
- try {
- return JSON.parse(text) as T;
- } catch {
- throw new HttpError(400, "Invalid JSON");
- }
-}
-
-export function parseSceneText(text: string): ScenePayload {
- return normalizeScene(parseJsonBody(text));
-}
-
-export function coerceStoredScene(input: unknown): ScenePayload {
- try {
- return normalizeScene(input);
- } catch {
- return emptyScene();
- }
-}
diff --git a/src/server.test.ts b/src/server.test.ts
deleted file mode 100644
index b00a27e..0000000
--- a/src/server.test.ts
+++ /dev/null
@@ -1,70 +0,0 @@
-import { afterEach, describe, expect, test } from "bun:test";
-import { mkdtempSync, rmSync } from "node:fs";
-import { tmpdir } from "node:os";
-import { join } from "node:path";
-import { createDrawingStore, type DrawingStore } from "./db";
-import { createServer } from "./server";
-
-const cleanup: string[] = [];
-const stores: DrawingStore[] = [];
-
-afterEach(() => {
- while (stores.length > 0) {
- stores.pop()?.close();
- }
-
- while (cleanup.length > 0) {
- const dir = cleanup.pop();
- if (dir) {
- rmSync(dir, { recursive: true, force: true });
- }
- }
-});
-
-function createServerFixture() {
- const dir = mkdtempSync(join(tmpdir(), "excali-server-"));
- cleanup.push(dir);
-
- const store = createDrawingStore(join(dir, "test.sqlite"));
- stores.push(store);
-
- return {
- store,
- server: createServer({ drawingStore: store }),
- };
-}
-
-describe("createServer", () => {
- test("redirects / to the latest drawing", () => {
- const { server, store } = createServerFixture();
-
- const response = server.routes["/"]?.(new Request("http://local/")) as Response;
- const created = store.listDrawings();
-
- expect(created).toHaveLength(1);
- expect(response.status).toBe(302);
- expect(response.headers.get("location")).toBe(`http://local/d/${created[0]?.id}`);
- });
-
- test("keeps Bun responsible for API routes", async () => {
- const { server } = createServerFixture();
- const response = await server.routes["/api/health"]?.GET?.();
-
- expect(response?.status).toBe(200);
- expect(await response?.json()).toEqual({ ok: true });
- });
-
- test("does not expose a Bun static route for drawings in production", () => {
- const { server } = createServerFixture();
-
- expect(Object.hasOwn(server.routes, "/d/:id")).toBe(false);
- });
-
- test("returns JSON 404 for unmatched requests", async () => {
- const { server } = createServerFixture();
- const response = await server.fetch(new Request("http://local/index-abc123.js"));
-
- expect(response.status).toBe(404);
- expect(await response.json()).toEqual({ ok: false, error: "Not found" });
- });
-});
diff --git a/src/server.tsx b/src/server.tsx
deleted file mode 100644
index e1d9479..0000000
--- a/src/server.tsx
+++ /dev/null
@@ -1,40 +0,0 @@
-import { createApi } from "./api";
-import { type DrawingStore, getDefaultDrawingStore } from "./db";
-
-type CreateServerOptions = {
- drawingStore?: DrawingStore;
-};
-
-export function createServer(options: CreateServerOptions = {}) {
- const drawingStore = options.drawingStore ?? getDefaultDrawingStore();
- const api = createApi(drawingStore);
-
- return {
- port: Number(process.env.PORT ?? "3000"),
- hostname: process.env.HOST ?? "localhost",
- routes: {
- "/": (request: Request) => {
- const drawing = drawingStore.ensureInitialDrawing();
- return Response.redirect(new URL(`/d/${drawing.id}`, request.url), 302);
- },
- "/api/health": {
- GET: () => api.health(),
- },
- "/api/drawings": {
- GET: () => api.listDrawings(),
- POST: () => api.createDrawing(),
- },
- "/api/drawings/:id": {
- GET: (request: Request & { params: Record }) => api.getDrawing(request),
- PUT: (request: Request & { params: Record }) => api.updateDrawing(request),
- DELETE: (request: Request & { params: Record }) => api.deleteDrawing(request),
- },
- },
- fetch: (_request: Request) => Response.json({ ok: false, error: "Not found" }, { status: 404 }),
- } satisfies Parameters[0];
-}
-
-if (import.meta.main) {
- const server = Bun.serve(createServer());
- console.log(`Listening on http://${server.hostname}:${server.port}`);
-}
diff --git a/src/shared.ts b/src/shared.ts
deleted file mode 100644
index 5153b0d..0000000
--- a/src/shared.ts
+++ /dev/null
@@ -1,18 +0,0 @@
-export type ScenePayload = {
- elements: unknown[];
- appState: Record;
- files: Record;
-};
-
-export type DrawingMeta = {
- id: string;
- title: string;
- createdAt: string;
- updatedAt: string;
-};
-
-export type DrawingRecord = DrawingMeta & {
- scene: ScenePayload;
-};
-
-export const DEFAULT_TITLE = "Untitled";
diff --git a/src/styles.css b/src/styles.css
deleted file mode 100644
index 82b1ea5..0000000
--- a/src/styles.css
+++ /dev/null
@@ -1,297 +0,0 @@
-:root {
- color-scheme: light;
- font-family:
- Inter,
- ui-sans-serif,
- system-ui,
- -apple-system,
- BlinkMacSystemFont,
- "Segoe UI",
- sans-serif;
-}
-
-* {
- box-sizing: border-box;
-}
-
-html,
-body,
-#root {
- margin: 0;
- height: 100%;
-}
-
-body {
- background: #f5f5f4;
- color: #18181b;
-}
-
-button,
-input {
- font: inherit;
-}
-
-.app-shell {
- height: 100%;
- color: var(--app-text-color, #18181b);
- --app-sidebar-font-size: 0.875rem;
- --app-island-bg: #ffffff;
- --app-sidebar-bg: #ffffff;
- --app-sidebar-border: #e4e4e7;
- --app-sidebar-shadow: 0 24px 80px rgba(24, 24, 27, 0.22);
- --app-surface-lowest: #ffffff;
- --app-surface-low: #f4f4f5;
- --app-selected-bg: #f4f4f5;
- --app-text-color: #18181b;
- --app-selected-text-color: #18181b;
- --app-disabled-color: #a1a1aa;
- --app-input-bg: #ffffff;
- --app-input-border: #d4d4d8;
- --app-input-color: #18181b;
- --app-button-border: #d4d4d8;
- --app-button-hover-bg: #f4f4f5;
- --app-button-active-bg: #e4e4e7;
- --app-button-active-border: #5b8def;
- --app-overlay-bg: rgba(255, 255, 255, 0.88);
-}
-
-.sidebar {
- position: fixed;
- top: 0;
- left: 0;
- z-index: 40;
- display: flex;
- height: 100%;
- width: min(340px, calc(100vw - 32px));
- flex-direction: column;
- border-right: 1px solid var(--app-sidebar-border);
- background: var(--app-sidebar-bg);
- box-shadow: var(--app-sidebar-shadow);
- transform: translateX(calc(-100% - 24px));
- transition:
- transform 180ms ease,
- box-shadow 180ms ease;
-}
-
-.sidebar-open {
- transform: translateX(0);
-}
-
-.sidebar-header {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 12px;
- border-bottom: 1px solid var(--app-sidebar-border);
- padding: 16px;
-}
-
-.sidebar-header h1 {
- margin: 0;
- font-size: var(--app-sidebar-font-size);
- font-weight: 600;
- color: var(--app-text-color);
-}
-
-.sidebar-actions {
- display: flex;
- align-items: center;
- gap: 8px;
-}
-
-.sidebar-actions .primary-button,
-.sidebar-actions .icon-button {
- height: 32px;
-}
-
-.drawing-list {
- flex: 1;
- overflow: auto;
- padding: 8px;
-}
-
-.drawing-item {
- display: flex;
- align-items: center;
- gap: 4px;
- border-radius: 8px;
- padding: 4px 12px;
-}
-
-.drawing-item-active {
- background: var(--app-selected-bg);
- color: var(--app-selected-text-color);
-}
-
-.drawing-link {
- flex: 1 1 auto;
- min-width: 0;
- border: 0;
- background: transparent;
- color: inherit;
- padding: 8px 0;
- text-align: left;
- font-size: var(--app-sidebar-font-size);
-}
-
-.drawing-title,
-.title-input {
- display: block;
- width: 100%;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.title-input {
- border: 1px solid var(--app-input-border);
- border-radius: 6px;
- background: var(--app-input-bg);
- color: var(--app-input-color);
- font-size: var(--app-sidebar-font-size);
- padding: 6px 8px;
-}
-
-.editor-shell {
- position: relative;
- height: 100%;
-}
-
-.app-actions {
- position: fixed;
- right: max(16px, calc(env(safe-area-inset-right) + 16px));
- bottom: calc(max(16px, env(safe-area-inset-bottom)) + 48px);
- z-index: 20;
- display: flex;
-}
-
-.app-actions-toggle {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- width: var(--lg-button-size, 2.25rem);
- height: var(--lg-button-size, 2.25rem);
- padding: 0;
- border: none;
- border-radius: var(--border-radius-lg, 12px);
- box-shadow: 0 0 0 1px var(--app-surface-lowest);
- background: var(--app-surface-low);
- color: var(--app-text-color);
-}
-
-.app-actions-toggle svg {
- width: 1rem;
- height: 1rem;
- overflow: visible;
- transform: translateY(0.5px) scale(1.08);
- transform-origin: center;
-}
-
-.primary-button,
-.secondary-button,
-.icon-button {
- border: 1px solid var(--app-button-border);
- border-radius: 8px;
- background: var(--app-island-bg);
- color: var(--app-text-color);
- transition:
- background-color 120ms ease,
- border-color 120ms ease,
- color 120ms ease,
- box-shadow 120ms ease;
-}
-
-.primary-button,
-.secondary-button {
- cursor: pointer;
- font-size: var(--app-sidebar-font-size);
- font-weight: 500;
- padding: 8px 12px;
-}
-
-.primary-button:hover,
-.secondary-button:hover,
-.icon-button:hover {
- background: var(--app-button-hover-bg);
-}
-
-.primary-button:active,
-.secondary-button:active,
-.icon-button:active {
- background: var(--app-button-active-bg);
- border-color: var(--app-button-active-border);
-}
-
-.secondary-button:disabled {
- cursor: not-allowed;
- opacity: 0.6;
- color: var(--app-disabled-color);
-}
-
-.icon-button {
- display: inline-flex;
- flex: 0 0 32px;
- align-items: center;
- justify-content: center;
- width: 32px;
- height: 32px;
- cursor: pointer;
-}
-
-.sidebar-backdrop {
- position: fixed;
- inset: 0;
- z-index: 30;
- background: color-mix(in srgb, var(--app-overlay-bg) 72%, #000000);
- opacity: 0;
- pointer-events: none;
- transition: opacity 180ms ease;
-}
-
-.sidebar-backdrop-open {
- opacity: 1;
- pointer-events: auto;
-}
-
-.editor-frame,
-.editor-loading {
- height: 100%;
-}
-
-.editor-loading {
- display: grid;
- place-items: center;
- color: var(--app-text-color);
-}
-
-.app-actions-toggle:hover {
- background: var(--app-button-hover-bg);
-}
-
-.app-actions-toggle:active {
- box-shadow: 0 0 0 1px var(--app-button-active-border);
- background: var(--app-button-active-bg);
-}
-
-.sr-only {
- position: absolute;
- width: 1px;
- height: 1px;
- padding: 0;
- margin: -1px;
- overflow: hidden;
- clip: rect(0, 0, 0, 0);
- white-space: nowrap;
- border: 0;
-}
-
-@media (max-width: 900px) {
- .sidebar {
- width: min(300px, calc(100vw - 20px));
- }
-
- .app-actions {
- right: max(16px, calc(env(safe-area-inset-right) + 16px));
- bottom: calc(max(16px, env(safe-area-inset-bottom)) + 56px);
- }
-}