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); - } -}