diff --git a/frame-configurator/src/types.ts b/frame-configurator/src/types.ts
index fc8990de..d246dbf7 100644
--- a/frame-configurator/src/types.ts
+++ b/frame-configurator/src/types.ts
@@ -5,7 +5,18 @@ export type TinyPatchworkLayoutDoc = {
moduleSettingsUrl: AutomergeUrl;
frameToolId: string;
- accountSidebarToolId: string;
- contextToolIds: string[];
- documentToolbarToolIds: string[];
+ /** @deprecated legacy fields, migrated into the threepane config doc */
+ accountSidebarToolId?: string;
+ contextToolIds?: string[];
+ documentToolbarToolIds?: string[];
+
+ tools?: Record;
+};
+
+export type ToolRef = [toolId: string, docId: AutomergeUrl];
+
+export type ThreepaneConfigDoc = {
+ sidebar: { widgets: ToolRef[] };
+ contextbar: { tabs: ToolRef[] };
+ doctitle: { tools: ToolRef[] };
};
diff --git a/patchwork-frame/src/components/ContextSidebar.tsx b/patchwork-frame/src/components/ContextSidebar.tsx
deleted file mode 100644
index ca93a7ae..00000000
--- a/patchwork-frame/src/components/ContextSidebar.tsx
+++ /dev/null
@@ -1,98 +0,0 @@
-import type { AutomergeUrl } from "@automerge/automerge-repo";
-import {
- getRegistry,
- type ToolDescription,
-} from "@inkandswitch/patchwork-plugins";
-import { createSignal, onCleanup, onMount, For, Show } from "solid-js";
-import type { Accessor } from "solid-js";
-import { Sidebar } from "./Sidebar";
-
-type ContextSidebarProps = {
- contextToolIds: Accessor;
- docUrl: AutomergeUrl;
- /**
- * Selected tab, owned by the frame *above* the branch-switch boundary so it
- * survives document and branch switches (the tool content below still
- * remounts and re-scopes per draft).
- */
- selectedToolId: Accessor;
- setSelectedToolId: (id: string) => void;
- isCollapsed: Accessor;
- width: Accessor;
- onMouseDown: (side: "left" | "right", e: MouseEvent) => void;
- onToggleClick: (side: "left" | "right", e: MouseEvent) => void;
- onClose: () => void;
-};
-
-/**
- * The document context sidebar: a tabbed Solid component that hosts the
- * configured context tools (`AccountDoc.contextToolIds`). Replaces the former
- * `context-sidebar` legacy tool, whose selected-tab state was local and reset
- * on every branch switch. Here the selection is lifted into the frame, so only
- * the per-tab tool content remounts.
- */
-export function ContextSidebar(props: ContextSidebarProps) {
- const toolRegistry = getRegistry("patchwork:tool");
-
- // Tool descriptions can register/load late; bump a version so tab labels
- // recompute when the registry changes (mirrors the old tool's behaviour).
- const [registryVersion, setRegistryVersion] = createSignal(0);
- onMount(() => {
- const off = toolRegistry.on("changed", () => {
- setRegistryVersion((v) => v + 1);
- });
- onCleanup(off);
- });
-
- const toolIds = () => props.contextToolIds() ?? [];
-
- // The selection may name a tool that isn't in the current list (or be unset);
- // fall back to the first tab so there's always a valid active tool.
- const activeToolId = () => {
- const ids = toolIds();
- const selected = props.selectedToolId();
- return selected && ids.includes(selected) ? selected : ids[0];
- };
-
- const toolName = (id: string) => {
- registryVersion();
- return toolRegistry.get(id)?.name ?? id;
- };
-
- return (
-
-
-
-
-
- {(id) => (
-
- )}
-
-
-
-
-
- {(toolId) => (
-
- )}
-
-
-
-
- );
-}
diff --git a/patchwork-frame/src/components/DocumentToolbar.tsx b/patchwork-frame/src/components/DocumentToolbar.tsx
deleted file mode 100644
index d22f5119..00000000
--- a/patchwork-frame/src/components/DocumentToolbar.tsx
+++ /dev/null
@@ -1,94 +0,0 @@
-import type { AutomergeUrl } from "@automerge/automerge-repo";
-import type { Accessor } from "solid-js";
-import { Show } from "solid-js";
-
-interface DocumentToolbarProps {
- toolIds: Accessor;
- docUrl: Accessor;
- /** Show a "bring back the left sidebar" button at the start of the toolbar. */
- showLeftSidebarButton?: Accessor;
- onShowLeftSidebar?: () => void;
- /** Show a "bring back the right sidebar" button at the end of the toolbar. */
- showRightSidebarButton?: Accessor;
- onShowRightSidebar?: () => void;
-}
-
-export function DocumentToolbar(props: DocumentToolbarProps) {
- return (
-
- {(ids) => (
-
diff --git a/threepane/src/account/cleanupLegacyAccountFields.ts b/threepane/src/account/cleanupLegacyAccountFields.ts
new file mode 100644
index 00000000..7861e3e9
--- /dev/null
+++ b/threepane/src/account/cleanupLegacyAccountFields.ts
@@ -0,0 +1,41 @@
+import type { DocHandle } from "@automerge/automerge-repo";
+import type { AccountDoc } from "../types";
+
+/**
+ * Opt-in, manual cleanup: removes the legacy account fields that have been
+ * migrated into the threepane config doc. NOT run automatically — only call this
+ * once the threepane migration has shipped everywhere and you no longer need to
+ * switch back to a build that reads these fields.
+ *
+ * Safe to run only after `account.tools.threepane` is set (i.e. migrated).
+ */
+export function cleanupLegacyAccountFields(
+ accountHandle: DocHandle
+): { removed: string[] } {
+ const account = accountHandle.doc();
+ if (!account?.tools?.["threepane"]) {
+ console.warn(
+ "cleanupLegacyAccountFields: not migrated yet (no tools.threepane); skipping"
+ );
+ return { removed: [] };
+ }
+
+ const legacyFields: (keyof AccountDoc)[] = [
+ "accountSidebarToolId",
+ "contextToolIds",
+ "documentToolbarToolIds",
+ ];
+
+ const removed: string[] = [];
+ accountHandle.change((doc) => {
+ for (const field of legacyFields) {
+ if (field in doc) {
+ delete (doc as Record)[field];
+ removed.push(field);
+ }
+ }
+ });
+
+ console.info("cleanupLegacyAccountFields: removed", removed);
+ return { removed };
+}
diff --git a/patchwork-frame/src/account/ensureSubdocs.ts b/threepane/src/account/ensureSubdocs.ts
similarity index 97%
rename from patchwork-frame/src/account/ensureSubdocs.ts
rename to threepane/src/account/ensureSubdocs.ts
index 2ff831d2..fa4387fb 100644
--- a/patchwork-frame/src/account/ensureSubdocs.ts
+++ b/threepane/src/account/ensureSubdocs.ts
@@ -16,7 +16,7 @@ type SubdocField = "rootFolderUrl" | "moduleSettingsUrl" | "contactUrl";
* by the time this runs, but subdoc datatypes (contact, etc.) can live in
* separately-loaded plugin bundles, so we have to tolerate late registration.
*/
-async function loadDatatypeWhenReady(
+export async function loadDatatypeWhenReady(
id: string
): Promise | undefined> {
const registry = getRegistry("patchwork:datatype");
diff --git a/threepane/src/account/ensureThreepaneConfig.ts b/threepane/src/account/ensureThreepaneConfig.ts
new file mode 100644
index 00000000..5158e0bb
--- /dev/null
+++ b/threepane/src/account/ensureThreepaneConfig.ts
@@ -0,0 +1,65 @@
+import type { DocHandle, Repo } from "@automerge/automerge-repo";
+import { createDocOfDatatype2 } from "@inkandswitch/patchwork-plugins";
+import type { AccountDoc, ThreepaneConfigDoc, ToolRef } from "../types";
+import { loadDatatypeWhenReady } from "./ensureSubdocs";
+
+// Title + spacer are intrinsic to the frame's top bar, never configured tools.
+const INTRINSIC_DOCTITLE_TOOLS = new Set(["document-title", "spacer"]);
+
+/**
+ * Lazily create the threepane layout config doc and point `account.tools.threepane`
+ * at it, migrating the legacy `account.*` arrays into its lanes.
+ *
+ * Non-destructive: the old `documentToolbarToolIds` / `contextToolIds` /
+ * `accountSidebarToolId` fields are left untouched so older builds keep working
+ * and you can switch branches freely during the PR. Run the (separate, opt-in)
+ * cleanupLegacyAccountFields script to remove them later.
+ *
+ * Idempotent: returns early once `account.tools.threepane` is set.
+ */
+export async function ensureThreepaneConfig(
+ accountHandle: DocHandle,
+ repo: Repo
+) {
+ if (accountHandle.doc()?.tools?.["threepane"]) return;
+
+ const datatype = await loadDatatypeWhenReady(
+ "threepane:config"
+ );
+ if (!datatype) {
+ console.warn("frame: threepane:config datatype never registered");
+ return;
+ }
+
+ // Re-check after the await in case another tab migrated concurrently.
+ if (accountHandle.doc()?.tools?.["threepane"]) return;
+
+ const account = accountHandle.doc();
+ const accountDocUrl = accountHandle.url;
+
+ // doctitle + contextbar migrate with the account doc as a placeholder docid
+ // (the frame still feeds doctitle the selected doc / contextbar the account
+ // doc). The sidebar starts empty — the document list is now an opt-in widget.
+ const doctitleTools: ToolRef[] = (account?.documentToolbarToolIds ?? [])
+ .filter((id) => !INTRINSIC_DOCTITLE_TOOLS.has(id))
+ .map((id) => [id, accountDocUrl]);
+ const contextTabs: ToolRef[] = (account?.contextToolIds ?? []).map((id) => [
+ id,
+ accountDocUrl,
+ ]);
+
+ const configHandle = await createDocOfDatatype2(
+ datatype,
+ repo
+ );
+ configHandle.change((doc) => {
+ doc.doctitle.tools = doctitleTools;
+ doc.contextbar.tabs = contextTabs;
+ doc.sidebar.widgets = [];
+ });
+
+ accountHandle.change((acc) => {
+ if (!acc.tools) acc.tools = {};
+ if (!acc.tools["threepane"]) acc.tools["threepane"] = configHandle.url;
+ });
+}
diff --git a/threepane/src/components/ContextSidebar.tsx b/threepane/src/components/ContextSidebar.tsx
new file mode 100644
index 00000000..2b87a8c8
--- /dev/null
+++ b/threepane/src/components/ContextSidebar.tsx
@@ -0,0 +1,58 @@
+import type { AutomergeUrl } from "@automerge/automerge-repo";
+import { Show } from "solid-js";
+import type { Accessor } from "solid-js";
+import { Sidebar } from "./Sidebar";
+import { Tray } from "./Tray";
+
+type ContextSidebarProps = {
+ contextToolIds: Accessor;
+ docUrl: AutomergeUrl;
+ /**
+ * Selected tab, owned by the frame *above* the branch-switch boundary so it
+ * survives document and branch switches. The tab bar itself now lives in the
+ * top toolbar (see `ContextTabs`); here we only render the active tool.
+ */
+ selectedToolId: Accessor;
+ isCollapsed: Accessor;
+ width: Accessor;
+ onMouseDown: (side: "left" | "right", e: MouseEvent) => void;
+ onToggleClick: (side: "left" | "right", e: MouseEvent) => void;
+};
+
+/**
+ * The document context sidebar body: renders the active context tool's content
+ * plus the bottom tray. The tab bar that selects the active tool lives in the
+ * top toolbar (`ContextTabs`).
+ */
+export function ContextSidebar(props: ContextSidebarProps) {
+ const toolIds = () => props.contextToolIds() ?? [];
+
+ // The selection may name a tool that isn't in the current list (or be unset);
+ // fall back to the first tab so there's always a valid active tool.
+ const activeToolId = () => {
+ const ids = toolIds();
+ const selected = props.selectedToolId();
+ return selected && ids.includes(selected) ? selected : ids[0];
+ };
+
+ return (
+
+
+
+
+ {(toolId) => (
+
+ )}
+
+
+
+
+
+ );
+}
diff --git a/threepane/src/components/ContextTabs.tsx b/threepane/src/components/ContextTabs.tsx
new file mode 100644
index 00000000..6dfdde74
--- /dev/null
+++ b/threepane/src/components/ContextTabs.tsx
@@ -0,0 +1,62 @@
+import {
+ getRegistry,
+ type ToolDescription,
+} from "@inkandswitch/patchwork-plugins";
+import { createSignal, onCleanup, onMount, For } from "solid-js";
+import type { Accessor } from "solid-js";
+
+type ContextTabsProps = {
+ contextToolIds: Accessor;
+ selectedToolId: Accessor;
+ setSelectedToolId: (id: string) => void;
+};
+
+/**
+ * The context sidebar's tab bar, lifted out of the sidebar and into the top
+ * toolbar (it sits above the right sidebar). Horizontally scrollable when the
+ * tabs overflow. Selection is owned by the frame so it survives branch switches.
+ */
+export function ContextTabs(props: ContextTabsProps) {
+ const toolRegistry = getRegistry("patchwork:tool");
+
+ // Tool descriptions can register/load late; bump a version so tab labels
+ // recompute when the registry changes.
+ const [registryVersion, setRegistryVersion] = createSignal(0);
+ onMount(() => {
+ const off = toolRegistry.on("changed", () => {
+ setRegistryVersion((v) => v + 1);
+ });
+ onCleanup(off);
+ });
+
+ const toolIds = () => props.contextToolIds() ?? [];
+
+ const activeToolId = () => {
+ const ids = toolIds();
+ const selected = props.selectedToolId();
+ return selected && ids.includes(selected) ? selected : ids[0];
+ };
+
+ const toolName = (id: string) => {
+ registryVersion();
+ return toolRegistry.get(id)?.name ?? id;
+ };
+
+ return (
+
+
+ {(id) => (
+
+ )}
+
+
+ );
+}
diff --git a/threepane/src/components/DocumentTitle.tsx b/threepane/src/components/DocumentTitle.tsx
new file mode 100644
index 00000000..34153755
--- /dev/null
+++ b/threepane/src/components/DocumentTitle.tsx
@@ -0,0 +1,64 @@
+import type { AutomergeUrl, Repo } from "@automerge/automerge-repo";
+import { useDocument } from "@automerge/automerge-repo-solid-primitives";
+import {
+ getType,
+ type HasPatchworkMetadata,
+} from "@inkandswitch/patchwork-filesystem";
+import {
+ getRegistry,
+ isLoadedPlugin,
+ type Datatype,
+} from "@inkandswitch/patchwork-plugins";
+import {
+ createEffect,
+ createMemo,
+ createSignal,
+ onCleanup,
+ onMount,
+ type Accessor,
+} from "solid-js";
+
+/**
+ * The document title, rendered intrinsically by the frame (ported from the
+ * standalone `document-title` tool): resolves the doc's datatype and asks it for
+ * a title. Kept in the frame so the top bar owns its placement and sizing.
+ */
+export function DocumentTitle(props: {
+ docUrl: Accessor;
+ repo: Repo;
+}) {
+ const [doc] = useDocument(() => props.docUrl(), {
+ repo: props.repo,
+ });
+ const registry = getRegistry("patchwork:datatype");
+
+ // Datatypes can register/load late; bump a version so the title recomputes.
+ const [registryVersion, setRegistryVersion] = createSignal(0);
+ onMount(() => {
+ const off = registry.on("changed", () => setRegistryVersion((v) => v + 1));
+ onCleanup(off);
+ });
+
+ const typeId = createMemo(() => {
+ const d = doc();
+ return d ? getType(d) : undefined;
+ });
+
+ // Ensure the datatype module is loaded so getTitle is callable.
+ createEffect(() => {
+ const id = typeId();
+ if (id) void registry.load(id);
+ });
+
+ const title = createMemo(() => {
+ registryVersion();
+ const d = doc();
+ const id = typeId();
+ if (!d || !id) return undefined;
+ const datatype = registry.get(id);
+ if (!datatype || !isLoadedPlugin(datatype)) return undefined;
+ return datatype.module.getTitle(d);
+ });
+
+ return {title() ?? "Untitled"};
+}
diff --git a/threepane/src/components/FrameTopBar.tsx b/threepane/src/components/FrameTopBar.tsx
new file mode 100644
index 00000000..e880f3d0
--- /dev/null
+++ b/threepane/src/components/FrameTopBar.tsx
@@ -0,0 +1,155 @@
+import type { AutomergeUrl, Repo } from "@automerge/automerge-repo";
+import { For, Show, type Accessor } from "solid-js";
+import { ContextTabs } from "./ContextTabs";
+import { DocumentTitle } from "./DocumentTitle";
+
+// Title + spacer are rendered intrinsically by the frame's top bar, so they're
+// dropped from the configured doctitle tools (which sit to the right).
+const TITLE_TOOL = "document-title";
+const SPACER_TOOL = "spacer";
+
+type FrameTopBarProps = {
+ repo: Repo;
+ docUrl: Accessor;
+ toolIds: Accessor;
+
+ hasLeftSidebar: Accessor;
+ isLeftCollapsed: Accessor;
+ onToggleLeft: () => void;
+
+ contextToolIds: Accessor;
+ selectedContextToolId: Accessor;
+ setSelectedContextToolId: (id: string) => void;
+ isRightCollapsed: Accessor;
+ rightWidth: Accessor;
+ onToggleRight: () => void;
+};
+
+/**
+ * The full-width top toolbar: a left sidebar toggle, the document tool views,
+ * and — above the right sidebar — the context tabs with a right sidebar toggle.
+ * Spans the main column; the left sidebar's reserved top margin lines up with it
+ * so it reads as one continuous bar across the top.
+ */
+export function FrameTopBar(props: FrameTopBarProps) {
+ const hasRight = () => !!props.contextToolIds()?.length;
+ const docToolIds = () =>
+ (props.toolIds() ?? []).filter(
+ (id) => id !== TITLE_TOOL && id !== SPACER_TOOL
+ );
+
+ return (
+
+
+
+
+
+ {/* document title, rendered intrinsically: shrinks to the title length,
+ capped at half the bar. */}
+
+
+
+
+
+
+ {/* built-in spacer */}
+
+
+ {/* configured doctitle tools, at the end on the right, scrollable */}
+
+
+
+ {(id) => }
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+// lucide `panel-left`
+function PanelLeftIcon() {
+ return (
+
+ );
+}
+
+// lucide `panel-right`
+function PanelRightIcon() {
+ return (
+
+ );
+}
diff --git a/patchwork-frame/src/components/MainDocumentView.tsx b/threepane/src/components/MainDocumentView.tsx
similarity index 100%
rename from patchwork-frame/src/components/MainDocumentView.tsx
rename to threepane/src/components/MainDocumentView.tsx
diff --git a/patchwork-frame/src/components/Sidebar.tsx b/threepane/src/components/Sidebar.tsx
similarity index 100%
rename from patchwork-frame/src/components/Sidebar.tsx
rename to threepane/src/components/Sidebar.tsx
diff --git a/threepane/src/components/SidebarWidgets.tsx b/threepane/src/components/SidebarWidgets.tsx
new file mode 100644
index 00000000..b0480b3c
--- /dev/null
+++ b/threepane/src/components/SidebarWidgets.tsx
@@ -0,0 +1,68 @@
+import type { AutomergeUrl, DocHandle } from "@automerge/automerge-repo";
+import { For, Show, type Accessor } from "solid-js";
+import type { ThreepaneConfigDoc, ToolRef } from "../types";
+
+const DOCUMENT_LIST_TOOL = "chee/document-list";
+
+/**
+ * The left pane: a stack of configured widgets (`[toolId, docId]`), each
+ * rendered against its pinned doc, with per-widget remove. When empty, offers to
+ * add a document list on the account's root folder.
+ */
+export function SidebarWidgets(props: {
+ widgets: Accessor;
+ configHandle: Accessor | undefined>;
+ rootFolderUrl: Accessor;
+}) {
+ const addDocumentList = () => {
+ const root = props.rootFolderUrl();
+ const handle = props.configHandle();
+ if (!root || !handle) return;
+ handle.change((doc) => {
+ doc.sidebar.widgets.push([DOCUMENT_LIST_TOOL, root]);
+ });
+ };
+
+ const removeWidget = (index: number) => {
+ props.configHandle()?.change((doc) => {
+ doc.sidebar.widgets.splice(index, 1);
+ });
+ };
+
+ return (
+
+
+
+
+ }
+ >
+
+ {(widget, index) => (
+
+
+
+
+ )}
+
+
+
+ );
+}
diff --git a/threepane/src/components/Tray.tsx b/threepane/src/components/Tray.tsx
new file mode 100644
index 00000000..13738185
--- /dev/null
+++ b/threepane/src/components/Tray.tsx
@@ -0,0 +1,38 @@
+import type { AutomergeUrl } from "@automerge/automerge-repo";
+import {
+ getRegistry,
+ type ToolDescription,
+} from "@inkandswitch/patchwork-plugins";
+import { createSignal, For, onCleanup, onMount, Show } from "solid-js";
+
+/**
+ * A horizontal row of every tool tagged `"tray"`, pinned to the bottom of the
+ * right context sidebar. Tray tools render against the account document.
+ */
+export function Tray(props: { docUrl: AutomergeUrl }) {
+ const toolRegistry = getRegistry("patchwork:tool");
+
+ // Tools can register/load late; recompute the tray when the registry changes.
+ const [registryVersion, setRegistryVersion] = createSignal(0);
+ onMount(() => {
+ const off = toolRegistry.on("changed", () => {
+ setRegistryVersion((v) => v + 1);
+ });
+ onCleanup(off);
+ });
+
+ const trayTools = () => {
+ registryVersion();
+ return toolRegistry.filter((t) => (t.tags ?? []).includes("tray"));
+ };
+
+ return (
+
+
+
+ {(tool) => }
+
+
+
+ );
+}
diff --git a/threepane/src/datatypes.ts b/threepane/src/datatypes.ts
new file mode 100644
index 00000000..c8773d6b
--- /dev/null
+++ b/threepane/src/datatypes.ts
@@ -0,0 +1,35 @@
+import type { DatatypeImplementation } from "@inkandswitch/patchwork-plugins";
+import type { AccountDoc, ThreepaneConfigDoc } from "./types";
+
+/**
+ * Default scalar configuration for a fresh account. Subdoc URLs are intentionally
+ * absent and are populated lazily by the frame on first mount.
+ */
+export const AccountDatatype: DatatypeImplementation = {
+ init(doc) {
+ doc.frameToolId = "threepane";
+ // The left pane is now a widget list (migrated into the threepane config
+ // doc); no default account sidebar tool. These seed the migration.
+ doc.contextToolIds = ["comments-view", "history-view", "context-view"];
+ // Title + spacer are rendered intrinsically by the frame's top bar; only the
+ // right-hand doctitle tools are configured here.
+ doc.documentToolbarToolIds = [
+ "add-doc-to-sidebar-button",
+ "doc-openwith",
+ "doc-presence",
+ "sync-indicator",
+ ];
+ },
+ getTitle: () => "Patchwork Account",
+};
+
+/** The threepane layout config doc (sidebar widgets, context tabs, doctitle tools). */
+export const ThreepaneConfigDatatype: DatatypeImplementation =
+ {
+ init(doc) {
+ doc.sidebar = { widgets: [] };
+ doc.contextbar = { tabs: [] };
+ doc.doctitle = { tools: [] };
+ },
+ getTitle: () => "Threepane Config",
+ };
diff --git a/patchwork-frame/src/hooks/index.ts b/threepane/src/hooks/index.ts
similarity index 100%
rename from patchwork-frame/src/hooks/index.ts
rename to threepane/src/hooks/index.ts
diff --git a/patchwork-frame/src/hooks/useDebugRegistryToast.tsx b/threepane/src/hooks/useDebugRegistryToast.tsx
similarity index 100%
rename from patchwork-frame/src/hooks/useDebugRegistryToast.tsx
rename to threepane/src/hooks/useDebugRegistryToast.tsx
diff --git a/patchwork-frame/src/hooks/useProviderReady.ts b/threepane/src/hooks/useProviderReady.ts
similarity index 100%
rename from patchwork-frame/src/hooks/useProviderReady.ts
rename to threepane/src/hooks/useProviderReady.ts
diff --git a/patchwork-frame/src/hooks/useSidebarResize.ts b/threepane/src/hooks/useSidebarResize.ts
similarity index 100%
rename from patchwork-frame/src/hooks/useSidebarResize.ts
rename to threepane/src/hooks/useSidebarResize.ts
diff --git a/patchwork-frame/src/hooks/useSidebarState.ts b/threepane/src/hooks/useSidebarState.ts
similarity index 100%
rename from patchwork-frame/src/hooks/useSidebarState.ts
rename to threepane/src/hooks/useSidebarState.ts
diff --git a/threepane/src/index.tsx b/threepane/src/index.tsx
new file mode 100644
index 00000000..612c191f
--- /dev/null
+++ b/threepane/src/index.tsx
@@ -0,0 +1,58 @@
+import { Plugin, ToolImplementation } from "@inkandswitch/patchwork-plugins";
+import { render } from "solid-js/web";
+import type { AccountDoc } from "./types";
+
+async function loadFrame(): Promise> {
+ const { PatchworkFrame } = await import("./PatchworkFrame");
+ return (handle, element) => {
+ return render(
+ () => ,
+ element
+ );
+ };
+}
+
+export const plugins: Plugin[] = [
+ {
+ type: "patchwork:datatype",
+ id: "account",
+ name: "Patchwork Account",
+ icon: "UserCircle",
+ unlisted: true,
+ async load() {
+ const { AccountDatatype } = await import("./datatypes");
+ return AccountDatatype;
+ },
+ },
+ {
+ type: "patchwork:datatype",
+ id: "threepane:config",
+ name: "Threepane Config",
+ icon: "Settings",
+ unlisted: true,
+ async load() {
+ const { ThreepaneConfigDatatype } = await import("./datatypes");
+ return ThreepaneConfigDatatype;
+ },
+ },
+ {
+ type: "patchwork:tool",
+ id: "threepane",
+ tags: ["frame-tool"],
+ name: "Threepane",
+ icon: "Window",
+ supportedDatatypes: ["account"],
+ load: loadFrame,
+ },
+ {
+ // Back-compat alias: accounts whose frameToolId still points at the old id
+ // keep resolving. Unlisted + untagged so it isn't offered as a new option.
+ type: "patchwork:tool",
+ id: "patchwork-frame",
+ name: "Patchwork Frame",
+ icon: "Window",
+ supportedDatatypes: ["account"],
+ unlisted: true,
+ load: loadFrame,
+ },
+];
diff --git a/patchwork-frame/src/styles.css b/threepane/src/styles.css
similarity index 71%
rename from patchwork-frame/src/styles.css
rename to threepane/src/styles.css
index 3e8d6fc8..11dd3d16 100644
--- a/patchwork-frame/src/styles.css
+++ b/threepane/src/styles.css
@@ -18,14 +18,19 @@
/* Height the frame reserves for its own header chrome. */
--patchwork-frame-header-height: 3.5rem;
- /* The sideboard reads --sideboard-top-margin for the space above its doclist.
- Extend its default (top safe-area inset) by our header height so the sideboard
- clears the frame header. Cascades into the sideboard's shadow DOM because the
- sideboard only reads this var (with a fallback) rather than declaring it. */
- --sideboard-top-margin: calc(
+ /* The document-list reads --document-list-top-margin for the space above its
+ list. Extend its default (top safe-area inset) by our top-bar height so it
+ clears the frame's top bar. Cascades into the tool's shadow DOM because the
+ tool only reads this var (with a fallback) rather than declaring it. */
+ --document-list-top-margin: calc(
env(safe-area-inset-top, 0px) + var(--patchwork-frame-space-2xl)
);
+ /* the left footer and right tray boxes share one height so they line up. */
+ --patchwork-frame-tray-height: var(--patchwork-frame-space-2xl);
+ /* sidebar toggles ride a touch paler than the line (first ink offset). */
+ --patchwork-frame-toggle-fg: var(--studio-line-offset-10, #333);
+
--patchwork-frame-toolbar: var(--studio-chrome, white);
/* toolbar text rides on the chrome surface, so it uses the chrome ink */
--patchwork-frame-toolbar-fg: var(--studio-chrome-line, var(--studio-line, black));
@@ -112,29 +117,119 @@
overflow: hidden;
}
-.toolbar {
+/* The main column (right of the left sidebar): full-width top bar over a row of
+ [document | right sidebar]. */
+.frame__main-column {
+ flex: 1;
+ min-width: 0;
display: flex;
- align-items: center;
- gap: 0.5rem;
- padding: 0.5rem;
- padding-bottom: 0;
+ flex-direction: column;
+ height: 100%;
+ overflow: hidden;
+}
+
+/* The full-width top toolbar. Its height + top safe-area inset match the
+ sideboard's reserved top margin so the bar reads as continuous across the top
+ (the left sidebar's empty top region lines up with it). */
+.frame__topbar {
+ display: flex;
+ align-items: stretch;
box-sizing: border-box;
min-height: var(--patchwork-frame-space-2xl);
+ padding-top: env(safe-area-inset-top, 0px);
+ padding-inline: var(--patchwork-frame-space-2xs);
+ gap: var(--patchwork-frame-space-2xs);
background: var(--patchwork-frame-toolbar);
color: var(--patchwork-frame-toolbar-fg);
}
-.toolbar patchwork-view {
+.frame__content-row {
+ flex: 1;
+ min-height: 0;
display: flex;
- height: 2rem;
overflow: hidden;
+}
+
+/* A sidebar toggle at either end of the top bar (always present). */
+.frame__sidebar-toggle {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+ padding-inline: var(--patchwork-frame-space-sm);
+ border: none;
+ background: transparent;
+ color: var(--patchwork-frame-toggle-fg);
+ cursor: pointer;
+ transition: color var(--studio-transition-fast, 0.1s ease);
+}
+
+.frame__sidebar-toggle:hover {
+ color: var(--patchwork-frame-fg);
+}
+
+/* The right end of the top bar: context tabs + the right toggle. Its width
+ tracks the right sidebar so the tabs sit directly above it. */
+.frame__topbar-right {
+ display: flex;
+ align-items: stretch;
+ min-width: 0;
+ flex-shrink: 0;
+}
+
+.frame__topbar-right--collapsed {
width: auto;
}
-.toolbar patchwork-view[tool-id="document-title"],
-.toolbar patchwork-view[tool-id="spacer"] {
+/* The document title, rendered intrinsically: shrinks to the title's width but
+ never past half the bar; ellipsis beyond. */
+.threepane__title {
+ display: flex;
+ align-items: center;
+ flex: 0 1 auto;
+ min-width: 0;
+ max-width: 50%;
+ overflow: hidden;
+ padding-inline: 0.5rem;
+}
+
+.threepane__title-text {
+ font-weight: 600;
+ white-space: nowrap;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+/* built-in spacer between the title and the right-hand tools */
+.threepane__spacer {
flex: 1;
+ min-width: var(--patchwork-frame-space-sm);
+}
+
+/* configured doctitle tools at the right end; scroll horizontally when they
+ overflow rather than squashing. */
+.threepane__doctitle-tools {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
min-width: 0;
+ padding-inline: 0.5rem;
+ overflow-x: auto;
+ overflow-y: hidden;
+ scrollbar-width: none;
+}
+
+.threepane__doctitle-tools::-webkit-scrollbar {
+ display: none;
+}
+
+.threepane__doctitle-tools patchwork-view {
+ display: flex;
+ align-items: center;
+ height: 2rem;
+ flex-shrink: 0;
+ width: auto;
}
.document-view {
@@ -293,6 +388,118 @@
height: 100%;
}
+/* Context tabs hosted in the top bar (rather than a sidebar header): center them
+ vertically and drop the panel-border connection tweaks. */
+.frame__topbar-right .context-sidebar__tablist {
+ align-items: center;
+ padding-bottom: 0;
+ margin-bottom: 0;
+}
+
+.frame__topbar-right .context-sidebar__tab[data-active] {
+ margin-bottom: 0;
+}
+
+/* Left sidebar: a stack of configured widgets. */
+.threepane-widgets {
+ display: flex;
+ flex-direction: column;
+ width: 100%;
+ height: 100%;
+ min-height: 0;
+}
+
+.threepane-widget {
+ position: relative;
+ flex: 1;
+ min-height: 0;
+ overflow: hidden;
+}
+
+.threepane-widget patchwork-view {
+ display: block;
+ width: 100%;
+ height: 100%;
+}
+
+/* per-widget remove control, revealed on hover */
+.threepane-widget__remove {
+ position: absolute;
+ top: calc(env(safe-area-inset-top, 0px) + var(--patchwork-frame-space-2xs));
+ right: var(--patchwork-frame-space-2xs);
+ z-index: 2;
+ width: 1.5rem;
+ height: 1.5rem;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border: none;
+ border-radius: var(--studio-radius-sm, 4px);
+ background: var(--patchwork-frame-sidebar-fill);
+ color: var(--patchwork-frame-muted);
+ font-size: 1rem;
+ line-height: 1;
+ cursor: pointer;
+ opacity: 0;
+ transition: opacity var(--studio-transition-fast, 0.1s ease);
+}
+
+.threepane-widget:hover .threepane-widget__remove {
+ opacity: 0.7;
+}
+
+.threepane-widget__remove:hover {
+ opacity: 1;
+}
+
+/* empty state: offer to add the document list */
+.threepane-widgets__empty {
+ flex: 1;
+ display: flex;
+ align-items: flex-start;
+ justify-content: center;
+ padding-top: calc(env(safe-area-inset-top, 0px) + var(--patchwork-frame-space-2xl));
+}
+
+.threepane-widgets__add {
+ padding: var(--patchwork-frame-space-xs) var(--patchwork-frame-space-sm);
+ border: 1px dashed var(--patchwork-frame-sidebar-divider);
+ border-radius: var(--studio-radius-sm, 4px);
+ background: transparent;
+ color: var(--patchwork-frame-muted);
+ font: inherit;
+ cursor: pointer;
+ white-space: nowrap;
+}
+
+.threepane-widgets__add:hover:not(:disabled) {
+ color: var(--patchwork-frame-fg);
+ border-color: var(--patchwork-frame-fg);
+}
+
+.threepane-widgets__add:disabled {
+ opacity: 0.5;
+ cursor: default;
+}
+
+/* Right sidebar tray: a row of tray-tagged tools pinned to the bottom. */
+.frame-tray {
+ display: flex;
+ align-items: center;
+ gap: var(--patchwork-frame-space-2xs);
+ box-sizing: border-box;
+ min-height: var(--patchwork-frame-tray-height);
+ padding-inline: var(--patchwork-frame-space-sm);
+ border-top: 1px solid var(--context-sidebar-border);
+ background: var(--context-sidebar-content-bg);
+}
+
+.frame-tray patchwork-view {
+ display: flex;
+ width: auto;
+ height: auto;
+}
+
/* Debug Registry Toast */
.debug-registry-toast {
position: fixed;
@@ -500,28 +707,3 @@
body[data-sidebar-resizing] .sidebar {
transition: none;
}
-
-/* "Bring back" buttons shown in the document toolbar when a sidebar is
- collapsed: a quiet icon button that brightens toward the chrome ink. */
-.toolbar__sidebar-toggle {
- display: flex;
- align-items: center;
- justify-content: center;
- flex: none;
- width: 2rem;
- height: 2rem;
- padding: 0;
- border: 0;
- border-radius: var(--studio-radius-sm, 4px);
- background: transparent;
- /* icon matches the chrome ink, same as the toolbar text (no dimming) */
- color: var(--patchwork-frame-toolbar-fg);
- cursor: pointer;
- transition: background var(--studio-transition-fast, 0.1s ease);
-}
-.toolbar__sidebar-toggle:hover {
- background: var(--studio-chrome-offset-10, #e5e5e5);
-}
-.toolbar__sidebar-toggle--right {
- margin-left: auto;
-}
diff --git a/threepane/src/types.ts b/threepane/src/types.ts
new file mode 100644
index 00000000..bf6e8a41
--- /dev/null
+++ b/threepane/src/types.ts
@@ -0,0 +1,49 @@
+import { AutomergeUrl } from "@automerge/automerge-repo";
+
+/**
+ * The account document for a Patchwork frame.
+ *
+ * Scalar configuration (frame/sidebar/toolbar tool ids) is populated eagerly
+ * by AccountDatatype.init. Subdocument URLs (rootFolderUrl, moduleSettingsUrl,
+ * contactUrl) are optional and are lazily populated by the frame on first
+ * mount via createDocOfDatatype2 of their respective datatypes.
+ */
+export type AccountDoc = {
+ frameToolId: string;
+ /** @deprecated no longer defaulted; the left pane is now sidebar.widgets */
+ accountSidebarToolId?: string;
+ /** @deprecated seeds migration into the threepane config doc's contextbar.tabs */
+ contextToolIds?: string[];
+ /** @deprecated seeds migration into the threepane config doc's doctitle.tools */
+ documentToolbarToolIds?: string[];
+
+ rootFolderUrl?: AutomergeUrl;
+ moduleSettingsUrl?: AutomergeUrl;
+ contactUrl?: AutomergeUrl;
+
+ /**
+ * Per-tool config doc urls, keyed by tool id. `tools["threepane"]` points at
+ * a ThreepaneConfigDoc holding the sidebar/contextbar/doctitle layout.
+ */
+ tools?: Record;
+};
+
+/** @deprecated use AccountDoc */
+export type TinyPatchworkConfigDoc = AccountDoc;
+
+/**
+ * A configured tool slot: which tool, and which document it renders against.
+ * For doctitle/contextbar the docid is currently a placeholder (the frame feeds
+ * the selected/account doc); for sidebar widgets it's a real pin (e.g. a folder).
+ */
+export type ToolRef = [toolId: string, docId: AutomergeUrl];
+
+/**
+ * The threepane layout config (its own document, referenced from
+ * `AccountDoc.tools["threepane"]`).
+ */
+export type ThreepaneConfigDoc = {
+ sidebar: { widgets: ToolRef[] };
+ contextbar: { tabs: ToolRef[] };
+ doctitle: { tools: ToolRef[] };
+};
diff --git a/patchwork-frame/tsconfig.json b/threepane/tsconfig.json
similarity index 100%
rename from patchwork-frame/tsconfig.json
rename to threepane/tsconfig.json
diff --git a/patchwork-frame/vite.config.ts b/threepane/vite.config.ts
similarity index 100%
rename from patchwork-frame/vite.config.ts
rename to threepane/vite.config.ts
From 804fd0311b3aa4ccc4ed5ff8f8dd0378c8f72d1f Mon Sep 17 00:00:00 2001
From: chee
Date: Sun, 28 Jun 2026 09:02:32 +0100
Subject: [PATCH 2/7] update lockfile
---
pnpm-lock.yaml | 140 ++++++++++++++++++++++++-------------------------
1 file changed, 70 insertions(+), 70 deletions(-)
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index e505bd97..fdedcd22 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -797,67 +797,6 @@ importers:
specifier: ~5.9.3
version: 5.9.3
- patchwork-frame:
- dependencies:
- '@automerge/automerge':
- specifier: 3.3.0-fragments.1
- version: 3.3.0-fragments.1
- '@automerge/automerge-repo':
- specifier: ^2.6.0-subduction.26
- version: 2.6.0-subduction.34(supports-color@5.5.0)
- '@automerge/automerge-repo-solid-primitives':
- specifier: ^2.6.0-subduction.26
- version: 2.6.0-subduction.34(@automerge/automerge@3.3.0-fragments.1)(solid-js@1.9.13)
- '@automerge/vanillajs':
- specifier: ^2.6.0-subduction.26
- version: 2.6.0-subduction.34(supports-color@5.5.0)
- '@inkandswitch/patchwork-elements':
- specifier: 1.0.0
- version: 1.0.0(@automerge/automerge-repo@2.6.0-subduction.34)(@inkandswitch/patchwork-filesystem@0.0.8(@automerge/automerge-repo@2.6.0-subduction.34)(@automerge/automerge@3.3.0-fragments.1))(@inkandswitch/patchwork-plugins@0.0.11(@automerge/automerge-repo@2.6.0-subduction.34)(@automerge/automerge@3.3.0-fragments.1)(@inkandswitch/patchwork-filesystem@0.0.8(@automerge/automerge-repo@2.6.0-subduction.34)(@automerge/automerge@3.3.0-fragments.1)))(@inkandswitch/patchwork-providers@0.2.2(@automerge/automerge-repo@2.6.0-subduction.34))(@types/react@18.3.31)(react@18.3.1)(solid-js@1.9.13)(supports-color@5.5.0)
- '@inkandswitch/patchwork-filesystem':
- specifier: ^0.0.8
- version: 0.0.8(@automerge/automerge-repo@2.6.0-subduction.34)(@automerge/automerge@3.3.0-fragments.1)(supports-color@5.5.0)
- '@inkandswitch/patchwork-plugins':
- specifier: ^0.0.11
- version: 0.0.11(@automerge/automerge-repo@2.6.0-subduction.34)(@automerge/automerge@3.3.0-fragments.1)(@inkandswitch/patchwork-filesystem@0.0.8(@automerge/automerge-repo@2.6.0-subduction.34)(@automerge/automerge@3.3.0-fragments.1))
- '@inkandswitch/patchwork-providers':
- specifier: 0.2.2
- version: 0.2.2(@automerge/automerge-repo@2.6.0-subduction.34)
- '@inkandswitch/patchwork-providers-solid':
- specifier: 0.2.3
- version: 0.2.3(@automerge/automerge-repo-solid-primitives@2.6.0-subduction.34(@automerge/automerge@3.3.0-fragments.1)(solid-js@1.9.13))(@automerge/automerge-repo@2.6.0-subduction.34)(solid-js@1.9.13)
- solid-js:
- specifier: ^1.9.10
- version: 1.9.13
- devDependencies:
- '@eslint/js':
- specifier: ^9.37.0
- version: 9.39.4
- '@inkandswitch/patchwork-bootloader':
- specifier: ^0.2.8
- version: 0.2.8(@automerge/automerge-repo-keyhive@0.3.0-alpha.sub.1c(ws@8.21.0))(@automerge/automerge-repo@2.6.0-subduction.34)(@automerge/automerge@3.3.0-fragments.1)(@automerge/vanillajs@2.6.0-subduction.34(supports-color@5.5.0))(@types/react@18.3.31)(react@18.3.1)(solid-js@1.9.13)
- '@typescript-eslint/parser':
- specifier: ^8.40.0
- version: 8.62.0(eslint@9.39.4)(supports-color@5.5.0)(typescript@5.9.3)
- eslint:
- specifier: ^9.34.0
- version: 9.39.4
- globals:
- specifier: ^16.4.0
- version: 16.5.0
- typescript-eslint:
- specifier: ^8.41.0
- version: 8.62.0(eslint@9.39.4)(typescript@5.9.3)
- vite:
- specifier: ^7.1.9
- version: 7.3.5(@types/node@24.13.2)
- vite-plugin-css-injected-by-js:
- specifier: ^3.5.2
- version: 3.5.2(vite@7.3.5(@types/node@24.13.2))
- vite-plugin-solid:
- specifier: ^2.11.10
- version: 2.11.12(solid-js@1.9.13)(vite@7.3.5(@types/node@24.13.2))
-
providers:
dependencies:
'@automerge/automerge':
@@ -1131,6 +1070,67 @@ importers:
specifier: ~5.9.3
version: 5.9.3
+ threepane:
+ dependencies:
+ '@automerge/automerge':
+ specifier: 3.3.0-fragments.1
+ version: 3.3.0-fragments.1
+ '@automerge/automerge-repo':
+ specifier: ^2.6.0-subduction.26
+ version: 2.6.0-subduction.34(supports-color@5.5.0)
+ '@automerge/automerge-repo-solid-primitives':
+ specifier: ^2.6.0-subduction.26
+ version: 2.6.0-subduction.34(@automerge/automerge@3.3.0-fragments.1)(solid-js@1.9.13)
+ '@automerge/vanillajs':
+ specifier: ^2.6.0-subduction.26
+ version: 2.6.0-subduction.34
+ '@inkandswitch/patchwork-elements':
+ specifier: 1.0.0
+ version: 1.0.0(@automerge/automerge-repo@2.6.0-subduction.34)(@inkandswitch/patchwork-filesystem@0.0.8(@automerge/automerge-repo@2.6.0-subduction.34)(@automerge/automerge@3.3.0-fragments.1))(@inkandswitch/patchwork-plugins@0.0.11(@automerge/automerge-repo@2.6.0-subduction.34)(@automerge/automerge@3.3.0-fragments.1)(@inkandswitch/patchwork-filesystem@0.0.8(@automerge/automerge-repo@2.6.0-subduction.34)(@automerge/automerge@3.3.0-fragments.1)))(@inkandswitch/patchwork-providers@0.2.2(@automerge/automerge-repo@2.6.0-subduction.34))(@types/react@18.3.31)(react@18.3.1)(solid-js@1.9.13)(supports-color@5.5.0)
+ '@inkandswitch/patchwork-filesystem':
+ specifier: ^0.0.8
+ version: 0.0.8(@automerge/automerge-repo@2.6.0-subduction.34)(@automerge/automerge@3.3.0-fragments.1)(supports-color@5.5.0)
+ '@inkandswitch/patchwork-plugins':
+ specifier: ^0.0.11
+ version: 0.0.11(@automerge/automerge-repo@2.6.0-subduction.34)(@automerge/automerge@3.3.0-fragments.1)(@inkandswitch/patchwork-filesystem@0.0.8(@automerge/automerge-repo@2.6.0-subduction.34)(@automerge/automerge@3.3.0-fragments.1))
+ '@inkandswitch/patchwork-providers':
+ specifier: 0.2.2
+ version: 0.2.2(@automerge/automerge-repo@2.6.0-subduction.34)
+ '@inkandswitch/patchwork-providers-solid':
+ specifier: 0.2.3
+ version: 0.2.3(@automerge/automerge-repo-solid-primitives@2.6.0-subduction.34(@automerge/automerge@3.3.0-fragments.1)(solid-js@1.9.13))(@automerge/automerge-repo@2.6.0-subduction.34)(solid-js@1.9.13)
+ solid-js:
+ specifier: ^1.9.10
+ version: 1.9.13
+ devDependencies:
+ '@eslint/js':
+ specifier: ^9.37.0
+ version: 9.39.4
+ '@inkandswitch/patchwork-bootloader':
+ specifier: ^0.2.8
+ version: 0.2.8(@automerge/automerge-repo-keyhive@0.3.0-alpha.sub.1c(ws@8.21.0))(@automerge/automerge-repo@2.6.0-subduction.34)(@automerge/automerge@3.3.0-fragments.1)(@automerge/vanillajs@2.6.0-subduction.34)(@types/react@18.3.31)(react@18.3.1)(solid-js@1.9.13)
+ '@typescript-eslint/parser':
+ specifier: ^8.40.0
+ version: 8.62.0(eslint@9.39.4)(supports-color@5.5.0)(typescript@5.9.3)
+ eslint:
+ specifier: ^9.34.0
+ version: 9.39.4
+ globals:
+ specifier: ^16.4.0
+ version: 16.5.0
+ typescript-eslint:
+ specifier: ^8.41.0
+ version: 8.62.0(eslint@9.39.4)(typescript@5.9.3)
+ vite:
+ specifier: ^7.1.9
+ version: 7.3.5(@types/node@24.13.2)
+ vite-plugin-css-injected-by-js:
+ specifier: ^3.5.2
+ version: 3.5.2(vite@7.3.5(@types/node@24.13.2))
+ vite-plugin-solid:
+ specifier: ^2.11.10
+ version: 2.11.12(solid-js@1.9.13)(vite@7.3.5(@types/node@24.13.2))
+
tldraw4:
dependencies:
'@automerge/automerge-repo-react-hooks':
@@ -5352,7 +5352,7 @@ snapshots:
- supports-color
- utf-8-validate
- '@automerge/automerge-repo-network-messagechannel@2.6.0-subduction.34(supports-color@5.5.0)':
+ '@automerge/automerge-repo-network-messagechannel@2.6.0-subduction.34':
dependencies:
'@automerge/automerge-repo': 2.6.0-subduction.34(supports-color@5.5.0)
debug: 4.4.3(supports-color@5.5.0)
@@ -5465,7 +5465,7 @@ snapshots:
- supports-color
- utf-8-validate
- '@automerge/automerge-repo-network-websocket@2.6.0-subduction.34(supports-color@5.5.0)':
+ '@automerge/automerge-repo-network-websocket@2.6.0-subduction.34':
dependencies:
'@automerge/automerge-repo': 2.6.0-subduction.34(supports-color@5.5.0)
cbor-x: 1.6.4
@@ -5741,8 +5741,8 @@ snapshots:
dependencies:
'@automerge/automerge-repo': 2.6.0-subduction.34(supports-color@5.5.0)
'@automerge/automerge-repo-network-broadcastchannel': 2.6.0-subduction.34
- '@automerge/automerge-repo-network-messagechannel': 2.6.0-subduction.34(supports-color@5.5.0)
- '@automerge/automerge-repo-network-websocket': 2.6.0-subduction.34(supports-color@5.5.0)
+ '@automerge/automerge-repo-network-messagechannel': 2.6.0-subduction.34
+ '@automerge/automerge-repo-network-websocket': 2.6.0-subduction.34
'@automerge/automerge-repo-react-hooks': 2.6.0-subduction.34(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@automerge/automerge-repo-storage-indexeddb': 2.6.0-subduction.34
react: 18.3.1
@@ -5800,12 +5800,12 @@ snapshots:
- supports-color
- utf-8-validate
- '@automerge/vanillajs@2.6.0-subduction.34(supports-color@5.5.0)':
+ '@automerge/vanillajs@2.6.0-subduction.34':
dependencies:
'@automerge/automerge-repo': 2.6.0-subduction.34(supports-color@5.5.0)
'@automerge/automerge-repo-network-broadcastchannel': 2.6.0-subduction.34
- '@automerge/automerge-repo-network-messagechannel': 2.6.0-subduction.34(supports-color@5.5.0)
- '@automerge/automerge-repo-network-websocket': 2.6.0-subduction.34(supports-color@5.5.0)
+ '@automerge/automerge-repo-network-messagechannel': 2.6.0-subduction.34
+ '@automerge/automerge-repo-network-websocket': 2.6.0-subduction.34
'@automerge/automerge-repo-storage-indexeddb': 2.6.0-subduction.34
transitivePeerDependencies:
- bufferutil
@@ -6842,7 +6842,7 @@ snapshots:
- supports-color
- utf-8-validate
- '@inkandswitch/patchwork-bootloader@0.2.8(@automerge/automerge-repo-keyhive@0.3.0-alpha.sub.1c(ws@8.21.0))(@automerge/automerge-repo@2.6.0-subduction.34)(@automerge/automerge@3.3.0-fragments.1)(@automerge/vanillajs@2.6.0-subduction.34(supports-color@5.5.0))(@types/react@18.3.31)(react@18.3.1)(solid-js@1.9.13)':
+ '@inkandswitch/patchwork-bootloader@0.2.8(@automerge/automerge-repo-keyhive@0.3.0-alpha.sub.1c(ws@8.21.0))(@automerge/automerge-repo@2.6.0-subduction.34)(@automerge/automerge@3.3.0-fragments.1)(@automerge/vanillajs@2.6.0-subduction.34)(@types/react@18.3.31)(react@18.3.1)(solid-js@1.9.13)':
dependencies:
'@automerge/automerge': 3.3.0-fragments.1
'@automerge/automerge-repo': 2.6.0-subduction.34(supports-color@5.5.0)
@@ -6851,7 +6851,7 @@ snapshots:
'@automerge/automerge-repo-network-websocket': 2.6.0-subduction.29
'@automerge/automerge-repo-storage-indexeddb': 2.6.0-subduction.29
'@automerge/automerge-subduction': 0.15.0
- '@automerge/vanillajs': 2.6.0-subduction.34(supports-color@5.5.0)
+ '@automerge/vanillajs': 2.6.0-subduction.34
'@inkandswitch/patchwork-elements': 2.0.0(@automerge/automerge-repo@2.6.0-subduction.34)(@inkandswitch/patchwork-filesystem@0.0.8(@automerge/automerge-repo@2.6.0-subduction.34)(@automerge/automerge@3.3.0-fragments.1))(@inkandswitch/patchwork-plugins@0.0.11(@automerge/automerge-repo@2.6.0-subduction.34)(@automerge/automerge@3.3.0-fragments.1))(@inkandswitch/patchwork-providers@0.3.0(@automerge/automerge-repo@2.6.0-subduction.34))(@types/react@18.3.31)(react@18.3.1)(solid-js@1.9.13)
'@inkandswitch/patchwork-filesystem': 0.0.8(@automerge/automerge-repo@2.6.0-subduction.34)(@automerge/automerge@3.3.0-fragments.1)(supports-color@5.5.0)
'@inkandswitch/patchwork-plugins': 0.0.11(@automerge/automerge-repo@2.6.0-subduction.34)(@automerge/automerge@3.3.0-fragments.1)(@inkandswitch/patchwork-filesystem@0.0.8(@automerge/automerge-repo@2.6.0-subduction.34)(@automerge/automerge@3.3.0-fragments.1))
From ef13420844d388241ea7f3fb4bdffa00d2912219 Mon Sep 17 00:00:00 2001
From: chee
Date: Sun, 28 Jun 2026 12:06:28 +0100
Subject: [PATCH 3/7] simplify intrinsic tools
---
frame-configurator/src/FrameConfigurator.tsx | 7 +-----
.../src/sideboard/document-list/item.tsx | 25 -------------------
threepane/src/PatchworkFrame.tsx | 12 ++++-----
threepane/src/components/FrameTopBar.tsx | 12 +++------
4 files changed, 10 insertions(+), 46 deletions(-)
diff --git a/frame-configurator/src/FrameConfigurator.tsx b/frame-configurator/src/FrameConfigurator.tsx
index 3e2279ff..03781ab4 100644
--- a/frame-configurator/src/FrameConfigurator.tsx
+++ b/frame-configurator/src/FrameConfigurator.tsx
@@ -22,9 +22,6 @@ import type {
ToolRef,
} from "./types";
-// Title + spacer are intrinsic to the frame's top bar, not configurable.
-const INTRINSIC_DOCTITLE_TOOLS = new Set(["document-title", "spacer"]);
-
type ModuleOption = {
id: string;
name: string;
@@ -485,9 +482,7 @@ function FrameConfiguratorUI(props: {
filterToolsByTag([...allTools], "frame-tool")
);
const documentToolbarOptions = createMemo(() =>
- filterToolsByTag([...allTools], "titlebar-tool").filter(
- (o) => !INTRINSIC_DOCTITLE_TOOLS.has(o.id)
- )
+ filterToolsByTag([...allTools], "titlebar-tool")
);
const contextToolOptions = createMemo(() =>
filterToolsByTag([...allTools], "context-tool")
diff --git a/sideboard/src/sideboard/document-list/item.tsx b/sideboard/src/sideboard/document-list/item.tsx
index 99f4e7bc..1d79c742 100644
--- a/sideboard/src/sideboard/document-list/item.tsx
+++ b/sideboard/src/sideboard/document-list/item.tsx
@@ -461,31 +461,6 @@ export default function Item(props: {
>
Automerge url
-
-
-
- Automerge url with...
-
-
-
-
- {(tool) => (
-
- navigator.clipboard.writeText(
- `${props.url}&tool=${tool.id}`
- )
- }
- >
- {tool.name}
-
- )}
-
-
-
-
-
diff --git a/threepane/src/PatchworkFrame.tsx b/threepane/src/PatchworkFrame.tsx
index c29a4247..c9597540 100644
--- a/threepane/src/PatchworkFrame.tsx
+++ b/threepane/src/PatchworkFrame.tsx
@@ -176,14 +176,14 @@ function PatchworkFrameInner(props: {
return threepaneConfigHandle()?.doc();
});
- // Derived layout lanes — prefer the threepane config doc, fall back to the
- // legacy account fields until migration completes.
+ // Derived layout lanes, read from the threepane config doc. The migration
+ // seeds it from the legacy account fields (dropping the intrinsic title +
+ // spacer); older builds read those fields directly, so branch-flipping stays
+ // safe without a fallback here.
const doctitleToolIds = () =>
- threepaneConfig()?.doctitle?.tools?.map((ref) => ref[0]) ??
- accountDoc()?.documentToolbarToolIds;
+ threepaneConfig()?.doctitle?.tools?.map((ref) => ref[0]);
const contextTabIds = () =>
- threepaneConfig()?.contextbar?.tabs?.map((ref) => ref[0]) ??
- accountDoc()?.contextToolIds;
+ threepaneConfig()?.contextbar?.tabs?.map((ref) => ref[0]);
const sidebarWidgets = (): ToolRef[] =>
threepaneConfig()?.sidebar?.widgets ?? [];
const rootFolderUrl = () => accountDoc()?.rootFolderUrl;
diff --git a/threepane/src/components/FrameTopBar.tsx b/threepane/src/components/FrameTopBar.tsx
index e880f3d0..0a813f62 100644
--- a/threepane/src/components/FrameTopBar.tsx
+++ b/threepane/src/components/FrameTopBar.tsx
@@ -3,11 +3,6 @@ import { For, Show, type Accessor } from "solid-js";
import { ContextTabs } from "./ContextTabs";
import { DocumentTitle } from "./DocumentTitle";
-// Title + spacer are rendered intrinsically by the frame's top bar, so they're
-// dropped from the configured doctitle tools (which sit to the right).
-const TITLE_TOOL = "document-title";
-const SPACER_TOOL = "spacer";
-
type FrameTopBarProps = {
repo: Repo;
docUrl: Accessor;
@@ -33,10 +28,9 @@ type FrameTopBarProps = {
*/
export function FrameTopBar(props: FrameTopBarProps) {
const hasRight = () => !!props.contextToolIds()?.length;
- const docToolIds = () =>
- (props.toolIds() ?? []).filter(
- (id) => id !== TITLE_TOOL && id !== SPACER_TOOL
- );
+ // Title + spacer are intrinsic to the bar and never in the config (the
+ // migration drops them), so the configured doctitle tools render as-is.
+ const docToolIds = () => props.toolIds() ?? [];
return (
From e51f6bc6278b577bedc9d7c5929d100d9d3bdca3 Mon Sep 17 00:00:00 2001
From: chee
Date: Sun, 28 Jun 2026 12:53:03 +0100
Subject: [PATCH 4/7] show sidebar toggle in the right place
---
threepane/src/PatchworkFrame.tsx | 79 ++++++++++++----
.../src/account/ensureThreepaneConfig.ts | 36 ++++++--
threepane/src/components/FrameTopBar.tsx | 49 ++--------
threepane/src/components/SidebarWidgets.tsx | 22 +----
threepane/src/styles.css | 92 +++++++++++--------
5 files changed, 158 insertions(+), 120 deletions(-)
diff --git a/threepane/src/PatchworkFrame.tsx b/threepane/src/PatchworkFrame.tsx
index c9597540..83ae6604 100644
--- a/threepane/src/PatchworkFrame.tsx
+++ b/threepane/src/PatchworkFrame.tsx
@@ -133,12 +133,14 @@ function PatchworkFrameInner(props: {
});
// Lazily populate subdoc fields (rootFolderUrl, moduleSettingsUrl, contactUrl)
- // on first mount. Each is created via createDocOfDatatype2 of its own
- // datatype, so defaults and shape are owned by the datatype, not the frame.
- void ensureAccountSubdocs(props.handle, props.repo);
- // Create the threepane layout config doc and migrate the legacy account arrays
- // into it (non-destructive — old fields stay so older builds keep working).
- void ensureThreepaneConfig(props.handle, props.repo);
+ // on first mount, then create the threepane layout config doc and migrate the
+ // legacy account arrays into it (non-destructive — old fields stay so older
+ // builds keep working). Ordered: the migration seeds the sidebar's default
+ // document-list widget against rootFolderUrl, so the subdocs must land first.
+ void (async () => {
+ await ensureAccountSubdocs(props.handle, props.repo);
+ await ensureThreepaneConfig(props.handle, props.repo);
+ })();
const [docVersion, setDocVersion] = createSignal(0);
createEffect(() => {
@@ -337,20 +339,49 @@ function FrameLayout(props: {
rootFolderUrl: Accessor;
children: JSX.Element;
}) {
+ const isCollapsed = props.sidebarState.isSidebarCollapsed;
return (
<>
+ {/*
+ The left toggle is pinned to the frame's top-left corner (absolute,
+ outside the collapsing sidebar) so it holds the same spot whether the
+ sidebar is open or closed. The title, in the top bar, slides up against
+ it as the sidebar closes rather than travelling the full sidebar width.
+ */}
+
+
-
+
+
+ {/* account / packages / settings, pinned to the sidebar's bottom */}
+
+
{props.children}
@@ -358,6 +389,26 @@ function FrameLayout(props: {
);
}
+// lucide `panel-left`
+function PanelLeftIcon() {
+ return (
+
+ );
+}
+
// Reads the draft list state from the draft-list provider, then renders the
// main document inside a draft-overlay provider keyed on the selected draft.
// The overlay provider is always mounted; it becomes a no-op when its `url`
@@ -445,11 +496,7 @@ function DraftDocumentArea(props: {
repo={props.repo}
docUrl={props.selectedDocUrl}
toolIds={props.doctitleToolIds}
- hasLeftSidebar={() => true}
isLeftCollapsed={props.sidebarState.isSidebarCollapsed}
- onToggleLeft={() =>
- props.sidebarState.setIsSidebarCollapsed((v) => !v)
- }
contextToolIds={props.contextTabIds}
selectedContextToolId={props.selectedContextToolId}
setSelectedContextToolId={props.setSelectedContextToolId}
diff --git a/threepane/src/account/ensureThreepaneConfig.ts b/threepane/src/account/ensureThreepaneConfig.ts
index 5158e0bb..b9edc8b0 100644
--- a/threepane/src/account/ensureThreepaneConfig.ts
+++ b/threepane/src/account/ensureThreepaneConfig.ts
@@ -1,4 +1,4 @@
-import type { DocHandle, Repo } from "@automerge/automerge-repo";
+import type { AutomergeUrl, DocHandle, Repo } from "@automerge/automerge-repo";
import { createDocOfDatatype2 } from "@inkandswitch/patchwork-plugins";
import type { AccountDoc, ThreepaneConfigDoc, ToolRef } from "../types";
import { loadDatatypeWhenReady } from "./ensureSubdocs";
@@ -6,22 +6,46 @@ import { loadDatatypeWhenReady } from "./ensureSubdocs";
// Title + spacer are intrinsic to the frame's top bar, never configured tools.
const INTRINSIC_DOCTITLE_TOOLS = new Set(["document-title", "spacer"]);
+// The sidebar's default widget: a document list pinned to the account's root
+// folder. Every account gets one so the left pane is never empty.
+const DOCUMENT_LIST_TOOL = "chee/document-list";
+
+/** The default sidebar widgets for a fresh (or empty) account. */
+function defaultSidebarWidgets(rootFolderUrl?: AutomergeUrl): ToolRef[] {
+ return rootFolderUrl ? [[DOCUMENT_LIST_TOOL, rootFolderUrl]] : [];
+}
+
/**
* Lazily create the threepane layout config doc and point `account.tools.threepane`
* at it, migrating the legacy `account.*` arrays into its lanes.
*
+ * Seeds the sidebar with a default document-list widget (pinned to the account's
+ * root folder), so the left pane is never empty. Expects `rootFolderUrl` to be
+ * populated already — call after `ensureAccountSubdocs`.
+ *
* Non-destructive: the old `documentToolbarToolIds` / `contextToolIds` /
* `accountSidebarToolId` fields are left untouched so older builds keep working
* and you can switch branches freely during the PR. Run the (separate, opt-in)
* cleanupLegacyAccountFields script to remove them later.
- *
- * Idempotent: returns early once `account.tools.threepane` is set.
*/
export async function ensureThreepaneConfig(
accountHandle: DocHandle,
repo: Repo
) {
- if (accountHandle.doc()?.tools?.["threepane"]) return;
+ const rootFolderUrl = accountHandle.doc()?.rootFolderUrl;
+ const existingConfigUrl = accountHandle.doc()?.tools?.["threepane"];
+
+ // Already migrated. Backfill the default document-list widget for accounts
+ // migrated by an earlier build of this branch that seeded an empty sidebar.
+ if (existingConfigUrl) {
+ if (!rootFolderUrl) return;
+ const configHandle = await repo.find(existingConfigUrl);
+ if (configHandle.doc()?.sidebar?.widgets?.length) return;
+ configHandle.change((doc) => {
+ doc.sidebar.widgets = defaultSidebarWidgets(rootFolderUrl);
+ });
+ return;
+ }
const datatype = await loadDatatypeWhenReady(
"threepane:config"
@@ -39,7 +63,7 @@ export async function ensureThreepaneConfig(
// doctitle + contextbar migrate with the account doc as a placeholder docid
// (the frame still feeds doctitle the selected doc / contextbar the account
- // doc). The sidebar starts empty — the document list is now an opt-in widget.
+ // doc). The sidebar is seeded with the default document-list widget.
const doctitleTools: ToolRef[] = (account?.documentToolbarToolIds ?? [])
.filter((id) => !INTRINSIC_DOCTITLE_TOOLS.has(id))
.map((id) => [id, accountDocUrl]);
@@ -55,7 +79,7 @@ export async function ensureThreepaneConfig(
configHandle.change((doc) => {
doc.doctitle.tools = doctitleTools;
doc.contextbar.tabs = contextTabs;
- doc.sidebar.widgets = [];
+ doc.sidebar.widgets = defaultSidebarWidgets(account?.rootFolderUrl);
});
accountHandle.change((acc) => {
diff --git a/threepane/src/components/FrameTopBar.tsx b/threepane/src/components/FrameTopBar.tsx
index 0a813f62..c5c2b26b 100644
--- a/threepane/src/components/FrameTopBar.tsx
+++ b/threepane/src/components/FrameTopBar.tsx
@@ -8,9 +8,7 @@ type FrameTopBarProps = {
docUrl: Accessor;
toolIds: Accessor;
- hasLeftSidebar: Accessor;
isLeftCollapsed: Accessor;
- onToggleLeft: () => void;
contextToolIds: Accessor;
selectedContextToolId: Accessor;
@@ -21,10 +19,11 @@ type FrameTopBarProps = {
};
/**
- * The full-width top toolbar: a left sidebar toggle, the document tool views,
- * and — above the right sidebar — the context tabs with a right sidebar toggle.
- * Spans the main column; the left sidebar's reserved top margin lines up with it
- * so it reads as one continuous bar across the top.
+ * The full-width top toolbar: the document title and tool views, and — above the
+ * right sidebar — the context tabs with a right sidebar toggle. Spans the main
+ * column; the left sidebar toggle is pinned separately to the frame's top-left
+ * corner. When the left sidebar is collapsed the bar reserves a matching slot at
+ * its start so the title slides up against (not under) that toggle.
*/
export function FrameTopBar(props: FrameTopBarProps) {
const hasRight = () => !!props.contextToolIds()?.length;
@@ -33,20 +32,10 @@ export function FrameTopBar(props: FrameTopBarProps) {
const docToolIds = () => props.toolIds() ?? [];
return (
-
-
-
-
-
+
{/* document title, rendered intrinsically: shrinks to the title length,
capped at half the bar. */}
@@ -108,26 +97,6 @@ export function FrameTopBar(props: FrameTopBarProps) {
);
}
-// lucide `panel-left`
-function PanelLeftIcon() {
- return (
-
- );
-}
-
// lucide `panel-right`
function PanelRightIcon() {
return (
diff --git a/threepane/src/components/SidebarWidgets.tsx b/threepane/src/components/SidebarWidgets.tsx
index b0480b3c..10540e7f 100644
--- a/threepane/src/components/SidebarWidgets.tsx
+++ b/threepane/src/components/SidebarWidgets.tsx
@@ -6,8 +6,9 @@ const DOCUMENT_LIST_TOOL = "chee/document-list";
/**
* The left pane: a stack of configured widgets (`[toolId, docId]`), each
- * rendered against its pinned doc, with per-widget remove. When empty, offers to
- * add a document list on the account's root folder.
+ * rendered against its pinned doc. Normally seeded with a document list on the
+ * account's root folder; the empty-state add button is a fallback for when no
+ * widgets are configured (there's no add/remove UI yet).
*/
export function SidebarWidgets(props: {
widgets: Accessor;
@@ -23,12 +24,6 @@ export function SidebarWidgets(props: {
});
};
- const removeWidget = (index: number) => {
- props.configHandle()?.change((doc) => {
- doc.sidebar.widgets.splice(index, 1);
- });
- };
-
return (