From d2b1996fda451b16e6ca746837f2b32ae8425adf Mon Sep 17 00:00:00 2001 From: chee Date: Sun, 28 Jun 2026 03:13:40 +0100 Subject: [PATCH 1/7] deconstruct sideboard, refactor three pane frame --- frame-configurator/src/FrameConfigurator.tsx | 92 +++-- frame-configurator/src/types.ts | 17 +- .../src/components/ContextSidebar.tsx | 98 ----- .../src/components/DocumentToolbar.tsx | 94 ----- patchwork-frame/src/datatypes.ts | 21 -- patchwork-frame/src/index.tsx | 34 -- patchwork-frame/src/types.ts | 23 -- sideboard/src/index.css | 351 ++++++++++-------- sideboard/src/index.tsx | 58 ++- sideboard/src/sideboard/account-bar.tsx | 56 +++ sideboard/src/sideboard/create-new.tsx | 6 +- .../src/sideboard/document-list-panel.tsx | 163 ++++++++ .../sideboard/document-list/document-list.tsx | 8 +- .../src/sideboard/document-list/item.tsx | 6 +- sideboard/src/sideboard/sideboard.tsx | 218 ++--------- sideboard/src/types.ts | 8 +- {patchwork-frame => threepane}/.gitignore | 0 .../eslint.config.ts | 0 {patchwork-frame => threepane}/package.json | 0 {patchwork-frame => threepane}/pnpm-lock.yaml | 0 .../src/PatchworkFrame.tsx | 171 ++++++--- .../src/account/cleanupLegacyAccountFields.ts | 41 ++ .../src/account/ensureSubdocs.ts | 2 +- .../src/account/ensureThreepaneConfig.ts | 65 ++++ threepane/src/components/ContextSidebar.tsx | 58 +++ threepane/src/components/ContextTabs.tsx | 62 ++++ threepane/src/components/DocumentTitle.tsx | 64 ++++ threepane/src/components/FrameTopBar.tsx | 155 ++++++++ .../src/components/MainDocumentView.tsx | 0 .../src/components/Sidebar.tsx | 0 threepane/src/components/SidebarWidgets.tsx | 68 ++++ threepane/src/components/Tray.tsx | 38 ++ threepane/src/datatypes.ts | 35 ++ .../src/hooks/index.ts | 0 .../src/hooks/useDebugRegistryToast.tsx | 0 .../src/hooks/useProviderReady.ts | 0 .../src/hooks/useSidebarResize.ts | 0 .../src/hooks/useSidebarState.ts | 0 threepane/src/index.tsx | 58 +++ {patchwork-frame => threepane}/src/styles.css | 260 +++++++++++-- threepane/src/types.ts | 49 +++ {patchwork-frame => threepane}/tsconfig.json | 0 {patchwork-frame => threepane}/vite.config.ts | 0 43 files changed, 1606 insertions(+), 773 deletions(-) delete mode 100644 patchwork-frame/src/components/ContextSidebar.tsx delete mode 100644 patchwork-frame/src/components/DocumentToolbar.tsx delete mode 100644 patchwork-frame/src/datatypes.ts delete mode 100644 patchwork-frame/src/index.tsx delete mode 100644 patchwork-frame/src/types.ts create mode 100644 sideboard/src/sideboard/account-bar.tsx create mode 100644 sideboard/src/sideboard/document-list-panel.tsx rename {patchwork-frame => threepane}/.gitignore (100%) rename {patchwork-frame => threepane}/eslint.config.ts (100%) rename {patchwork-frame => threepane}/package.json (100%) rename {patchwork-frame => threepane}/pnpm-lock.yaml (100%) rename {patchwork-frame => threepane}/src/PatchworkFrame.tsx (69%) create mode 100644 threepane/src/account/cleanupLegacyAccountFields.ts rename {patchwork-frame => threepane}/src/account/ensureSubdocs.ts (97%) create mode 100644 threepane/src/account/ensureThreepaneConfig.ts create mode 100644 threepane/src/components/ContextSidebar.tsx create mode 100644 threepane/src/components/ContextTabs.tsx create mode 100644 threepane/src/components/DocumentTitle.tsx create mode 100644 threepane/src/components/FrameTopBar.tsx rename {patchwork-frame => threepane}/src/components/MainDocumentView.tsx (100%) rename {patchwork-frame => threepane}/src/components/Sidebar.tsx (100%) create mode 100644 threepane/src/components/SidebarWidgets.tsx create mode 100644 threepane/src/components/Tray.tsx create mode 100644 threepane/src/datatypes.ts rename {patchwork-frame => threepane}/src/hooks/index.ts (100%) rename {patchwork-frame => threepane}/src/hooks/useDebugRegistryToast.tsx (100%) rename {patchwork-frame => threepane}/src/hooks/useProviderReady.ts (100%) rename {patchwork-frame => threepane}/src/hooks/useSidebarResize.ts (100%) rename {patchwork-frame => threepane}/src/hooks/useSidebarState.ts (100%) create mode 100644 threepane/src/index.tsx rename {patchwork-frame => threepane}/src/styles.css (71%) create mode 100644 threepane/src/types.ts rename {patchwork-frame => threepane}/tsconfig.json (100%) rename {patchwork-frame => threepane}/vite.config.ts (100%) diff --git a/frame-configurator/src/FrameConfigurator.tsx b/frame-configurator/src/FrameConfigurator.tsx index 2fe9e5f9..3e2279ff 100644 --- a/frame-configurator/src/FrameConfigurator.tsx +++ b/frame-configurator/src/FrameConfigurator.tsx @@ -16,7 +16,14 @@ import { } from "solid-js"; import { createStore, reconcile } from "solid-js/store"; import { render } from "solid-js/web"; -import type { TinyPatchworkLayoutDoc } from "./types"; +import type { + TinyPatchworkLayoutDoc, + ThreepaneConfigDoc, + 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; @@ -465,16 +472,22 @@ function FrameConfiguratorUI(props: { () => props.handle.url ); + // The doctitle + contextbar config now live in the threepane config doc; we + // edit it here. Entries are [toolId, docId] pairs — the docid is the account + // doc (a placeholder; the frame feeds doctitle the selected doc). + const [threepaneDoc, threepaneHandle] = useDocument( + () => accountDoc()?.tools?.["threepane"] + ); + const allTools = useToolDescriptions(); const frameOptions = createMemo(() => filterToolsByTag([...allTools], "frame-tool") ); - const sidebarOptions = createMemo(() => - filterToolsByTag([...allTools], "sidebar-account") - ); const documentToolbarOptions = createMemo(() => - filterToolsByTag([...allTools], "titlebar-tool") + filterToolsByTag([...allTools], "titlebar-tool").filter( + (o) => !INTRINSIC_DOCTITLE_TOOLS.has(o.id) + ) ); const contextToolOptions = createMemo(() => filterToolsByTag([...allTools], "context-tool") @@ -482,6 +495,22 @@ function FrameConfiguratorUI(props: { const docUrl = props.handle.url; + const doctitleIds = () => + threepaneDoc()?.doctitle?.tools?.map((ref) => ref[0]); + const contextIds = () => threepaneDoc()?.contextbar?.tabs?.map((ref) => ref[0]); + + const toPairs = (ids: string[]): ToolRef[] => + ids.map((id) => [id, docUrl]); + + const setDoctitle = (next: string[]) => + threepaneHandle()?.change((doc) => { + doc.doctitle.tools = toPairs(next); + }); + const setContext = (next: string[]) => + threepaneHandle()?.change((doc) => { + doc.contextbar.tabs = toPairs(next); + }); + const setField = ( key: K, value: TinyPatchworkLayoutDoc[K] @@ -491,16 +520,6 @@ function FrameConfiguratorUI(props: { }); }; - const setArrayField = ( - key: keyof TinyPatchworkLayoutDoc, - next: string[] - ) => { - props.handle.change((doc: any) => { - const arr = doc[key]; - arr.splice(0, arr.length, ...next); - }); - }; - return ( - setField("accountSidebarToolId", v as any)} - options={sidebarOptions()} - docUrl={docUrl} - /> - - setArrayField("documentToolbarToolIds", next)} - allOptions={documentToolbarOptions()} - docUrl={docUrl} - /> - - setArrayField("contextToolIds", next)} - allOptions={contextToolOptions()} - /> + Preparing layout configuration…

+ } + > + + + +
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) => ( -
- - - - - {ids.map((toolId) => ( - - ))} - - - - -
- )} -
- ); -} - -// lucide `panel-left-open` -function PanelLeftOpenIcon() { - return ( - - - - - - ); -} - -// lucide `panel-right-open` -function PanelRightOpenIcon() { - return ( - - - - - - ); -} diff --git a/patchwork-frame/src/datatypes.ts b/patchwork-frame/src/datatypes.ts deleted file mode 100644 index 2a79900b..00000000 --- a/patchwork-frame/src/datatypes.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { DatatypeImplementation } from "@inkandswitch/patchwork-plugins"; -import type { AccountDoc } 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 = "patchwork-frame"; - doc.accountSidebarToolId = "chee/sideboard"; - doc.contextToolIds = ["comments-view", "history-view", "context-view"]; - doc.documentToolbarToolIds = [ - "document-title", - "spacer", - "highlight-changes-checkbox", - "add-doc-to-sidebar-button", - ]; - }, - getTitle: () => "Patchwork Account", -}; diff --git a/patchwork-frame/src/index.tsx b/patchwork-frame/src/index.tsx deleted file mode 100644 index 5431eda3..00000000 --- a/patchwork-frame/src/index.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { Plugin, ToolImplementation } from "@inkandswitch/patchwork-plugins"; -import { render } from "solid-js/web"; -import type { AccountDoc } from "./types"; - -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:tool", - id: "patchwork-frame", - tags: ["frame-tool"], - name: "Patchwork Frame", - icon: "Window", - supportedDatatypes: ["account"], - async load(): Promise> { - const { PatchworkFrame } = await import("./PatchworkFrame"); - return (handle, element) => { - return render( - () => , - element - ); - }; - }, - }, -]; diff --git a/patchwork-frame/src/types.ts b/patchwork-frame/src/types.ts deleted file mode 100644 index aebbbb85..00000000 --- a/patchwork-frame/src/types.ts +++ /dev/null @@ -1,23 +0,0 @@ -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; - accountSidebarToolId: string; - contextToolIds: string[]; - documentToolbarToolIds: string[]; - - rootFolderUrl?: AutomergeUrl; - moduleSettingsUrl?: AutomergeUrl; - contactUrl?: AutomergeUrl; -}; - -/** @deprecated use AccountDoc */ -export type TinyPatchworkConfigDoc = AccountDoc; diff --git a/sideboard/src/index.css b/sideboard/src/index.css index d8d01766..0256fd1f 100644 --- a/sideboard/src/index.css +++ b/sideboard/src/index.css @@ -2,62 +2,59 @@ :root, :host, [theme] { - --sideboard-fill: var( + --document-list-fill: var( --studio-chrome, light-dark( color-mix(in oklch, var(--studio-secondary, #33ccf8), var(--studio-fill, white) 80%), black ) ); - --sideboard-fill-offset: color-mix( + --document-list-fill-offset: color-mix( in oklab, - var(--sideboard-fill), + var(--document-list-fill), oklch(1 0 0 / 1) 10% ); - /* the footer sits a touch darker than the sideboard surface. */ - --sideboard-footer-fill: var(--studio-chrome-fill-offset-10); + --document-list-line: var(--studio-line, black); + --document-list-widget-fill: var(--studio-fill, white); + --document-list-widget-line: var(--document-list-line); - --sideboard-line: var(--studio-line, black); - --sideboard-widget-fill: var(--studio-fill, white); - --sideboard-widget-line: var(--sideboard-line); + --document-list-padding-x: 0.5rem; - --sideboard-padding-x: 0.5rem; - - --sideboard-radius: 0.275rem; + --document-list-radius: 0.275rem; --placeholder-line: var(--studio-line-offset-50, #808e80); - --sideboard-search-line: var(--studio-line, #000); + --document-list-search-line: var(--studio-line, #000); - --sideboard-primary: color-mix(in oklch, var(--studio-secondary, #33ccf8), var(--studio-fill, white) 60%); + --document-list-primary: color-mix(in oklch, var(--studio-secondary, #33ccf8), var(--studio-fill, white) 60%); - --sideboard-create-new-fill: var(--studio-fill, white); - --sideboard-create-new-line: color-mix( + --document-list-create-new-fill: var(--studio-fill, white); + --document-list-create-new-line: color-mix( in oklch, - var(--sideboard-primary), + var(--document-list-primary), var(--studio-line, black) 30% ); - --sideboard-create-new-border-line: color-mix( + --document-list-create-new-border-line: color-mix( in oklch, - var(--sideboard-primary), - var(--sideboard-create-new-fill) 50% + var(--document-list-primary), + var(--document-list-create-new-fill) 50% ); - --sideboard-create-new-shadow-color: color-mix( + --document-list-create-new-shadow-color: color-mix( in oklch, - var(--sideboard-fill), - var(--sideboard-line) 10% + var(--document-list-fill), + var(--document-list-line) 10% ); - --sideboard-family: var(--studio-family-sans, system-ui, sans-serif); - --sideboard-family-code: var(--studio-family-code, ui-monospace, monospace); - --sideboard-muted: var(--studio-line-offset-40, #666); - --sideboard-border: var(--studio-fill-offset-10, #e5e5e5); - --sideboard-border-strong: var(--studio-fill-offset-20, #ccc); - /* subtle divider on the sideboard's chrome surface - the same base-theme + --document-list-family: var(--studio-family-sans, system-ui, sans-serif); + --document-list-family-code: var(--studio-family-code, ui-monospace, monospace); + --document-list-muted: var(--studio-line-offset-40, #666); + --document-list-border: var(--studio-fill-offset-10, #e5e5e5); + --document-list-border-strong: var(--studio-fill-offset-20, #ccc); + /* subtle divider on the document-list's chrome surface - the same base-theme token the patchwork-frame sidebar divider uses (chrome mixed toward the chrome ink, so it tracks the palette and reads correctly light/dark). */ - --sideboard-divider: var(--studio-chrome-offset-20, #d4d4d4); - --sideboard-added: var(--studio-added, #35f7ca); - --sideboard-danger: var(--studio-danger, #ff6a90); - --sideboard-toggle-hover: color-mix(in oklch, var(--studio-line, black), transparent 93%); + --document-list-divider: var(--studio-chrome-offset-20, #d4d4d4); + --document-list-added: var(--studio-added, #35f7ca); + --document-list-danger: var(--studio-danger, #ff6a90); + --document-list-toggle-hover: color-mix(in oklch, var(--studio-line, black), transparent 93%); } } @@ -70,27 +67,27 @@ button { padding: 0; } -.sideboard, -.sideboard * { +.document-list, +.document-list * { box-sizing: border-box; } -.sideboard { - background: var(--sideboard-fill); +.document-list { + background: var(--document-list-fill); display: flex; height: 100%; flex-direction: column; gap: 0.75rem; - font-family: var(--sideboard-family); - padding-inline: var(--sideboard-padding-x); + font-family: var(--document-list-family); + padding-inline: var(--document-list-padding-x); /* Space above the doclist. Defaults to just the top safe-area inset; hosts - like patchwork-frame override --sideboard-top-margin to also clear their + like patchwork-frame override --document-list-top-margin to also clear their own header. Declared via the var() fallback (not on :host) so an ancestor's value can cascade in. */ - padding-top: var(--sideboard-top-margin, env(safe-area-inset-top, 0px)); + padding-top: var(--document-list-top-margin, env(safe-area-inset-top, 0px)); padding-bottom: env(safe-area-inset-bottom); --depth: 0; - .sideboard-close-button { + .document-list-close-button { display: flex; align-items: center; align-self: flex-start; @@ -101,10 +98,10 @@ button { border: none; cursor: pointer; opacity: 0.5; - color: var(--sideboard-line); + color: var(--document-list-line); } - .sideboard-close-button:hover { + .document-list-close-button:hover { opacity: 1; } } @@ -114,9 +111,9 @@ button { padding-inline: 0.75rem; padding-block: 0.375rem; border: none; - border-radius: var(--sideboard-radius); - background: var(--sideboard-create-new-fill); - color: var(--sideboard-line); + border-radius: var(--document-list-radius); + background: var(--document-list-create-new-fill); + color: var(--document-list-line); font-weight: 400; align-items: center; justify-content: start; @@ -137,7 +134,7 @@ button { justify-content: center; cursor: grab; /* icon rides a little paler than the full ink at rest */ - color: color-mix(in oklch, var(--sideboard-line), transparent 35%); + color: color-mix(in oklch, var(--document-list-line), transparent 35%); transition: color 0.12s ease, box-shadow 0.12s ease; @@ -145,7 +142,7 @@ button { /* darken and grow a little buttony shadow on hover */ .create-new-button--square:hover { - color: var(--sideboard-line); + color: var(--document-list-line); box-shadow: var(--studio-shadow, 0 1px 3px rgb(0 0 0 / 0.1), 0 1px 2px rgb(0 0 0 / 0.06)); } @@ -174,7 +171,7 @@ button { /* sticky toolbar pinned to the top of the scrolling doclist: square new-doc button + filter, laid out horizontally. */ -.sideboard__toolbar { +.document-list__toolbar { position: sticky; top: 0; z-index: 3; @@ -182,11 +179,11 @@ button { align-items: center; gap: 0.375rem; padding: 0.375rem; - background: var(--sideboard-widget-fill); - border-bottom: 1px solid var(--sideboard-divider); + background: var(--document-list-widget-fill); + border-bottom: 1px solid var(--document-list-divider); } -.sideboard__toolbar .sideboard__filter-container { +.document-list__toolbar .document-list__filter-container { flex: 1; min-width: 0; } @@ -195,10 +192,10 @@ button { height: 1em; } -.sideboard__filter-container { +.document-list__filter-container { position: relative; font-size: 0.875rem; - border-radius: var(--sideboard-radius); + border-radius: var(--document-list-radius); .search-icon { height: 1em; top: 0.4lh; @@ -209,23 +206,23 @@ button { } &:focus-within { .search-icon { - stroke: var(--sideboard-line); + stroke: var(--document-list-line); } } } -.sideboard__filter { +.document-list__filter { width: 100%; - background: var(--sideboard-widget-fill); - color: var(--sideboard-widget-line); + background: var(--document-list-widget-fill); + color: var(--document-list-widget-line); padding-left: 2em; padding-right: 0.75rem; padding-top: 0.375rem; padding-bottom: 0.375rem; border: none; - border-radius: var(--sideboard-radius); + border-radius: var(--document-list-radius); &::placeholder { color: var(--placeholder-line); @@ -235,15 +232,15 @@ button { outline-color: var(--sidebar-widget-line); outline-offset: 1px; box-shadow: 0 0 0 4px #fff; - border-radius: var(--sideboard-radius); + border-radius: var(--document-list-radius); } } -.sideboard__doclist { +.document-list__doclist { --doclist-padding-x: 0.75rem; display: flex; flex-direction: column; - border-radius: var(--sideboard-radius); + border-radius: var(--document-list-radius); box-shadow: 0 1px 2px rgba(0, 20, 50, 0.05); width: 100%; max-height: 100%; @@ -257,10 +254,10 @@ button { } } -.sideboard__doclist--drag-over { - background: color-mix(in oklch, var(--sideboard-primary), transparent 95%); +.document-list__doclist--drag-over { + background: color-mix(in oklch, var(--document-list-primary), transparent 95%); box-shadow: - 0 0 0 2px var(--sideboard-primary) inset, + 0 0 0 2px var(--document-list-primary) inset, 0 1px 2px rgba(0, 20, 50, 0.05); } @@ -277,8 +274,8 @@ button { &:focus-visible { outline-color: #000; outline-offset: -2px; - box-shadow: 0 0 0 4px var(--sideboard-widget-fill); - border-radius: var(--sideboard-radius); + box-shadow: 0 0 0 4px var(--document-list-widget-fill); + border-radius: var(--document-list-radius); } &:active { @@ -287,19 +284,19 @@ button { } &[aria-selected="true"] { - background: var(--sideboard-primary); - .sideboard__doclist > &:first-child { - border-top-left-radius: var(--sideboard-radius); - border-top-right-radius: var(--sideboard-radius); + background: var(--document-list-primary); + .document-list__doclist > &:first-child { + border-top-left-radius: var(--document-list-radius); + border-top-right-radius: var(--document-list-radius); } - .sideboard__doclist > &:last-child { - border-bottom-left-radius: var(--sideboard-radius); - border-bottom-right-radius: var(--sideboard-radius); + .document-list__doclist > &:last-child { + border-bottom-left-radius: var(--document-list-radius); + border-bottom-right-radius: var(--document-list-radius); } } &[aria-checked="true"] { outline: 2px dashed - color-mix(in oklch, var(--sideboard-line), transparent 50%); + color-mix(in oklch, var(--document-list-line), transparent 50%); outline-offset: -4px; } &[data-dnd-dragging] { @@ -312,7 +309,7 @@ button { margin-left: calc(var(--depth) * 1.75rem); right: 0; height: 2px; - background: var(--sideboard-line); + background: var(--document-list-line); } &[data-dnd-droplist-state="above"]::before { top: -1px; @@ -323,17 +320,17 @@ button { } .document-list-folder[data-drop-state="inside"] { - outline: 2px solid var(--sideboard-primary); + outline: 2px solid var(--document-list-primary); outline-offset: -2px; - border-radius: var(--sideboard-radius); - background: color-mix(in oklch, var(--sideboard-primary), transparent 50%); + border-radius: var(--document-list-radius); + background: color-mix(in oklch, var(--document-list-primary), transparent 50%); > .document-list-item::before { content: ""; position: absolute; left: 0; right: 0; height: 2px; - background: var(--sideboard-line); + background: var(--document-list-line); bottom: 0; } [data-dnd-droplist-state]::before { @@ -365,7 +362,7 @@ button { margin-left: -0.75ch; padding-inline: 0.25ch; &:hover { - background: var(--sideboard-toggle-hover); + background: var(--document-list-toggle-hover); } } @@ -376,22 +373,22 @@ button { .document-list-folder__circular-ref { padding: 0.5rem; padding-left: calc(2.2rem * var(--depth)); - color: var(--sideboard-muted); + color: var(--document-list-muted); font-style: italic; font-size: 0.9em; } -.sideboard__item { +.document-list__item { content-visibility: auto; contain-intrinsic-size: auto 2.25rem; } -.sideboard__item--invisible { +.document-list__item--invisible { display: none; } -.sideboard__item--visible, -.sideboard__item--invisible:has(.sideboard__item--visible) { +.document-list__item--visible, +.document-list__item--invisible:has(.document-list__item--visible) { display: block; } @@ -399,43 +396,13 @@ button { padding-left: calc(1.8rem * var(--depth)); } -.sideboard-footer { - container-type: inline-size; - height: 4rem; - min-height: 4em; - display: flex; - overflow: hidden; - justify-content: space-between; - align-items: center; - gap: 1rem; - padding: var(--sideboard-padding-x); - padding-bottom: calc(0.25rem + env(safe-area-inset-bottom)); - padding-left: calc(env(safe-area-inset-left) + var(--sideboard-padding-x)); - padding-right: calc(env(safe-area-inset-right) + var(--sideboard-padding-x)); - margin-inline: calc(-1 * var(--sideboard-padding-x)); - background: var(--sideboard-footer-fill); - color: var(--sideboard-line); - border-top: 1px solid var(--sideboard-divider); - margin-top: auto; - > button { - border: 1px solid transparent; - padding-inline: 0.25rem; - padding-block: 0.125rem; - border-radius: 3px; - white-space: nowrap; - &:hover { - border: 1px solid var(--sideboard-divider); - } - } -} - -.sideboard-widget { +.document-list-widget { width: 100%; - background: var(--sideboard-widget-fill); - color: var(--sideboard-widget-line); + background: var(--document-list-widget-fill); + color: var(--document-list-widget-line); } -.sideboard-widget:empty { +.document-list-widget:empty { display: none; } @@ -461,10 +428,10 @@ button { max-height: min(400px, 50vh); overflow-y: auto; padding: 4px; - background-color: var(--sideboard-widget-fill); + background-color: var(--document-list-widget-fill); font-size: 0.8rem; border-radius: 6px; - border: 1px solid var(--sideboard-border); + border: 1px solid var(--document-list-border); box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); @@ -482,7 +449,7 @@ button { .popmenu__sub-trigger { font-size: 0.8rem; line-height: 1; - color: var(--sideboard-widget-line); + color: var(--document-list-widget-line); border-radius: 4px; display: flex; align-items: center; @@ -495,14 +462,14 @@ button { cursor: default; } .popmenu__sub-trigger[data-expanded] { - background-color: var(--sideboard-fill-offset); - color: var(--sideboard-line); + background-color: var(--document-list-fill-offset); + color: var(--document-list-line); } .popmenu__item[data-disabled], .popmenu__checkbox-item[data-disabled], .popmenu__radio-item[data-disabled], .popmenu__sub-trigger[data-disabled] { - color: var(--sideboard-muted); + color: var(--document-list-muted); opacity: 0.5; pointer-events: none; } @@ -513,10 +480,10 @@ button { outline: none; background-color: color-mix( in oklch, - var(--sideboard-primary), - var(--sideboard-widget-fill) 20% + var(--document-list-primary), + var(--document-list-widget-fill) 20% ); - color: var(--sideboard-widget-line); + color: var(--document-list-widget-line); } @keyframes contentShow { from { @@ -543,10 +510,10 @@ button { outline: none; background-color: color-mix( in oklch, - var(--sideboard-primary), - var(--sideboard-widget-fill) 20% + var(--document-list-primary), + var(--document-list-widget-fill) 20% ); - color: var(--sideboard-widget-line); + color: var(--document-list-widget-line); } .create-new-filter { @@ -558,19 +525,19 @@ button { box-sizing: border-box; padding: 6px 8px; font-size: 0.8rem; - border: 1px solid var(--sideboard-border-strong); + border: 1px solid var(--document-list-border-strong); border-radius: 4px; - background: var(--sideboard-widget-fill); - color: var(--sideboard-widget-line); + background: var(--document-list-widget-fill); + color: var(--document-list-widget-line); outline: none; } .create-new-filter__input:focus { - border-color: var(--sideboard-primary); + border-color: var(--document-list-primary); } .create-new-filter__input::placeholder { - color: var(--sideboard-muted); + color: var(--document-list-muted); } /* Share Modal */ @@ -589,8 +556,8 @@ button { } .share-modal__content { - background: var(--sideboard-widget-fill); - color: var(--sideboard-widget-line); + background: var(--document-list-widget-fill); + color: var(--document-list-widget-line); border-radius: 8px; width: 90vw; max-width: 480px; @@ -607,7 +574,7 @@ button { align-items: center; padding: 1.25rem 1.5rem; border-bottom: 1px solid - color-mix(in oklab, var(--sideboard-widget-fill), black 15%); + color-mix(in oklab, var(--document-list-widget-fill), black 15%); } .share-modal__header h2 { @@ -621,7 +588,7 @@ button { border: none; cursor: pointer; font-size: 1.5rem; - color: var(--sideboard-widget-line); + color: var(--document-list-widget-line); width: 2rem; height: 2rem; display: flex; @@ -632,7 +599,7 @@ button { } .share-modal__close:hover { - background: color-mix(in oklab, var(--sideboard-widget-fill), black 10%); + background: color-mix(in oklab, var(--document-list-widget-fill), black 10%); } .share-modal__body { @@ -655,7 +622,7 @@ button { .share-modal__public-status { font-size: 0.875rem; - color: var(--sideboard-widget-line); + color: var(--document-list-widget-line); } .share-modal__public-actions { @@ -678,27 +645,27 @@ button { .share-modal__input { width: 100%; padding: 0.5rem 0.75rem; - border: 1px solid color-mix(in oklab, var(--sideboard-widget-fill), black 15%); + border: 1px solid color-mix(in oklab, var(--document-list-widget-fill), black 15%); border-radius: 4px; - background: var(--sideboard-widget-fill); - color: var(--sideboard-widget-line); + background: var(--document-list-widget-fill); + color: var(--document-list-widget-line); font-size: 0.875rem; - font-family: var(--sideboard-family-code); + font-family: var(--document-list-family-code); resize: vertical; min-height: 4rem; } .share-modal__input:focus { - outline: 2px solid var(--sideboard-primary); + outline: 2px solid var(--document-list-primary); outline-offset: 1px; } .share-modal__select { padding: 0.5rem 0.75rem; - border: 1px solid color-mix(in oklab, var(--sideboard-widget-fill), black 15%); + border: 1px solid color-mix(in oklab, var(--document-list-widget-fill), black 15%); border-radius: 4px; - background: var(--sideboard-widget-fill); - color: var(--sideboard-widget-line); + background: var(--document-list-widget-fill); + color: var(--document-list-widget-line); font-size: 0.875rem; } @@ -726,7 +693,7 @@ button { .share-modal__divider { border: none; border-top: 1px solid - color-mix(in oklab, var(--sideboard-widget-fill), black 15%); + color-mix(in oklab, var(--document-list-widget-fill), black 15%); margin: 0 0 1.5rem 0; } @@ -739,7 +706,7 @@ button { .share-modal__loading, .share-modal__empty { font-size: 0.875rem; - color: color-mix(in oklab, var(--sideboard-widget-line), transparent 40%); + color: color-mix(in oklab, var(--document-list-widget-line), transparent 40%); font-style: italic; } @@ -764,7 +731,7 @@ button { align-items: center; justify-content: space-between; padding: 0.75rem; - background: color-mix(in oklab, var(--sideboard-widget-fill), black 5%); + background: color-mix(in oklab, var(--document-list-widget-fill), black 5%); border-radius: 4px; } @@ -775,7 +742,7 @@ button { } .share-modal__member-id { - font-family: var(--sideboard-family-code); + font-family: var(--document-list-family-code); font-size: 0.8rem; } @@ -785,25 +752,25 @@ button { .share-modal__member-id--public { font-weight: 600; - color: var(--sideboard-added); + color: var(--document-list-added); } .share-modal__you-label { - color: var(--sideboard-primary); + color: var(--document-list-primary); font-weight: 600; } .share-modal__member-access { font-size: 0.75rem; text-transform: uppercase; - color: color-mix(in oklab, var(--sideboard-widget-line), transparent 30%); + color: color-mix(in oklab, var(--document-list-widget-line), transparent 30%); } .share-modal__remove-button { background: transparent; border: none; cursor: pointer; - color: color-mix(in oklab, var(--sideboard-widget-line), transparent 40%); + color: color-mix(in oklab, var(--document-list-widget-line), transparent 40%); padding: 0.25rem; border-radius: 4px; transition: all 0.15s ease; @@ -813,7 +780,71 @@ button { } .share-modal__remove-button:hover { - color: var(--sideboard-danger); + color: var(--document-list-danger); background: color-mix(in oklch, #ef4444, transparent 90%); } + +/* Combined chee/sideboard tool: the document list with the account bar pinned + below it. The document-list panel fills; the bar sits at the bottom. */ +.sideboard { + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; +} + +.sideboard > .document-list { + flex: 1; + height: auto; + min-height: 0; + padding-bottom: 0; +} + +/* The account bar: avatar (account picker), packages, settings. Usable as its + own tool or pinned to the bottom of the combined sideboard. */ +.account-bar { + container-type: inline-size; + display: flex; + align-items: center; + gap: 1rem; + min-height: 4rem; + background: var(--document-list-fill); + color: var(--document-list-line); + border-top: 1px solid var(--document-list-divider); + padding-block: 0.25rem; + padding-bottom: calc(0.25rem + env(safe-area-inset-bottom)); + padding-left: calc(env(safe-area-inset-left) + var(--document-list-padding-x)); + padding-right: calc(env(safe-area-inset-right) + var(--document-list-padding-x)); +} + +.account-bar__button { + display: flex; + align-items: center; + border: 1px solid transparent; + padding-inline: 0.25rem; + padding-block: 0.125rem; + border-radius: 3px; + background: transparent; + color: inherit; + font: inherit; + white-space: nowrap; + cursor: pointer; +} + +.account-bar__button:hover { + border-color: var(--document-list-divider); +} + +.account-bar__avatar { + padding: 0; + width: 1.75rem; + height: 1.75rem; + flex-shrink: 0; +} + +.account-bar__avatar patchwork-view { + display: block; + width: 100%; + height: 100%; +} diff --git a/sideboard/src/index.tsx b/sideboard/src/index.tsx index 3ba6d59f..cdb24073 100644 --- a/sideboard/src/index.tsx +++ b/sideboard/src/index.tsx @@ -1,7 +1,8 @@ import "./index.css"; import { render } from "solid-js/web"; import { type ToolImplementation } from "@inkandswitch/patchwork-plugins"; -import type { TinyPatchworkAccountDoc } from "./types.ts"; +import type { FolderDoc } from "@inkandswitch/patchwork-filesystem"; +import type { SideboardAccountDoc } from "./types.ts"; async function loadStyles() { const url = new URL("./index.css", import.meta.url); @@ -17,15 +18,65 @@ function addStyles(textContent: string) { } export const plugins = [ + { + id: "chee/document-list", + type: "patchwork:tool", + name: "Document List", + supportedDatatypes: ["folder"], + icon: "FolderOpen", + unlisted: true, + async load(): Promise> { + const [{ DocumentListPanel }, styles] = await Promise.all([ + import("./sideboard/document-list-panel.tsx"), + loadStyles(), + ]); + return (handle, element) => { + addStyles(styles); + return render( + () => ( + + ), + element + ); + }; + }, + }, + { + id: "chee/account-bar", + type: "patchwork:tool", + name: "Account Bar", + supportedDatatypes: ["account"], + icon: "UserCircle", + unlisted: true, + async load(): Promise> { + const [{ AccountBar }, styles] = await Promise.all([ + import("./sideboard/account-bar.tsx"), + loadStyles(), + ]); + return (handle, element) => { + addStyles(styles); + return render( + () => ( + + ), + element + ); + }; + }, + }, { id: "chee/sideboard", type: "patchwork:tool", tags: ["sidebar-account"], name: "Sideboard", - supportedDatatypes: ["patchwork:account", "folder"], + supportedDatatypes: ["account"], icon: "FolderOpen", unlisted: true, - async load(): Promise> { + async load(): Promise> { const [{ Sideboard }, styles] = await Promise.all([ import("./sideboard/sideboard.tsx"), loadStyles(), @@ -34,7 +85,6 @@ export const plugins = [ addStyles(styles); return render( () => ( - // @ts-expect-error - handle type doesn't know it supports folders ), element diff --git a/sideboard/src/sideboard/account-bar.tsx b/sideboard/src/sideboard/account-bar.tsx new file mode 100644 index 00000000..73720a01 --- /dev/null +++ b/sideboard/src/sideboard/account-bar.tsx @@ -0,0 +1,56 @@ +import { makeDocumentProjection } from "@automerge/automerge-repo-solid-primitives"; +import { Show } from "solid-js"; +import type { OpenDocumentEventDetail } from "@inkandswitch/patchwork-elements"; + +import type { PatchworkToolProps, SideboardAccountDoc } from "../types.ts"; +import { createOpenEvent } from "./events.ts"; + +/** + * The account bar: the contact avatar (opens the account picker), Packages + * (module settings) and Settings (frame configurator). Usable as its own tool + * or pinned to the bottom of the combined sideboard. + */ +export function AccountBar(props: PatchworkToolProps) { + const doc = makeDocumentProjection(props.handle); + + const open = (detail: OpenDocumentEventDetail) => + props.element.dispatchEvent(createOpenEvent(detail)); + + return ( +
+ + + + + + + + + +
+ ); +} diff --git a/sideboard/src/sideboard/create-new.tsx b/sideboard/src/sideboard/create-new.tsx index 62b91dfc..7cd6f0b7 100644 --- a/sideboard/src/sideboard/create-new.tsx +++ b/sideboard/src/sideboard/create-new.tsx @@ -220,13 +220,13 @@ export default function CreateNew(props: CreateNewProps) { preview.style.cssText = ` position: absolute; top: -1000px; - background: var(--sideboard-primary); + background: var(--document-list-primary); padding: 0.5rem 0.75rem; - border-radius: var(--sideboard-radius); + border-radius: var(--document-list-radius); font-family: inherit; font-size: 0.9rem; pointer-events: none; - color: var(--sideboard-line); + color: var(--document-list-line); box-shadow: 0 2px 8px rgba(0,0,0,0.1); `; preview.textContent = "new doc"; diff --git a/sideboard/src/sideboard/document-list-panel.tsx b/sideboard/src/sideboard/document-list-panel.tsx new file mode 100644 index 00000000..31a7a615 --- /dev/null +++ b/sideboard/src/sideboard/document-list-panel.tsx @@ -0,0 +1,163 @@ +import { useDocument } from "@automerge/automerge-repo-solid-primitives"; +import type { AutomergeUrl, Repo } from "@automerge/automerge-repo"; +import type { PatchworkViewElement } from "@inkandswitch/patchwork-elements"; +import type { OpenDocumentEventDetail } from "@inkandswitch/patchwork-elements"; +import type { FolderDoc } from "@inkandswitch/patchwork-filesystem"; +import { createSignal } from "solid-js"; + +import { filter, setFilter, setPendingNewDoc } from "./state.ts"; +import CreateNew from "./create-new.tsx"; +import { createOpenEvent } from "./events.ts"; +import { SearchIcon } from "./icons.tsx"; +import { DocumentList } from "./document-list/document-list.tsx"; +import { subscribe } from "@inkandswitch/patchwork-providers-solid"; +import { handleFilesDrop } from "./document-list/file-drop.ts"; +import { copyMode, isNewDocDrag } from "./dnd/dnd.ts"; +import { executeDrop } from "./dnd/operations.ts"; +import { getDndPayload, hasDocumentDrag } from "./dnd/payload.ts"; + +/** + * The document-list panel: a sticky toolbar (new-doc button + filter) over a + * scrolling, recursive list of the folder's documents and subfolders. Renders a + * single folder, given its url. + */ +export function DocumentListPanel(props: { + folderUrl: AutomergeUrl; + repo: Repo; + element: PatchworkViewElement; +}) { + const [folder, folderHandle] = useDocument(() => props.folderUrl, { + repo: props.repo, + }); + + const selectedDocUrls = subscribe( + props.element, + { type: "patchwork:selected-doc" }, + [] + ); + + function open(detail: OpenDocumentEventDetail) { + props.element.dispatchEvent(createOpenEvent(detail)); + } + + const [isDraggingFile, setIsDraggingFile] = createSignal(false); + + return ( + + ); +} diff --git a/sideboard/src/sideboard/document-list/document-list.tsx b/sideboard/src/sideboard/document-list/document-list.tsx index 7c80b74f..1ef6cd41 100644 --- a/sideboard/src/sideboard/document-list/document-list.tsx +++ b/sideboard/src/sideboard/document-list/document-list.tsx @@ -96,7 +96,7 @@ export function DocumentList(props: DocumentListProps) { }; const placeholder = () => ( -
+
diff --git a/sideboard/src/sideboard/document-list/item.tsx b/sideboard/src/sideboard/document-list/item.tsx index 1bd87082..99f4e7bc 100644 --- a/sideboard/src/sideboard/document-list/item.tsx +++ b/sideboard/src/sideboard/document-list/item.tsx @@ -196,13 +196,13 @@ export default function Item(props: { preview.style.cssText = ` position: absolute; top: -1000px; - background: var(--sideboard-primary); + background: var(--document-list-primary); padding: 0.5rem 0.75rem; - border-radius: var(--sideboard-radius); + border-radius: var(--document-list-radius); font-family: inherit; font-size: 0.9rem; pointer-events: none; - color: var(--sideboard-line); + color: var(--document-list-line); box-shadow: 0 2px 8px rgba(0,0,0,0.1); `; const count = dragstack.size; diff --git a/sideboard/src/sideboard/sideboard.tsx b/sideboard/src/sideboard/sideboard.tsx index 9727625e..6d63b0cc 100644 --- a/sideboard/src/sideboard/sideboard.tsx +++ b/sideboard/src/sideboard/sideboard.tsx @@ -1,205 +1,31 @@ -import { - makeDocumentProjection, - useDocument, -} from "@automerge/automerge-repo-solid-primitives"; - -type TinyPatchworkAccountDoc = { - rootFolderUrl: AutomergeUrl; - moduleSettingsUrl: AutomergeUrl; - contactUrl: AutomergeUrl; -}; - -import type { PatchworkToolProps } from "../types.ts"; -import { filter, setFilter, setPendingNewDoc } from "./state.ts"; -import CreateNew from "./create-new.tsx"; -import type { FolderDoc } from "@inkandswitch/patchwork-filesystem"; -import { createOpenEvent } from "./events.ts"; -import { SearchIcon } from "./icons.tsx"; -import { DocumentList } from "./document-list/document-list.tsx"; -import type { AutomergeUrl } from "@automerge/automerge-repo"; -import type { OpenDocumentEventDetail } from "@inkandswitch/patchwork-elements"; -import { subscribe } from "@inkandswitch/patchwork-providers-solid"; -import { createSignal, Show } from "solid-js"; -import { handleFilesDrop } from "./document-list/file-drop.ts"; -import { copyMode, isNewDocDrag } from "./dnd/dnd.ts"; -import { executeDrop } from "./dnd/operations.ts"; -import { getDndPayload, hasDocumentDrag } from "./dnd/payload.ts"; - -export function Sideboard( - props: PatchworkToolProps -) { +import { makeDocumentProjection } from "@automerge/automerge-repo-solid-primitives"; +import { Show } from "solid-js"; + +import type { PatchworkToolProps, SideboardAccountDoc } from "../types.ts"; +import { DocumentListPanel } from "./document-list-panel.tsx"; +import { AccountBar } from "./account-bar.tsx"; + +/** + * The combined sideboard: the document-list panel for the account's root folder, + * with the account bar pinned below it. Reads the account document. + */ +export function Sideboard(props: PatchworkToolProps) { const doc = makeDocumentProjection(props.handle); - const [folder, folderHandle] = useDocument( - () => ("rootFolderUrl" in doc ? doc.rootFolderUrl : props.handle.url), - props - ); - - const moduleSettingsUrl = () => - "moduleSettingsUrl" in doc ? doc.moduleSettingsUrl : undefined; - const accountDocUrl = () => props.handle.url; - const contactUrl = () => ("contactUrl" in doc ? doc.contactUrl : undefined); - const selectedDocUrls = subscribe( - props.element, - { type: "patchwork:selected-doc" }, - [] - ); - - function open(detail: OpenDocumentEventDetail) { - props.element.dispatchEvent(createOpenEvent(detail)); - } - - const [isDraggingFile, setIsDraggingFile] = createSignal(false); return ( - + +
); } diff --git a/sideboard/src/types.ts b/sideboard/src/types.ts index 56d23341..88277cdd 100644 --- a/sideboard/src/types.ts +++ b/sideboard/src/types.ts @@ -7,7 +7,9 @@ export interface PatchworkToolProps { element: PatchworkViewElement; } -export type TinyPatchworkAccountDoc = { - rootFolderUrl: AutomergeUrl; - moduleSettingsUrl: AutomergeUrl; +/** The account-doc fields the account bar / combined sideboard read. */ +export type SideboardAccountDoc = { + rootFolderUrl?: AutomergeUrl; + contactUrl?: AutomergeUrl; + moduleSettingsUrl?: AutomergeUrl; }; diff --git a/patchwork-frame/.gitignore b/threepane/.gitignore similarity index 100% rename from patchwork-frame/.gitignore rename to threepane/.gitignore diff --git a/patchwork-frame/eslint.config.ts b/threepane/eslint.config.ts similarity index 100% rename from patchwork-frame/eslint.config.ts rename to threepane/eslint.config.ts diff --git a/patchwork-frame/package.json b/threepane/package.json similarity index 100% rename from patchwork-frame/package.json rename to threepane/package.json diff --git a/patchwork-frame/pnpm-lock.yaml b/threepane/pnpm-lock.yaml similarity index 100% rename from patchwork-frame/pnpm-lock.yaml rename to threepane/pnpm-lock.yaml diff --git a/patchwork-frame/src/PatchworkFrame.tsx b/threepane/src/PatchworkFrame.tsx similarity index 69% rename from patchwork-frame/src/PatchworkFrame.tsx rename to threepane/src/PatchworkFrame.tsx index c371b17f..c29a4247 100644 --- a/patchwork-frame/src/PatchworkFrame.tsx +++ b/threepane/src/PatchworkFrame.tsx @@ -1,9 +1,12 @@ import "@inkandswitch/patchwork-elements"; -import { useDocHandle } from "@automerge/automerge-repo-solid-primitives"; +import { + useDocHandle, + useDocument, +} from "@automerge/automerge-repo-solid-primitives"; import type { AutomergeUrl, DocHandle, Repo } from "@automerge/automerge-repo"; import { subscribe } from "@inkandswitch/patchwork-providers"; import { subscribeDoc } from "@inkandswitch/patchwork-providers-solid"; -import type { AccountDoc } from "./types"; +import type { AccountDoc, ThreepaneConfigDoc, ToolRef } from "./types"; import { useSidebarState, useSidebarResize, @@ -13,7 +16,8 @@ import { } from "./hooks"; import { Sidebar } from "./components/Sidebar"; import { ContextSidebar } from "./components/ContextSidebar"; -import { DocumentToolbar } from "./components/DocumentToolbar"; +import { FrameTopBar } from "./components/FrameTopBar"; +import { SidebarWidgets } from "./components/SidebarWidgets"; import { MainDocumentView } from "./components/MainDocumentView"; import { createEffect, @@ -26,7 +30,19 @@ import { type JSX, } from "solid-js"; import { ensureAccountSubdocs } from "./account/ensureSubdocs"; -import "./styles.css"; +import { ensureThreepaneConfig } from "./account/ensureThreepaneConfig"; +// Imported as a string (not a side-effect import) so the stylesheet is only +// injected when the frame tool actually activates — not when index.js loads. +import frameStyles from "./styles.css?inline"; + +function ensureFrameStyles() { + const id = "patchwork-frame-styles"; + if (document.getElementById(id)) return; + const el = document.createElement("style"); + el.id = id; + el.textContent = frameStyles; + document.head.append(el); +} type DraftsState = { drafts: AutomergeUrl[]; @@ -55,6 +71,7 @@ export const PatchworkFrame = ({ handle: DocHandle; repo: Repo; }) => { + ensureFrameStyles(); const accountDocUrl = handle.url; const [accountProviderElement, setAccountProviderElement] = @@ -119,6 +136,9 @@ function PatchworkFrameInner(props: { // 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); const [docVersion, setDocVersion] = createSignal(0); createEffect(() => { @@ -135,6 +155,39 @@ function PatchworkFrameInner(props: { }); const accountDocUrl = props.handle.url; + // The threepane layout config doc (sidebar widgets / context tabs / doctitle + // tools). Read via handle + version counter (same reason as accountDoc: store + // proxying conflicts with Automerge splice). + const threepaneUrl = () => accountDoc()?.tools?.["threepane"]; + const threepaneConfigHandle = useDocHandle( + () => threepaneUrl(), + { repo: props.repo } + ); + const [threepaneVersion, setThreepaneVersion] = createSignal(0); + createEffect(() => { + const h = threepaneConfigHandle(); + if (!h) return; + const onChange = () => setThreepaneVersion((v) => v + 1); + h.on("change", onChange); + onCleanup(() => h.off("change", onChange)); + }); + const threepaneConfig = createMemo(() => { + threepaneVersion(); + return threepaneConfigHandle()?.doc(); + }); + + // Derived layout lanes — prefer the threepane config doc, fall back to the + // legacy account fields until migration completes. + const doctitleToolIds = () => + threepaneConfig()?.doctitle?.tools?.map((ref) => ref[0]) ?? + accountDoc()?.documentToolbarToolIds; + const contextTabIds = () => + threepaneConfig()?.contextbar?.tabs?.map((ref) => ref[0]) ?? + accountDoc()?.contextToolIds; + const sidebarWidgets = (): ToolRef[] => + threepaneConfig()?.sidebar?.widgets ?? []; + const rootFolderUrl = () => accountDoc()?.rootFolderUrl; + const sidebarState = useSidebarState(); const sidebarResize = useSidebarResize({ setLeftSidebarWidth: sidebarState.setLeftSidebarWidth, @@ -210,6 +263,9 @@ function PatchworkFrameInner(props: { accountDocUrl={accountDocUrl} sidebarState={sidebarState} sidebarResize={sidebarResize} + sidebarWidgets={sidebarWidgets} + configHandle={threepaneConfigHandle} + rootFolderUrl={rootFolderUrl} >
; + configHandle: Accessor | undefined>; + rootFolderUrl: Accessor; children: JSX.Element; }) { return ( <> - {props.accountDoc()?.accountSidebarToolId && ( - + - )} + {props.children} @@ -308,10 +375,13 @@ function FrameLayout(props: { // identity-sensitive toolbar tools (e.g. add-to-sidebar) are unaffected. function DraftDocumentArea(props: { host: HTMLElement; + repo: Repo; accountDoc: Accessor; accountDocUrl: AutomergeUrl; selectedDocUrl: Accessor; selectedToolId: Accessor; + doctitleToolIds: Accessor; + contextTabIds: Accessor; sidebarState: SidebarState; sidebarResize: SidebarResize; selectedContextToolId: Accessor; @@ -370,47 +440,48 @@ function DraftDocumentArea(props: { ref={setFocusProviderElement} > -
- props.accountDoc()?.documentToolbarToolIds} +
+ - !!props.accountDoc()?.accountSidebarToolId && - props.sidebarState.isSidebarCollapsed() - } - onShowLeftSidebar={() => - props.sidebarState.setIsSidebarCollapsed(false) - } - showRightSidebarButton={() => - !!props.accountDoc()?.contextToolIds?.length && - props.sidebarState.isRightSidebarCollapsed() + toolIds={props.doctitleToolIds} + hasLeftSidebar={() => true} + isLeftCollapsed={props.sidebarState.isSidebarCollapsed} + onToggleLeft={() => + props.sidebarState.setIsSidebarCollapsed((v) => !v) } - onShowRightSidebar={() => - props.sidebarState.setIsRightSidebarCollapsed(false) + contextToolIds={props.contextTabIds} + selectedContextToolId={props.selectedContextToolId} + setSelectedContextToolId={props.setSelectedContextToolId} + isRightCollapsed={props.sidebarState.isRightSidebarCollapsed} + rightWidth={props.sidebarState.rightSidebarWidth} + onToggleRight={() => + props.sidebarState.setIsRightSidebarCollapsed((v) => !v) } /> - -
- {!!props.accountDoc()?.contextToolIds?.length && ( - props.accountDoc()?.contextToolIds} - docUrl={props.accountDocUrl} - selectedToolId={props.selectedContextToolId} - setSelectedToolId={props.setSelectedContextToolId} - isCollapsed={props.sidebarState.isRightSidebarCollapsed} - width={props.sidebarState.rightSidebarWidth} - onMouseDown={props.sidebarResize.handleMouseDown} - onToggleClick={props.sidebarResize.handleToggleClick} - onClose={() => - props.sidebarState.setIsRightSidebarCollapsed(true) - } - /> - )} +
+
+ +
+ + + + +
+
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 (
- {(widget, index) => ( + {(widget) => (
-
)} diff --git a/threepane/src/styles.css b/threepane/src/styles.css index 11dd3d16..c12d28e6 100644 --- a/threepane/src/styles.css +++ b/threepane/src/styles.css @@ -16,13 +16,10 @@ --patchwork-frame-space-sm: var(--studio-space-sm, 0.5rem); --patchwork-frame-space-2xl: var(--studio-space-2xl, 3rem); - /* Height the frame reserves for its own header chrome. */ - --patchwork-frame-header-height: 3.5rem; - /* 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( + /* Height of the frame's top strip: the top bar over the main column, and the + matching empty space reserved above the left sidebar's widgets so the two + line up and the left toggle has a home. */ + --patchwork-frame-header-height: calc( env(safe-area-inset-top, 0px) + var(--patchwork-frame-space-2xl) ); @@ -141,6 +138,16 @@ gap: var(--patchwork-frame-space-2xs); background: var(--patchwork-frame-toolbar); color: var(--patchwork-frame-toolbar-fg); + /* Animate the collapsed left-slot in step with the sidebar's width transition + so the title glides up against the pinned toggle instead of jumping. */ + transition: padding-left var(--studio-transition-slow, 0.25s ease); +} + +/* When the left sidebar is collapsed the bar's left edge reaches the frame edge, + where the pinned left toggle lives; reserve a matching slot so the title slides + up against the toggle rather than under it. */ +.frame__topbar--left-collapsed { + padding-left: var(--patchwork-frame-space-2xl); } .frame__content-row { @@ -150,6 +157,19 @@ overflow: hidden; } +/* The left sidebar toggle, pinned to the frame's top-left corner so it keeps the + same spot whether the sidebar is open or closed. Square, filling the top strip; + sits over the sidebar's reserved (empty) top area when open. */ +.frame__left-toggle { + position: absolute; + top: 0; + left: 0; + z-index: 5; + width: var(--patchwork-frame-space-2xl); + height: var(--patchwork-frame-header-height); + padding-top: env(safe-area-inset-top, 0px); +} + /* A sidebar toggle at either end of the top bar (always present). */ .frame__sidebar-toggle { display: flex; @@ -400,8 +420,8 @@ margin-bottom: 0; } -/* Left sidebar: a stack of configured widgets. */ -.threepane-widgets { +/* Left sidebar body: a stack of widgets with the account footer pinned below. */ +.threepane-sidebar { display: flex; flex-direction: column; width: 100%; @@ -409,47 +429,40 @@ min-height: 0; } -.threepane-widget { - position: relative; - flex: 1; - min-height: 0; - overflow: hidden; +/* account / packages / settings footer, pinned to the sidebar bottom */ +.threepane-sidebar__footer { + flex: none; } -.threepane-widget patchwork-view { +.threepane-sidebar__footer patchwork-view { display: block; width: 100%; - height: 100%; + height: auto; } -/* 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; +/* Left sidebar: a stack of configured widgets. The widgets start below the + frame's top strip (the empty space matching the top bar), so the frame owns + that spacing rather than each widget reserving its own top margin. */ +.threepane-widgets { + flex: 1; 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); + flex-direction: column; + width: 100%; + min-height: 0; + padding-top: var(--patchwork-frame-header-height); } -.threepane-widget:hover .threepane-widget__remove { - opacity: 0.7; +.threepane-widget { + position: relative; + flex: 1; + min-height: 0; + overflow: hidden; } -.threepane-widget__remove:hover { - opacity: 1; +.threepane-widget patchwork-view { + display: block; + width: 100%; + height: 100%; } /* empty state: offer to add the document list */ @@ -458,7 +471,6 @@ 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 { From 3b72ac8a257d458c646d546ded5207ec82306f15 Mon Sep 17 00:00:00 2001 From: chee Date: Sun, 28 Jun 2026 13:16:31 +0100 Subject: [PATCH 5/7] update footer styles --- sideboard/src/index.css | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/sideboard/src/index.css b/sideboard/src/index.css index 0256fd1f..9289944f 100644 --- a/sideboard/src/index.css +++ b/sideboard/src/index.css @@ -55,6 +55,7 @@ --document-list-added: var(--studio-added, #35f7ca); --document-list-danger: var(--studio-danger, #ff6a90); --document-list-toggle-hover: color-mix(in oklch, var(--studio-line, black), transparent 93%); + --account-bar-fill: var(--studio-chrome-offset-10) } } @@ -809,13 +810,14 @@ button { align-items: center; gap: 1rem; min-height: 4rem; - background: var(--document-list-fill); + background: var(--account-bar-fill); color: var(--document-list-line); border-top: 1px solid var(--document-list-divider); padding-block: 0.25rem; padding-bottom: calc(0.25rem + env(safe-area-inset-bottom)); padding-left: calc(env(safe-area-inset-left) + var(--document-list-padding-x)); padding-right: calc(env(safe-area-inset-right) + var(--document-list-padding-x)); + justify-content: space-between; } .account-bar__button { @@ -836,13 +838,6 @@ button { border-color: var(--document-list-divider); } -.account-bar__avatar { - padding: 0; - width: 1.75rem; - height: 1.75rem; - flex-shrink: 0; -} - .account-bar__avatar patchwork-view { display: block; width: 100%; From 11469d49ff5292d39cd1233ed030f72fcd5c4de6 Mon Sep 17 00:00:00 2001 From: chee Date: Sun, 28 Jun 2026 13:22:09 +0100 Subject: [PATCH 6/7] retouch the tab selector to the body --- threepane/src/styles.css | 3 --- 1 file changed, 3 deletions(-) diff --git a/threepane/src/styles.css b/threepane/src/styles.css index c12d28e6..7775728e 100644 --- a/threepane/src/styles.css +++ b/threepane/src/styles.css @@ -1,5 +1,3 @@ -/* Layout */ - :root, :host, [theme] { @@ -411,7 +409,6 @@ /* 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; } From d4eabb1e40c9dd2b3d0d102af737f76e811e6083 Mon Sep 17 00:00:00 2001 From: chee Date: Sun, 28 Jun 2026 13:42:09 +0100 Subject: [PATCH 7/7] add vitest configs --- .github/workflows/ci.yml | 30 + account-history/package.json | 8 +- account-history/vitest.config.ts | 14 + account-picker/package.json | 10 +- account-picker/vitest.config.ts | 14 + add-doc-to-sidebar-button/package.json | 8 +- add-doc-to-sidebar-button/vitest.config.ts | 9 + codemirror-base/package.json | 10 +- codemirror-base/vitest.config.ts | 14 + codemirror-markdown/package.json | 8 +- codemirror-markdown/vitest.config.ts | 9 + commands/package.json | 8 +- commands/vitest.config.ts | 9 + comments-view/package.json | 8 +- comments-view/vitest.config.ts | 14 + contact/package.json | 11 +- contact/vitest.config.ts | 11 + doc-openwith/package.json | 10 +- doc-openwith/vitest.config.ts | 9 + doc-presence/package.json | 10 +- doc-presence/vitest.config.ts | 9 + doc-title/package.json | 8 +- doc-title/vitest.config.ts | 9 + drafts/package.json | 8 +- drafts/vitest.config.ts | 14 + file/package.json | 8 +- file/vitest.config.ts | 14 + folder/package.json | 8 +- folder/vitest.config.ts | 9 + frame-configurator/package.json | 8 +- frame-configurator/vitest.config.ts | 14 + history-view/package.json | 8 +- history-view/vitest.config.ts | 14 + module-settings-manager/package.json | 10 +- .../components/ModuleControls.tsx | 136 ++++- .../src/module-settings/utils/module-types.ts | 49 +- module-settings-manager/vitest.config.ts | 14 + package.json | 5 +- pnpm-lock.yaml | 574 +++++++++++++++++- providers/package.json | 8 +- providers/vitest.config.ts | 9 + raw/package.json | 10 +- raw/vitest.config.ts | 9 + scripts/lint-standalone.mjs | 91 +++ sideboard/package.json | 10 +- sideboard/vitest.config.ts | 14 + spacer/package.json | 8 +- spacer/vitest.config.ts | 9 + sync-indicator/package.json | 8 +- sync-indicator/vitest.config.ts | 14 + tasks/package.json | 8 +- tasks/vitest.config.ts | 11 + theming/package.json | 12 +- theming/vitest.config.ts | 14 + threepane/package.json | 8 +- threepane/vitest.config.ts | 14 + tldraw4/package.json | 9 +- tldraw4/vitest.config.ts | 11 + 58 files changed, 1344 insertions(+), 76 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 account-history/vitest.config.ts create mode 100644 account-picker/vitest.config.ts create mode 100644 add-doc-to-sidebar-button/vitest.config.ts create mode 100644 codemirror-base/vitest.config.ts create mode 100644 codemirror-markdown/vitest.config.ts create mode 100644 commands/vitest.config.ts create mode 100644 comments-view/vitest.config.ts create mode 100644 contact/vitest.config.ts create mode 100644 doc-openwith/vitest.config.ts create mode 100644 doc-presence/vitest.config.ts create mode 100644 doc-title/vitest.config.ts create mode 100644 drafts/vitest.config.ts create mode 100644 file/vitest.config.ts create mode 100644 folder/vitest.config.ts create mode 100644 frame-configurator/vitest.config.ts create mode 100644 history-view/vitest.config.ts create mode 100644 module-settings-manager/vitest.config.ts create mode 100644 providers/vitest.config.ts create mode 100644 raw/vitest.config.ts create mode 100644 scripts/lint-standalone.mjs create mode 100644 sideboard/vitest.config.ts create mode 100644 spacer/vitest.config.ts create mode 100644 sync-indicator/vitest.config.ts create mode 100644 tasks/vitest.config.ts create mode 100644 theming/vitest.config.ts create mode 100644 threepane/vitest.config.ts create mode 100644 tldraw4/vitest.config.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..ac1944e7 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,30 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + ci: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "22" + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + # Every top-level folder must stay pullable on its own — no workspace-only + # dependency specifiers (workspace:/catalog:/link:/file:). + - name: Lint standalone packages + run: pnpm lint:standalone + + - name: Run tests + run: pnpm -r test diff --git a/account-history/package.json b/account-history/package.json index 52d5d7c3..e39681de 100644 --- a/account-history/package.json +++ b/account-history/package.json @@ -7,7 +7,9 @@ "scripts": { "build": "vite build", "dev": "vite build --watch", - "register": "pw-modules add \"$MODULE_SETTINGS_DOC_URL\" \"$(pushwork url)\"" + "register": "pw-modules add \"$MODULE_SETTINGS_DOC_URL\" \"$(pushwork url)\"", + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": { "@automerge/automerge-repo-solid-primitives": "2.5.2" @@ -22,10 +24,12 @@ "@inkandswitch/patchwork-filesystem": "^0.0.4", "@inkandswitch/patchwork-plugins": "^0.0.6", "babel-preset-solid": "^1.9.9", + "happy-dom": "^15.11.7", "solid-js": "^1.9.9", "typescript": "^5.9.3", "vite": "^7.1.9", + "vite-plugin-css-injected-by-js": "^3.5.2", "vite-plugin-solid": "^2.11.10", - "vite-plugin-css-injected-by-js": "^3.5.2" + "vitest": "^3.2.4" } } diff --git a/account-history/vitest.config.ts b/account-history/vitest.config.ts new file mode 100644 index 00000000..715c53e5 --- /dev/null +++ b/account-history/vitest.config.ts @@ -0,0 +1,14 @@ +import {defineConfig} from "vitest/config" +import solid from "vite-plugin-solid" + +export default defineConfig({ + plugins: [solid()], + test: { + environment: "happy-dom", + globals: true, + passWithNoTests: true, + }, + resolve: { + conditions: ["development", "browser"], + }, +}) diff --git a/account-picker/package.json b/account-picker/package.json index 2959579a..96e11eae 100644 --- a/account-picker/package.json +++ b/account-picker/package.json @@ -14,7 +14,9 @@ "build": "node esbuild/build.ts", "dev": "node esbuild/watch.ts", "pushwatch": "pnpm dev pushwork", - "register": "pw-modules add \"$MODULE_SETTINGS_DOC_URL\" \"$(pushwork url)\"" + "register": "pw-modules add \"$MODULE_SETTINGS_DOC_URL\" \"$(pushwork url)\"", + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": { "@automerge/automerge": "^3.2.5", @@ -34,6 +36,10 @@ "@babel/parser": "7.28.4", "babel-preset-solid": "^1.9.9", "esbuild": "^0.23.1", - "typescript": "^5.8.3" + "happy-dom": "^15.11.7", + "typescript": "^5.8.3", + "vite": "^7.1.9", + "vite-plugin-solid": "^2.11.10", + "vitest": "^3.2.4" } } diff --git a/account-picker/vitest.config.ts b/account-picker/vitest.config.ts new file mode 100644 index 00000000..715c53e5 --- /dev/null +++ b/account-picker/vitest.config.ts @@ -0,0 +1,14 @@ +import {defineConfig} from "vitest/config" +import solid from "vite-plugin-solid" + +export default defineConfig({ + plugins: [solid()], + test: { + environment: "happy-dom", + globals: true, + passWithNoTests: true, + }, + resolve: { + conditions: ["development", "browser"], + }, +}) diff --git a/add-doc-to-sidebar-button/package.json b/add-doc-to-sidebar-button/package.json index f59bda98..571cd2ae 100644 --- a/add-doc-to-sidebar-button/package.json +++ b/add-doc-to-sidebar-button/package.json @@ -5,12 +5,18 @@ "main": "./index.js", "scripts": { "sync": "pushwork sync", - "register": "pw-modules add \"$MODULE_SETTINGS_DOC_URL\" \"$(pushwork url)\"" + "register": "pw-modules add \"$MODULE_SETTINGS_DOC_URL\" \"$(pushwork url)\"", + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": { "@automerge/automerge": "^3.2.5", "@automerge/automerge-repo": "^2.6.0-subduction.9", "@inkandswitch/patchwork-filesystem": "^0.0.4", "@inkandswitch/patchwork-plugins": "^0.0.6" + }, + "devDependencies": { + "happy-dom": "^15.11.7", + "vitest": "^3.2.4" } } diff --git a/add-doc-to-sidebar-button/vitest.config.ts b/add-doc-to-sidebar-button/vitest.config.ts new file mode 100644 index 00000000..44198156 --- /dev/null +++ b/add-doc-to-sidebar-button/vitest.config.ts @@ -0,0 +1,9 @@ +import {defineConfig} from "vitest/config" + +export default defineConfig({ + test: { + environment: "happy-dom", + globals: true, + passWithNoTests: true, + }, +}) diff --git a/codemirror-base/package.json b/codemirror-base/package.json index c8acf38b..5f340753 100644 --- a/codemirror-base/package.json +++ b/codemirror-base/package.json @@ -22,7 +22,9 @@ "build": "node esbuild/build.ts", "dev": "node esbuild/watch.ts", "pushwatch": "pnpm dev pushwork", - "register": "pw-modules add \"$MODULE_SETTINGS_DOC_URL\" \"$(pushwork url)\"" + "register": "pw-modules add \"$MODULE_SETTINGS_DOC_URL\" \"$(pushwork url)\"", + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": { "@automerge/automerge-codemirror": "^0.2.0", @@ -49,7 +51,11 @@ "esbuild": "^0.23.1", "eslint": "^9.36.0", "globals": "^16.4.0", + "happy-dom": "^15.11.7", "typescript": "~5.9.3", - "typescript-eslint": "^8.45.0" + "typescript-eslint": "^8.45.0", + "vite": "^7.1.9", + "vite-plugin-solid": "^2.11.10", + "vitest": "^3.2.4" } } diff --git a/codemirror-base/vitest.config.ts b/codemirror-base/vitest.config.ts new file mode 100644 index 00000000..715c53e5 --- /dev/null +++ b/codemirror-base/vitest.config.ts @@ -0,0 +1,14 @@ +import {defineConfig} from "vitest/config" +import solid from "vite-plugin-solid" + +export default defineConfig({ + plugins: [solid()], + test: { + environment: "happy-dom", + globals: true, + passWithNoTests: true, + }, + resolve: { + conditions: ["development", "browser"], + }, +}) diff --git a/codemirror-markdown/package.json b/codemirror-markdown/package.json index 564c75a2..7d9e1312 100644 --- a/codemirror-markdown/package.json +++ b/codemirror-markdown/package.json @@ -30,7 +30,9 @@ "build": "node esbuild/build.ts", "dev": "node esbuild/watch.ts", "pushwatch": "pnpm dev pushwork", - "register": "pw-modules add \"$MODULE_SETTINGS_DOC_URL\" \"$(pushwork url)\"" + "register": "pw-modules add \"$MODULE_SETTINGS_DOC_URL\" \"$(pushwork url)\"", + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": { "@automerge/automerge": "^3.2.5", @@ -56,7 +58,9 @@ "esbuild": "^0.23.1", "eslint": "^9.36.0", "globals": "^16.4.0", + "happy-dom": "^15.11.7", "typescript": "~5.9.3", - "typescript-eslint": "^8.45.0" + "typescript-eslint": "^8.45.0", + "vitest": "^3.2.4" } } diff --git a/codemirror-markdown/vitest.config.ts b/codemirror-markdown/vitest.config.ts new file mode 100644 index 00000000..44198156 --- /dev/null +++ b/codemirror-markdown/vitest.config.ts @@ -0,0 +1,9 @@ +import {defineConfig} from "vitest/config" + +export default defineConfig({ + test: { + environment: "happy-dom", + globals: true, + passWithNoTests: true, + }, +}) diff --git a/commands/package.json b/commands/package.json index 5eda98db..a16fafb5 100644 --- a/commands/package.json +++ b/commands/package.json @@ -14,7 +14,9 @@ "build": "node esbuild/build.ts", "dev": "node esbuild/watch.ts", "pushwatch": "pnpm dev pushwork", - "register": "pw-modules add \"$MODULE_SETTINGS_DOC_URL\" \"$(pushwork url)\"" + "register": "pw-modules add \"$MODULE_SETTINGS_DOC_URL\" \"$(pushwork url)\"", + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": { "@inkandswitch/patchwork-bootloader": "^0.0.4", @@ -23,6 +25,8 @@ }, "devDependencies": { "@automerge/automerge-repo": "^2.6.0-subduction.9", - "esbuild": "^0.23.1" + "esbuild": "^0.23.1", + "happy-dom": "^15.11.7", + "vitest": "^3.2.4" } } diff --git a/commands/vitest.config.ts b/commands/vitest.config.ts new file mode 100644 index 00000000..44198156 --- /dev/null +++ b/commands/vitest.config.ts @@ -0,0 +1,9 @@ +import {defineConfig} from "vitest/config" + +export default defineConfig({ + test: { + environment: "happy-dom", + globals: true, + passWithNoTests: true, + }, +}) diff --git a/comments-view/package.json b/comments-view/package.json index 06b7ead7..c10baaf0 100644 --- a/comments-view/package.json +++ b/comments-view/package.json @@ -8,7 +8,9 @@ "dev": "vite build --watch", "build": "vite build", "sync": "vite build && pushwork sync", - "register": "pw-modules add \"$MODULE_SETTINGS_DOC_URL\" \"$(pushwork url)\"" + "register": "pw-modules add \"$MODULE_SETTINGS_DOC_URL\" \"$(pushwork url)\"", + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": { "@automerge/automerge": "3.3.0-fragments.1", @@ -27,9 +29,11 @@ "@typescript-eslint/parser": "^8.40.0", "eslint": "^9.34.0", "globals": "^16.4.0", + "happy-dom": "^15.11.7", "typescript-eslint": "^8.41.0", "vite": "^7.1.9", "vite-plugin-css-injected-by-js": "^3.5.2", - "vite-plugin-solid": "^2.11.10" + "vite-plugin-solid": "^2.11.10", + "vitest": "^3.2.4" } } diff --git a/comments-view/vitest.config.ts b/comments-view/vitest.config.ts new file mode 100644 index 00000000..715c53e5 --- /dev/null +++ b/comments-view/vitest.config.ts @@ -0,0 +1,14 @@ +import {defineConfig} from "vitest/config" +import solid from "vite-plugin-solid" + +export default defineConfig({ + plugins: [solid()], + test: { + environment: "happy-dom", + globals: true, + passWithNoTests: true, + }, + resolve: { + conditions: ["development", "browser"], + }, +}) diff --git a/contact/package.json b/contact/package.json index 4a12948b..237d709e 100644 --- a/contact/package.json +++ b/contact/package.json @@ -16,7 +16,9 @@ "build": "node esbuild/build.ts", "dev": "node esbuild/watch.ts", "pushwatch": "pnpm dev pushwork", - "register": "pw-modules add \"$MODULE_SETTINGS_DOC_URL\" \"$(pushwork url)\"" + "register": "pw-modules add \"$MODULE_SETTINGS_DOC_URL\" \"$(pushwork url)\"", + "test": "vitest run", + "test:watch": "vitest" }, "keywords": [], "author": "Ink & Switch", @@ -29,7 +31,7 @@ "@inkandswitch/patchwork-providers": "^0.2.1", "@inkandswitch/patchwork-providers-react": "^0.2.1", "@radix-ui/react-avatar": "^1.1.10", -"lucide-react": "^0.469.0", + "lucide-react": "^0.469.0", "react": "^18.0.0", "react-dom": "^18.0.0" }, @@ -41,6 +43,9 @@ "@vitejs/plugin-react": "^4.5.1", "esbuild": "^0.23.1", "eslint-plugin-react-hooks": "^4.6.2", - "typescript": "^5.8.3" + "happy-dom": "^15.11.7", + "typescript": "^5.8.3", + "vite": "^7.1.9", + "vitest": "^3.2.4" } } diff --git a/contact/vitest.config.ts b/contact/vitest.config.ts new file mode 100644 index 00000000..1dd6c6a6 --- /dev/null +++ b/contact/vitest.config.ts @@ -0,0 +1,11 @@ +import {defineConfig} from "vitest/config" +import react from "@vitejs/plugin-react" + +export default defineConfig({ + plugins: [react()], + test: { + environment: "happy-dom", + globals: true, + passWithNoTests: true, + }, +}) diff --git a/doc-openwith/package.json b/doc-openwith/package.json index 9633edb6..4671ba8f 100644 --- a/doc-openwith/package.json +++ b/doc-openwith/package.json @@ -1,5 +1,13 @@ { "name": "document open with", "type": "module", - "main": "doc-openwith.js" + "main": "doc-openwith.js", + "devDependencies": { + "happy-dom": "^15.11.7", + "vitest": "^3.2.4" + }, + "scripts": { + "test": "vitest run", + "test:watch": "vitest" + } } diff --git a/doc-openwith/vitest.config.ts b/doc-openwith/vitest.config.ts new file mode 100644 index 00000000..44198156 --- /dev/null +++ b/doc-openwith/vitest.config.ts @@ -0,0 +1,9 @@ +import {defineConfig} from "vitest/config" + +export default defineConfig({ + test: { + environment: "happy-dom", + globals: true, + passWithNoTests: true, + }, +}) diff --git a/doc-presence/package.json b/doc-presence/package.json index 81e9e3c8..09ed84ec 100644 --- a/doc-presence/package.json +++ b/doc-presence/package.json @@ -1,5 +1,13 @@ { "name": "document presence", "type": "module", - "main": "doc-presence.js" + "main": "doc-presence.js", + "devDependencies": { + "happy-dom": "^15.11.7", + "vitest": "^3.2.4" + }, + "scripts": { + "test": "vitest run", + "test:watch": "vitest" + } } diff --git a/doc-presence/vitest.config.ts b/doc-presence/vitest.config.ts new file mode 100644 index 00000000..44198156 --- /dev/null +++ b/doc-presence/vitest.config.ts @@ -0,0 +1,9 @@ +import {defineConfig} from "vitest/config" + +export default defineConfig({ + test: { + environment: "happy-dom", + globals: true, + passWithNoTests: true, + }, +}) diff --git a/doc-title/package.json b/doc-title/package.json index 09464d8c..9a1a93b3 100644 --- a/doc-title/package.json +++ b/doc-title/package.json @@ -7,7 +7,9 @@ "dev": "vite build --watch", "build": "vite build", "sync": "vite build && pushwork sync", - "register": "pw-modules add \"$MODULE_SETTINGS_DOC_URL\" \"$(pushwork url)\"" + "register": "pw-modules add \"$MODULE_SETTINGS_DOC_URL\" \"$(pushwork url)\"", + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": { "@automerge/automerge": "^3.2.5", @@ -23,8 +25,10 @@ "@typescript-eslint/parser": "^8.40.0", "eslint": "^9.34.0", "globals": "^16.4.0", + "happy-dom": "^15.11.7", "typescript-eslint": "^8.41.0", "vite": "^7.1.9", - "vite-plugin-css-injected-by-js": "^3.5.2" + "vite-plugin-css-injected-by-js": "^3.5.2", + "vitest": "^3.2.4" } } diff --git a/doc-title/vitest.config.ts b/doc-title/vitest.config.ts new file mode 100644 index 00000000..44198156 --- /dev/null +++ b/doc-title/vitest.config.ts @@ -0,0 +1,9 @@ +import {defineConfig} from "vitest/config" + +export default defineConfig({ + test: { + environment: "happy-dom", + globals: true, + passWithNoTests: true, + }, +}) diff --git a/drafts/package.json b/drafts/package.json index e392c66d..6377b99b 100644 --- a/drafts/package.json +++ b/drafts/package.json @@ -8,7 +8,9 @@ "dev": "vite build --watch", "build": "vite build", "sync": "vite build && pushwork sync", - "register": "pw-modules add \"$MODULE_SETTINGS_DOC_URL\" \"$(pushwork url)\"" + "register": "pw-modules add \"$MODULE_SETTINGS_DOC_URL\" \"$(pushwork url)\"", + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": { "@automerge/automerge": "3.3.0-fragments.1", @@ -26,9 +28,11 @@ "@typescript-eslint/parser": "^8.40.0", "eslint": "^9.34.0", "globals": "^16.4.0", + "happy-dom": "^15.11.7", "typescript-eslint": "^8.41.0", "vite": "^7.1.9", "vite-plugin-css-injected-by-js": "^3.5.2", - "vite-plugin-solid": "^2.11.10" + "vite-plugin-solid": "^2.11.10", + "vitest": "^3.2.4" } } diff --git a/drafts/vitest.config.ts b/drafts/vitest.config.ts new file mode 100644 index 00000000..715c53e5 --- /dev/null +++ b/drafts/vitest.config.ts @@ -0,0 +1,14 @@ +import {defineConfig} from "vitest/config" +import solid from "vite-plugin-solid" + +export default defineConfig({ + plugins: [solid()], + test: { + environment: "happy-dom", + globals: true, + passWithNoTests: true, + }, + resolve: { + conditions: ["development", "browser"], + }, +}) diff --git a/file/package.json b/file/package.json index af47917e..895fd3a5 100644 --- a/file/package.json +++ b/file/package.json @@ -18,7 +18,9 @@ "build": "vite build", "dev": "vite build --watch", "push": "pnpm build && pushwork sync", - "register": "pw-modules add \"$MODULE_SETTINGS_DOC_URL\" \"$(pushwork url)\"" + "register": "pw-modules add \"$MODULE_SETTINGS_DOC_URL\" \"$(pushwork url)\"", + "test": "vitest run", + "test:watch": "vitest" }, "keywords": [], "author": "Ink & Switch", @@ -54,8 +56,10 @@ }, "devDependencies": { "@inkandswitch/patchwork-bootloader": "^0.0.4", + "happy-dom": "^15.11.7", "typescript": "^5.8.3", "vite": "^5.4.19", - "vite-plugin-solid": "^2.10.0" + "vite-plugin-solid": "^2.10.0", + "vitest": "^3.2.4" } } diff --git a/file/vitest.config.ts b/file/vitest.config.ts new file mode 100644 index 00000000..715c53e5 --- /dev/null +++ b/file/vitest.config.ts @@ -0,0 +1,14 @@ +import {defineConfig} from "vitest/config" +import solid from "vite-plugin-solid" + +export default defineConfig({ + plugins: [solid()], + test: { + environment: "happy-dom", + globals: true, + passWithNoTests: true, + }, + resolve: { + conditions: ["development", "browser"], + }, +}) diff --git a/folder/package.json b/folder/package.json index a6fee2c7..9ffab513 100644 --- a/folder/package.json +++ b/folder/package.json @@ -15,7 +15,9 @@ "dev": "node esbuild/watch.ts", "pushwatch": "pnpm dev pushwork", "sync": "pnpm build && npx pushwork sync", - "register": "pw-modules add \"$MODULE_SETTINGS_DOC_URL\" \"$(pushwork url)\"" + "register": "pw-modules add \"$MODULE_SETTINGS_DOC_URL\" \"$(pushwork url)\"", + "test": "vitest run", + "test:watch": "vitest" }, "keywords": [], "author": "Ink & Switch", @@ -27,6 +29,8 @@ }, "devDependencies": { "@automerge/automerge-repo": "^2.6.0-subduction.9", - "esbuild": "^0.23.1" + "esbuild": "^0.23.1", + "happy-dom": "^15.11.7", + "vitest": "^3.2.4" } } diff --git a/folder/vitest.config.ts b/folder/vitest.config.ts new file mode 100644 index 00000000..44198156 --- /dev/null +++ b/folder/vitest.config.ts @@ -0,0 +1,9 @@ +import {defineConfig} from "vitest/config" + +export default defineConfig({ + test: { + environment: "happy-dom", + globals: true, + passWithNoTests: true, + }, +}) diff --git a/frame-configurator/package.json b/frame-configurator/package.json index a91589f0..1676d49a 100644 --- a/frame-configurator/package.json +++ b/frame-configurator/package.json @@ -8,7 +8,9 @@ "watch": "vite --port 4444", "build": "vite build", "sync": "vite build && pushwork sync", - "register": "pw-modules add \"$MODULE_SETTINGS_DOC_URL\" \"$(pushwork url)\"" + "register": "pw-modules add \"$MODULE_SETTINGS_DOC_URL\" \"$(pushwork url)\"", + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": { "@automerge/automerge": "^3.2.5", @@ -26,9 +28,11 @@ "babel-preset-solid": "^1.9.9", "eslint": "^9.34.0", "globals": "^16.4.0", + "happy-dom": "^15.11.7", "typescript-eslint": "^8.41.0", "vite": "^7.1.9", "vite-plugin-css-injected-by-js": "^3.5.2", - "vite-plugin-solid": "^2.11.10" + "vite-plugin-solid": "^2.11.10", + "vitest": "^3.2.4" } } diff --git a/frame-configurator/vitest.config.ts b/frame-configurator/vitest.config.ts new file mode 100644 index 00000000..715c53e5 --- /dev/null +++ b/frame-configurator/vitest.config.ts @@ -0,0 +1,14 @@ +import {defineConfig} from "vitest/config" +import solid from "vite-plugin-solid" + +export default defineConfig({ + plugins: [solid()], + test: { + environment: "happy-dom", + globals: true, + passWithNoTests: true, + }, + resolve: { + conditions: ["development", "browser"], + }, +}) diff --git a/history-view/package.json b/history-view/package.json index 5a1f8728..8ed682a7 100644 --- a/history-view/package.json +++ b/history-view/package.json @@ -7,7 +7,9 @@ "dev": "vite build --watch", "build": "vite build", "sync": "vite build && pushwork sync", - "register": "pw-modules add \"$MODULE_SETTINGS_DOC_URL\" \"$(pushwork url)\"" + "register": "pw-modules add \"$MODULE_SETTINGS_DOC_URL\" \"$(pushwork url)\"", + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": { "@automerge/automerge": "3.3.0-fragments.1", @@ -27,10 +29,12 @@ "@typescript-eslint/parser": "^8.40.0", "eslint": "^9.34.0", "globals": "^16.4.0", + "happy-dom": "^15.11.7", "solid-js": "^1.9.10", "typescript-eslint": "^8.41.0", "vite": "^7.1.9", "vite-plugin-css-injected-by-js": "^3.5.2", - "vite-plugin-solid": "^2.11.10" + "vite-plugin-solid": "^2.11.10", + "vitest": "^3.2.4" } } diff --git a/history-view/vitest.config.ts b/history-view/vitest.config.ts new file mode 100644 index 00000000..715c53e5 --- /dev/null +++ b/history-view/vitest.config.ts @@ -0,0 +1,14 @@ +import {defineConfig} from "vitest/config" +import solid from "vite-plugin-solid" + +export default defineConfig({ + plugins: [solid()], + test: { + environment: "happy-dom", + globals: true, + passWithNoTests: true, + }, + resolve: { + conditions: ["development", "browser"], + }, +}) diff --git a/module-settings-manager/package.json b/module-settings-manager/package.json index f11098d0..88934d24 100644 --- a/module-settings-manager/package.json +++ b/module-settings-manager/package.json @@ -23,7 +23,9 @@ "build": "node esbuild/build.ts", "dev": "node esbuild/watch.ts", "pushwatch": "pnpm dev pushwork", - "register": "pw-modules add \"$MODULE_SETTINGS_DOC_URL\" \"$(pushwork url)\"" + "register": "pw-modules add \"$MODULE_SETTINGS_DOC_URL\" \"$(pushwork url)\"", + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": { "@inkandswitch/patchwork-bootloader": "^0.0.8", @@ -39,7 +41,11 @@ "@inkandswitch/patchwork-plugins": "^0.0.6", "babel-preset-solid": "^1.9.9", "esbuild": "^0.23.1", + "happy-dom": "^15.11.7", "solid-js": "^1.9.10", - "typescript": "~5.9.3" + "typescript": "~5.9.3", + "vite": "^7.1.9", + "vite-plugin-solid": "^2.11.10", + "vitest": "^3.2.4" } } diff --git a/module-settings-manager/src/module-settings/components/ModuleControls.tsx b/module-settings-manager/src/module-settings/components/ModuleControls.tsx index 86ae46d2..cb6bd175 100644 --- a/module-settings-manager/src/module-settings/components/ModuleControls.tsx +++ b/module-settings-manager/src/module-settings/components/ModuleControls.tsx @@ -17,9 +17,12 @@ import { import { DEFAULT_BRANCH, chosenBranchFor, + chosenPinFor, getModuleEntryKind, + publishVersions, type BranchesDoc, type ModuleSettingsDocWithBranches, + type UrlHeads, } from "../utils/module-types.ts"; import { unregisterPlugins } from "@inkandswitch/patchwork-plugins"; import { @@ -75,6 +78,12 @@ export function ModuleControls(props: ModuleControlsProps) { />
+ ; + userSettingsHandle?: DocHandle; +} + +/** + * Pin a folder module to a frozen previous version. Pins are written into the + * user's own settings doc (like branch choices) under `pinned`, keyed by the + * folder URL. The watcher then loads that exact, already-cached version instead + * of the latest, and stops hot-reloading it until unpinned. + */ +function VersionControls(props: VersionControlsProps) { + // Write pins to the user's own settings doc so they're user-local. + const targetHandle = () => props.userSettingsHandle ?? props.settingsHandle; + const targetDoc = createMemo(() => makeDocumentProjection(targetHandle())); + + const [folderHandle] = createResource( + () => props.folderUrl, + (url) => props.repo.find<{ lastSyncAt?: number }>(url) + ); + + const versions = createMemo(() => { + const handle = folderHandle(); + return handle ? publishVersions(handle) : []; + }); + + const currentPin = createMemo(() => + chosenPinFor([targetDoc()], props.folderUrl) + ); + + const isPinned = createMemo(() => !!currentPin()); + + const sameHeads = (a: UrlHeads | undefined, b: UrlHeads | undefined) => + !!a && !!b && a.length === b.length && a.every((h, i) => h === b[i]); + + // The