From 7bd42fa546e663f29ee6e5f79e0443059d4f8317 Mon Sep 17 00:00:00 2001 From: wusumac <736139669@qq.com> Date: Wed, 12 Aug 2026 10:52:04 +0800 Subject: [PATCH] docs: add interactive Plainleaf workspace demo --- docs/design-v1/app.js | 548 +++++++++ docs/design-v1/index.html | 258 ++++ docs/design-v1/styles.css | 1470 +++++++++++++++++++++++ docs/design-v1/verify-demo.mjs | 85 ++ docs/product-design/decisions-v1.md | 56 + docs/product-design/design-review-v1.md | 49 + docs/product-design/design-spec-v1.md | 197 +++ 7 files changed, 2663 insertions(+) create mode 100644 docs/design-v1/app.js create mode 100644 docs/design-v1/index.html create mode 100644 docs/design-v1/styles.css create mode 100644 docs/design-v1/verify-demo.mjs create mode 100644 docs/product-design/decisions-v1.md create mode 100644 docs/product-design/design-review-v1.md create mode 100644 docs/product-design/design-spec-v1.md diff --git a/docs/design-v1/app.js b/docs/design-v1/app.js new file mode 100644 index 00000000..99f270c2 --- /dev/null +++ b/docs/design-v1/app.js @@ -0,0 +1,548 @@ +const notes = [ + { + id: "plainleaf-plan", + title: "Plainleaf 改造计划", + path: "项目/Plainleaf/Plainleaf 改造计划", + updated: "今天 10:42", + favorite: true, + tags: ["产品", "设计", "SilverBullet"], + content: ` +

Plainleaf 会保留 SilverBullet 最重要的基础:每一篇笔记仍然是 NAS 中可以直接打开的 Markdown 文件。

+
目标不是复制 Obsidian,而是让文件、搜索和上下文工具变得可见,同时保留 SilverBullet 的查询与自动化能力。
+

第一阶段目标

+ +

数据原则

+

界面只是工作层,文件才是长期资产。即使以后停止使用 Plainleaf,也可以通过 Obsidian、VS Code 或普通文本编辑器继续使用全部内容。

+

下一步

+

交互 Demo 确认后,将工作区外壳接到 SilverBullet 的 [[页面索引]] 与现有导航命令上。

+ `, + outline: ["第一阶段目标", "数据原则", "下一步"], + backlinks: [ + ["产品路线图", "将 Plainleaf 改造计划作为第一阶段的体验基线。"], + ["极空间部署", "新版上线时继续挂载同一个 Markdown Space。"], + ["笔记工具对比", "最终选择基于 SilverBullet 构建 Plainleaf。"] + ] + }, + { + id: "zspace", + title: "极空间部署", + path: "知识库/Docker/极空间部署", + updated: "昨天 22:16", + favorite: true, + tags: ["Docker", "NAS", "部署"], + content: ` +

极空间负责运行正式环境,Mac 用于开发和构建镜像。笔记目录通过卷挂载到容器的 /space。

+

部署结构

+ +

更新流程

+

在本地完成构建和验证后,推送自定义镜像,再由极空间 Compose 切换版本。

+ `, + outline: ["部署结构", "更新流程"], + backlinks: [["Plainleaf 改造计划", "正式版继续使用极空间现有的 Markdown 目录。"]] + }, + { + id: "daily", + title: "2026-08-12", + path: "日记/2026/08/2026-08-12", + updated: "今天 08:30", + favorite: false, + tags: ["日记"], + content: ` +

今天继续整理个人笔记系统,希望数据保持开放,同时让 Web 端变得更易用。

+

今日事项

+ +

记录

+

文件树应该承担定位,而不是让用户记住所有命令。高级能力可以继续通过命令面板进入。

+ `, + outline: ["今日事项", "记录"], + backlinks: [] + }, + { + id: "docker-network", + title: "Docker 容器网络", + path: "知识库/Docker/Docker 容器网络", + updated: "8 月 10 日", + favorite: false, + tags: ["Docker", "网络"], + content: ` +

同一个 Compose 项目中的服务应通过服务名通信,而不是依赖宿主机端口或孤立容器名称。

+

服务发现

+

容器加入同一网络后,可以使用 service:port 作为内部地址。

+

排查顺序

+ + `, + outline: ["服务发现", "排查顺序"], + backlinks: [["极空间部署", "部署时先确认容器网络和服务发现。"]] + }, + { + id: "tools", + title: "笔记工具对比", + path: "收件箱/笔记工具对比", + updated: "8 月 11 日", + favorite: false, + tags: ["调研", "笔记"], + content: ` +

对比了 Blossom、Blinko、Notesnook、SilverBullet 和 Flatnotes。

+

核心结论

+

数据库优先的产品通常拥有更现代的成品体验,但文件原生模型更符合长期迁移与多工具使用的要求。

+

选择

+

以 SilverBullet 为文件与能力内核,在其上构建 Plainleaf 的现代工作区。

+ `, + outline: ["核心结论", "选择"], + backlinks: [["Plainleaf 改造计划", "选型结论推动了 Plainleaf 项目。"]] + }, + { + id: "ideas", + title: "产品灵感收件箱", + path: "收件箱/产品灵感收件箱", + updated: "8 月 9 日", + favorite: false, + tags: ["灵感"], + content: ` +

临时想法先进入收件箱,稍后再归档到项目、知识库或日记。

+

待整理

+ + `, + outline: ["待整理"], + backlinks: [] + } +]; + +const tree = [ + { type: "folder", name: "收件箱", children: ["tools", "ideas"] }, + { + type: "folder", + name: "项目", + children: [ + { type: "folder", name: "Plainleaf", children: ["plainleaf-plan"] }, + { type: "folder", name: "网站改版", children: [] } + ] + }, + { + type: "folder", + name: "知识库", + children: [ + { type: "folder", name: "Docker", children: ["zspace", "docker-network"] }, + { type: "folder", name: "AI", children: [] } + ] + }, + { + type: "folder", + name: "日记", + children: [{ type: "folder", name: "2026", children: [{ type: "folder", name: "08", children: ["daily"] }] }] + } +]; + +const state = { + currentNoteId: "plainleaf-plan", + tool: "files", + context: "outline", + mode: "edit", + theme: "light", + offline: false, + searchIndex: 0, + expandedFolders: new Set(["收件箱", "项目", "项目/Plainleaf", "知识库", "知识库/Docker", "日记", "日记/2026", "日记/2026/08"]) +}; + +const byId = (id) => document.getElementById(id); +const currentNote = () => notes.find((note) => note.id === state.currentNoteId); + +function icon(name, className = "") { + return ``; +} + +function refreshIcons() { + if (window.lucide) window.lucide.createIcons(); +} + +function renderTreeNode(node, depth = 0, parentPath = "") { + if (typeof node === "string") { + const note = notes.find((item) => item.id === node); + return ` +
+ ${icon("chevron-right", "disclosure is-placeholder")} + ${icon("file-text")} + ${note.title} +
`; + } + + const path = parentPath ? `${parentPath}/${node.name}` : node.name; + const expanded = state.expandedFolders.has(path); + const children = node.children.map((child) => renderTreeNode(child, depth + 1, path)).join(""); + return ` +
+
+ ${icon(expanded ? "chevron-down" : "chevron-right", "disclosure")} + ${icon(expanded ? "folder-open" : "folder")} + ${node.name} + ${node.children.length ? `${node.children.length}` : ""} +
+
${children}
+
`; +} + +function filesViewMarkup() { + const favoriteRows = notes.filter((note) => note.favorite).map((note) => ` + `).join(""); + const recentRows = notes.slice(0, 3).map((note) => ` + `).join(""); + return ` + + + `; +} + +function searchViewMarkup() { + return ` + + `; +} + +function tagsViewMarkup() { + const tags = [["Docker", 3], ["产品", 2], ["设计", 2], ["NAS", 2], ["日记", 1], ["灵感", 1], ["部署", 1]]; + return ``; +} + +function tasksViewMarkup() { + const tasks = [["完成交互 Demo", false, "今天"], ["确认文件树布局", true, "今天"], ["整理开发任务", false, "明天"], ["构建极空间镜像", false, "稍后"]]; + return ``; +} + +function graphViewMarkup() { + return `
${icon("network")}

关系图保留为高级视图

正式实现将复用 SilverBullet 现有对象索引和关系图能力。

`; +} + +function bindNavigationRows(root = byId("navigationContent"), mobileSheet = false) { + root.querySelectorAll("[data-note-id]").forEach((row) => row.addEventListener("click", () => openNote(row.dataset.noteId))); + root.querySelectorAll("[data-folder-path]").forEach((row) => row.addEventListener("click", () => { + const path = row.dataset.folderPath; + if (state.expandedFolders.has(path)) state.expandedFolders.delete(path); + else state.expandedFolders.add(path); + if (mobileSheet) renderMobileSheet("files"); + else renderNavigation(); + })); + root.querySelectorAll("[data-open-search]").forEach((row) => row.addEventListener("click", () => openSearch(row.dataset.openSearch))); + root.querySelector("#inlineNewNote")?.addEventListener("click", openNewNote); +} + +function renderNavigation() { + const titles = { files: "文件", search: "搜索", tags: "标签", tasks: "任务", graph: "关系图" }; + byId("paneTitle").textContent = titles[state.tool]; + const views = { files: filesViewMarkup, search: searchViewMarkup, tags: tagsViewMarkup, tasks: tasksViewMarkup, graph: graphViewMarkup }; + byId("navigationContent").innerHTML = views[state.tool](); + bindNavigationRows(); + refreshIcons(); +} + +function renderBreadcrumb(note) { + const segments = note.path.split("/"); + byId("breadcrumb").innerHTML = segments.map((segment, index) => `${index ? icon("chevron-right") : ""}${segment}`).join(""); +} + +function renderContext() { + const note = currentNote(); + const heading = { outline: "大纲", backlinks: "反向链接", properties: "属性", info: "页面信息" }[state.context]; + let body = ""; + if (state.context === "outline") { + body = `
${note.outline.length ? note.outline.map((item, index) => ``).join("") : `
${icon("list-tree")}

没有大纲

添加二级或三级标题后会显示在这里。

`}
`; + } else if (state.context === "backlinks") { + body = ``; + } else if (state.context === "properties") { + body = `
tags
${note.tags.map((tag) => `${tag}`).join("")}
status
active
created
2026-08-12
aliases
暂无
`; + } else { + body = `
文件
${note.path}.md
修改
${note.updated}
编码
UTF-8
大小
3.4 KB
权限
读写
`; + } + byId("contextContent").innerHTML = `

${heading}

${body}`; + refreshIcons(); +} + +function renderPreview(note) { + byId("documentPreview").innerHTML = `

${note.title}

${note.content}`; +} + +function openNote(id) { + const note = notes.find((item) => item.id === id); + if (!note) return; + state.currentNoteId = id; + byId("pageTitleInput").value = note.title; + byId("editorContent").innerHTML = note.content; + byId("updatedTime").textContent = `${note.updated} 更新`; + byId("pageLocation").textContent = note.path.split("/").slice(0, -1).join(" / ") || "根目录"; + renderBreadcrumb(note); + renderPreview(note); + renderContext(); + renderNavigation(); + closeAllOverlays(); + byId("mobileSheetOverlay").classList.remove("is-visible"); + refreshIcons(); +} + +function setMode(mode) { + state.mode = mode; + byId("documentStage").dataset.mode = mode; + document.querySelectorAll(".segment").forEach((button) => button.classList.toggle("is-active", button.dataset.mode === mode)); +} + +function setTheme(theme) { + state.theme = theme; + document.documentElement.dataset.theme = theme; + const iconName = theme === "dark" ? "sun" : "moon"; + byId("themeButton").innerHTML = icon(iconName); + refreshIcons(); +} + +function toggleTheme() { + setTheme(state.theme === "light" ? "dark" : "light"); +} + +function setOffline(offline) { + state.offline = offline; + byId("offlineBanner").classList.toggle("is-visible", offline); + byId("connectionLabel").textContent = offline ? "离线" : "已连接"; + byId("connectionButton").innerHTML = `${icon(offline ? "cloud-off" : "cloud-check")}${offline ? "离线" : "已连接"}`; + byId("navStatusDot").classList.toggle("is-online", !offline); + byId("navStatusLabel").textContent = offline ? "等待连接后同步" : "本地文件已同步"; + byId("menuOfflineButton").querySelector("span").textContent = offline ? "恢复在线" : "模拟离线"; + refreshIcons(); +} + +let saveTimer; +function markChanged() { + clearTimeout(saveTimer); + byId("saveState").className = "save-state is-saving"; + byId("saveState").innerHTML = `${icon("loader-circle")}保存中`; + refreshIcons(); + saveTimer = setTimeout(() => { + byId("saveState").className = "save-state is-saved"; + byId("saveState").innerHTML = `${icon("check")}已保存`; + refreshIcons(); + }, 900); +} + +function filteredNotes(query) { + const normalized = query.trim().toLowerCase(); + if (!normalized) return notes.slice(0, 5); + return notes.filter((note) => `${note.title} ${note.path} ${note.tags.join(" ")} ${note.content.replace(/<[^>]+>/g, " ")}`.toLowerCase().includes(normalized)); +} + +function renderSearchResults(query = "") { + const results = filteredNotes(query); + state.searchIndex = Math.min(state.searchIndex, Math.max(results.length - 1, 0)); + byId("searchResults").innerHTML = results.length ? results.map((note, index) => ` +
+
${icon("file-text")}
+
${note.title}
${note.path}.md
+ ${note.updated} +
`).join("") : `
${icon("search-x")}

没有找到笔记

保留当前搜索词,可以直接创建同名 Markdown 文件。

`; + byId("searchResults").querySelectorAll("[data-search-note]").forEach((row) => row.addEventListener("click", () => openNote(row.dataset.searchNote))); + refreshIcons(); +} + +function openSearch(query = "") { + state.searchIndex = 0; + byId("searchOverlay").classList.add("is-visible"); + byId("searchOverlay").setAttribute("aria-hidden", "false"); + byId("globalSearchInput").value = query; + renderSearchResults(query); + requestAnimationFrame(() => byId("globalSearchInput").focus()); +} + +function openNewNote() { + byId("newNoteOverlay").classList.add("is-visible"); + byId("newNoteOverlay").setAttribute("aria-hidden", "false"); + byId("newNoteName").value = ""; + requestAnimationFrame(() => byId("newNoteName").focus()); +} + +function closeAllOverlays() { + document.querySelectorAll(".overlay").forEach((overlay) => { + overlay.classList.remove("is-visible"); + overlay.setAttribute("aria-hidden", "true"); + }); +} + +function createNote() { + const title = byId("newNoteName").value.trim() || "未命名笔记"; + const folder = byId("newNoteFolder").value; + const id = `new-${Date.now()}`; + const note = { + id, + title, + path: `${folder}/${title}`, + updated: "刚刚", + favorite: false, + tags: [], + content: `

开始记录...

`, + outline: [], + backlinks: [] + }; + notes.unshift(note); + const targetFolder = findFolder(tree, folder.split("/")); + if (targetFolder) targetFolder.children.unshift(id); + state.expandedFolders.add(folder); + openNote(id); + requestAnimationFrame(() => byId("editorContent").focus()); +} + +function findFolder(nodes, parts) { + const [head, ...rest] = parts; + const found = nodes.find((node) => typeof node !== "string" && node.name === head); + if (!found) return null; + if (!rest.length) return found; + return findFolder(found.children.filter((child) => typeof child !== "string"), rest); +} + +function renderMobileSheet(kind) { + const title = kind === "links" ? "反向链接" : "文件"; + byId("mobileSheetTitle").textContent = title; + if (kind === "links") { + const note = currentNote(); + byId("mobileSheetContent").innerHTML = note.backlinks.length ? `` : `
${icon("links")}

还没有反向链接

其他页面引用当前笔记后会显示在这里。

`; + } else { + byId("mobileSheetContent").innerHTML = `
${tree.map((node) => renderTreeNode(node)).join("")}
`; + bindNavigationRows(byId("mobileSheetContent"), true); + } + byId("mobileSheetOverlay").classList.add("is-visible"); + byId("mobileSheetOverlay").setAttribute("aria-hidden", "false"); + refreshIcons(); +} + +function setupResizer(resizer, side) { + resizer.addEventListener("pointerdown", (event) => { + event.preventDefault(); + resizer.setPointerCapture(event.pointerId); + resizer.classList.add("is-dragging"); + const onMove = (moveEvent) => { + const value = side === "left" ? moveEvent.clientX - 48 : window.innerWidth - moveEvent.clientX; + document.documentElement.style.setProperty(side === "left" ? "--nav-width" : "--context-width", `${Math.max(side === "left" ? 220 : 240, Math.min(side === "left" ? 380 : 380, value))}px`); + }; + const onUp = () => { + resizer.classList.remove("is-dragging"); + resizer.removeEventListener("pointermove", onMove); + resizer.removeEventListener("pointerup", onUp); + }; + resizer.addEventListener("pointermove", onMove); + resizer.addEventListener("pointerup", onUp); + }); +} + +function initEvents() { + document.querySelectorAll("[data-tool]").forEach((button) => button.addEventListener("click", () => { + state.tool = button.dataset.tool; + document.querySelectorAll("[data-tool]").forEach((item) => item.classList.toggle("is-active", item === button)); + renderNavigation(); + })); + document.querySelectorAll("[data-mode]").forEach((button) => button.addEventListener("click", () => setMode(button.dataset.mode))); + document.querySelectorAll("[data-context]").forEach((button) => button.addEventListener("click", () => { + state.context = button.dataset.context; + document.querySelectorAll("[data-context]").forEach((item) => item.classList.toggle("is-active", item === button)); + renderContext(); + })); + document.querySelectorAll("[data-mobile-action]").forEach((button) => button.addEventListener("click", () => { + const action = button.dataset.mobileAction; + if (action === "files") renderMobileSheet("files"); + if (action === "search") openSearch(); + if (action === "new") openNewNote(); + if (action === "links") renderMobileSheet("links"); + if (action === "more") byId("moreMenu").classList.toggle("is-visible"); + })); + byId("searchTrigger").addEventListener("click", () => openSearch()); + byId("inPageSearchButton").addEventListener("click", () => openSearch(currentNote().title)); + byId("newNoteButton").addEventListener("click", openNewNote); + byId("createNoteButton").addEventListener("click", createNote); + document.querySelectorAll(".close-overlay-button").forEach((button) => button.addEventListener("click", closeAllOverlays)); + byId("globalSearchInput").addEventListener("input", (event) => { + state.searchIndex = 0; + renderSearchResults(event.target.value); + }); + byId("globalSearchInput").addEventListener("keydown", (event) => { + const results = filteredNotes(event.currentTarget.value); + if (event.key === "ArrowDown" && results.length) { + event.preventDefault(); state.searchIndex = (state.searchIndex + 1) % results.length; renderSearchResults(event.currentTarget.value); + } + if (event.key === "ArrowUp" && results.length) { + event.preventDefault(); state.searchIndex = (state.searchIndex - 1 + results.length) % results.length; renderSearchResults(event.currentTarget.value); + } + if (event.key === "Enter" && results[state.searchIndex]) openNote(results[state.searchIndex].id); + }); + byId("pageTitleInput").addEventListener("input", (event) => { + const note = currentNote(); + note.title = event.target.value; + const pathParts = note.path.split("/"); + pathParts[pathParts.length - 1] = note.title; + note.path = pathParts.join("/"); + renderBreadcrumb(note); + renderPreview(note); + renderNavigation(); + markChanged(); + }); + byId("editorContent").addEventListener("input", () => { + currentNote().content = byId("editorContent").innerHTML; + renderPreview(currentNote()); + const count = byId("editorContent").innerText.replace(/\s+/g, "").length; + byId("wordCount").textContent = `${count} 字`; + markChanged(); + }); + byId("themeButton").addEventListener("click", toggleTheme); + byId("menuThemeButton").addEventListener("click", () => { toggleTheme(); byId("moreMenu").classList.remove("is-visible"); }); + byId("connectionButton").addEventListener("click", () => setOffline(!state.offline)); + byId("menuOfflineButton").addEventListener("click", () => { setOffline(!state.offline); byId("moreMenu").classList.remove("is-visible"); }); + byId("moreButton").addEventListener("click", (event) => { event.stopPropagation(); byId("moreMenu").classList.toggle("is-visible"); }); + byId("collapseNavButton").addEventListener("click", () => byId("appShell").classList.add("is-nav-collapsed")); + byId("showNavButton").addEventListener("click", () => { + if (window.innerWidth <= 760) renderMobileSheet("files"); + else byId("appShell").classList.remove("is-nav-collapsed"); + }); + byId("toggleContextButton").addEventListener("click", () => byId("appShell").classList.toggle("is-context-collapsed")); + byId("closeContextButton").addEventListener("click", () => byId("appShell").classList.add("is-context-collapsed")); + byId("closeMobileSheet").addEventListener("click", closeAllOverlays); + document.querySelectorAll(".overlay").forEach((overlay) => overlay.addEventListener("pointerdown", (event) => { if (event.target === overlay) closeAllOverlays(); })); + document.addEventListener("click", (event) => { if (!byId("moreMenu").contains(event.target) && event.target !== byId("moreButton")) byId("moreMenu").classList.remove("is-visible"); }); + document.addEventListener("keydown", (event) => { + if (event.key === "Escape") { closeAllOverlays(); byId("moreMenu").classList.remove("is-visible"); } + if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") { event.preventDefault(); openSearch(); } + }); + setupResizer(byId("navResizer"), "left"); + setupResizer(byId("contextResizer"), "right"); +} + +function init() { + byId("documentStage").dataset.mode = state.mode; + renderNavigation(); + openNote(state.currentNoteId); + initEvents(); + refreshIcons(); +} + +init(); diff --git a/docs/design-v1/index.html b/docs/design-v1/index.html new file mode 100644 index 00000000..4766ff05 --- /dev/null +++ b/docs/design-v1/index.html @@ -0,0 +1,258 @@ + + + + + + + Plainleaf Workspace Demo + + + + +
+ + + + + + +
+
+
+ + +
+ +
+
+ + 已保存 +
+
+ + + +
+ + + +
+
+ +
+ + 当前离线,修改会保存在本机并在连接恢复后同步。 +
+ +
+
+ +
+ 今天 10:42 更新 + + 项目 / Plainleaf +
+
+
+ + + +
+
+ +
+
+ + 426 字 + 12 个链接 +
+
+ 第 18 行,第 6 列 + Markdown +
+
+
+ + + + + + +
+ + + + + + + + + + + + + diff --git a/docs/design-v1/styles.css b/docs/design-v1/styles.css new file mode 100644 index 00000000..b7786201 --- /dev/null +++ b/docs/design-v1/styles.css @@ -0,0 +1,1470 @@ +:root { + color-scheme: light; + --canvas: #f7f8f6; + --editor: #ffffff; + --navigation: #eef1ee; + --rail: #202621; + --rail-muted: #9ca69e; + --rail-active: #f0f5f1; + --text: #20241f; + --muted: #687169; + --faint: #929a93; + --border: #dce2dc; + --border-strong: #cbd3cc; + --accent: #397453; + --accent-hover: #2f6447; + --accent-soft: #dcece2; + --accent-contrast: #ffffff; + --link: #376d8a; + --warning: #a86718; + --warning-soft: #fff4dc; + --danger: #b64a4a; + --selection: #e2eee6; + --code-bg: #f2f4f2; + --shadow-modal: 0 18px 60px rgb(29 36 31 / 22%); + --nav-width: 272px; + --context-width: 296px; + --top-height: 44px; + --status-height: 28px; + --rail-width: 48px; + --radius: 5px; + --ui-font: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans SC", "Microsoft YaHei", sans-serif; + --body-font: Inter, "Noto Sans SC", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + --code-font: "JetBrains Mono", "SFMono-Regular", Consolas, monospace; +} + +html[data-theme="dark"] { + color-scheme: dark; + --canvas: #171a18; + --editor: #1e221f; + --navigation: #202521; + --rail: #111411; + --rail-muted: #89938b; + --rail-active: #e1e8e2; + --text: #e8ece8; + --muted: #9ba69d; + --faint: #727c74; + --border: #343b35; + --border-strong: #465048; + --accent: #75b18d; + --accent-hover: #88c09e; + --accent-soft: #283c30; + --accent-contrast: #122018; + --link: #75a8c2; + --warning: #dfa84f; + --warning-soft: #3b3020; + --danger: #dc7d7d; + --selection: #2a3d31; + --code-bg: #292e2a; + --shadow-modal: 0 22px 70px rgb(0 0 0 / 48%); +} + +* { + box-sizing: border-box; +} + +html, +body { + width: 100%; + height: 100%; + margin: 0; + overflow: hidden; + background: var(--canvas); + color: var(--text); + font-family: var(--ui-font); + letter-spacing: 0; +} + +button, +input, +select { + color: inherit; + font: inherit; + letter-spacing: 0; +} + +button { + border: 0; +} + +button:focus-visible, +input:focus-visible, +select:focus-visible, +[contenteditable="true"]:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 1px; +} + +.app-shell { + display: grid; + grid-template-columns: var(--rail-width) var(--nav-width) 4px minmax(420px, 1fr) 4px var(--context-width); + width: 100%; + height: 100%; + background: var(--canvas); +} + +.activity-rail { + z-index: 20; + display: flex; + flex-direction: column; + align-items: center; + grid-column: 1; + background: var(--rail); + color: var(--rail-muted); + border-right: 1px solid rgb(255 255 255 / 7%); +} + +.brand-mark, +.rail-button { + display: grid; + place-items: center; + position: relative; + width: 36px; + height: 36px; + padding: 0; + border-radius: var(--radius); + color: inherit; + background: transparent; + cursor: pointer; +} + +.brand-mark { + width: 34px; + height: 34px; + margin: 8px 0 10px; + color: #dce9df; + background: #315f45; +} + +.brand-mark svg { + width: 19px; + height: 19px; +} + +.rail-tools, +.rail-footer { + display: flex; + flex-direction: column; + gap: 4px; + align-items: center; +} + +.rail-tools { + flex: 1; +} + +.rail-footer { + margin-bottom: 8px; +} + +.rail-button:hover, +.rail-button.is-active { + color: var(--rail-active); + background: rgb(255 255 255 / 8%); +} + +.rail-button.is-active::before { + position: absolute; + left: -6px; + width: 3px; + height: 20px; + border-radius: 0 3px 3px 0; + background: #79b38e; + content: ""; +} + +.rail-button svg, +.icon-button svg { + width: 18px; + height: 18px; + stroke-width: 1.8; +} + +.navigation-pane { + display: flex; + min-width: 0; + flex-direction: column; + grid-column: 2; + background: var(--navigation); + border-right: 1px solid var(--border); + overflow: hidden; +} + +.pane-header, +.context-header { + display: flex; + align-items: center; + justify-content: space-between; + flex: 0 0 auto; +} + +.pane-header { + height: 72px; + padding: 12px 12px 8px 16px; +} + +.workspace-kicker, +.dialog-kicker { + margin: 0 0 3px; + color: var(--faint); + font-size: 10px; + font-weight: 700; + line-height: 14px; +} + +.pane-header h1, +.new-note-dialog h2, +.mobile-sheet h2 { + margin: 0; + font-size: 16px; + font-weight: 650; + line-height: 22px; +} + +.header-actions, +.toolbar-actions, +.toolbar-leading, +.context-tabs, +.status-group { + display: flex; + align-items: center; +} + +.header-actions, +.toolbar-actions, +.context-tabs { + gap: 3px; +} + +.icon-button { + display: grid; + place-items: center; + flex: 0 0 32px; + width: 32px; + height: 32px; + padding: 0; + border-radius: var(--radius); + color: var(--muted); + background: transparent; + cursor: pointer; +} + +.icon-button:hover, +.context-tab:hover, +.context-tab.is-active { + color: var(--text); + background: var(--selection); +} + +.search-trigger { + display: flex; + align-items: center; + gap: 8px; + width: calc(100% - 24px); + height: 34px; + margin: 0 12px 10px; + padding: 0 9px; + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--muted); + background: var(--editor); + cursor: pointer; + text-align: left; +} + +.search-trigger:hover { + border-color: var(--border-strong); +} + +.search-trigger svg { + width: 15px; + height: 15px; +} + +.search-trigger span { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +kbd { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 22px; + height: 20px; + padding: 0 5px; + border: 1px solid var(--border); + border-bottom-color: var(--border-strong); + border-radius: 4px; + color: var(--faint); + background: var(--canvas); + font-family: var(--ui-font); + font-size: 11px; +} + +.navigation-content { + flex: 1; + min-height: 0; + padding: 0 8px 12px; + overflow: auto; +} + +.nav-section { + margin-top: 10px; +} + +.section-heading { + display: flex; + align-items: center; + justify-content: space-between; + height: 24px; + padding: 0 8px; + color: var(--faint); + font-size: 11px; + font-weight: 700; +} + +.section-heading button { + display: grid; + place-items: center; + width: 24px; + height: 24px; + padding: 0; + border-radius: 4px; + color: var(--muted); + background: transparent; + cursor: pointer; +} + +.section-heading button:hover { + background: var(--selection); +} + +.nav-row, +.tree-row, +.tag-row, +.task-row, +.search-result { + display: flex; + align-items: center; + min-width: 0; + border-radius: var(--radius); + cursor: pointer; +} + +.nav-row, +.tree-row, +.tag-row, +.task-row { + height: 28px; + padding: 0 7px; + color: var(--muted); + font-size: 13px; +} + +.nav-row:hover, +.tree-row:hover, +.tag-row:hover, +.task-row:hover, +.tree-row.is-active { + color: var(--text); + background: var(--selection); +} + +.tree-row.is-active { + font-weight: 600; +} + +.tree-row svg, +.nav-row svg, +.tag-row svg, +.task-row svg { + flex: 0 0 auto; + width: 15px; + height: 15px; + margin-right: 7px; + stroke-width: 1.8; +} + +.tree-row .disclosure { + width: 14px; + margin-right: 3px; +} + +.tree-row .disclosure.is-placeholder { + visibility: hidden; +} + +.row-label { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.row-meta { + flex: 0 0 auto; + margin-left: 8px; + color: var(--faint); + font-size: 11px; +} + +.folder-children.is-collapsed { + display: none; +} + +.navigation-footer { + display: flex; + align-items: center; + justify-content: space-between; + flex: 0 0 40px; + min-width: 0; + padding: 0 7px 0 14px; + border-top: 1px solid var(--border); + color: var(--muted); + font-size: 11px; +} + +.sync-summary { + display: flex; + align-items: center; + min-width: 0; + gap: 7px; +} + +.sync-summary span:last-child { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.status-dot { + flex: 0 0 7px; + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--warning); +} + +.status-dot.is-online { + background: #4f956b; +} + +.pane-resizer { + z-index: 12; + cursor: col-resize; + background: transparent; +} + +.pane-resizer:hover, +.pane-resizer.is-dragging { + background: var(--accent); +} + +.nav-resizer { + grid-column: 3; +} + +.context-resizer { + grid-column: 5; +} + +.workspace { + display: flex; + min-width: 0; + flex-direction: column; + grid-column: 4; + background: var(--editor); +} + +.workspace-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + flex: 0 0 var(--top-height); + min-width: 0; + padding: 0 8px 0 14px; + border-bottom: 1px solid var(--border); + background: var(--editor); +} + +.toolbar-leading { + min-width: 0; +} + +.breadcrumb { + display: flex; + align-items: center; + min-width: 0; + color: var(--muted); + font-size: 12px; + white-space: nowrap; +} + +.breadcrumb span { + overflow: hidden; + text-overflow: ellipsis; +} + +.breadcrumb svg { + flex: 0 0 13px; + width: 13px; + height: 13px; + margin: 0 2px; + color: var(--faint); +} + +.save-state { + display: flex; + align-items: center; + gap: 4px; + width: 66px; + color: var(--muted); + font-size: 11px; +} + +.save-state svg { + width: 14px; + height: 14px; +} + +.save-state.is-saving { + color: var(--warning); +} + +.segmented-control { + display: grid; + grid-template-columns: repeat(3, 42px); + height: 30px; + padding: 2px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--canvas); +} + +.segment { + padding: 0; + border-radius: 3px; + color: var(--muted); + background: transparent; + cursor: pointer; + font-size: 11px; +} + +.segment:hover, +.segment.is-active { + color: var(--text); + background: var(--editor); + box-shadow: 0 1px 2px rgb(24 30 25 / 10%); +} + +.show-nav-button { + display: none; + margin-right: 5px; +} + +.offline-banner { + display: none; + align-items: center; + gap: 8px; + flex: 0 0 34px; + padding: 0 16px; + color: var(--warning); + background: var(--warning-soft); + border-bottom: 1px solid color-mix(in srgb, var(--warning), transparent 70%); + font-size: 12px; +} + +.offline-banner.is-visible { + display: flex; +} + +.offline-banner svg { + width: 15px; + height: 15px; +} + +.document-stage { + display: grid; + grid-template-columns: minmax(0, 1fr) 0 minmax(0, 0); + flex: 1; + min-height: 0; + overflow: hidden; +} + +.document-stage[data-mode="read"] { + grid-template-columns: 0 0 minmax(0, 1fr); +} + +.document-stage[data-mode="split"] { + grid-template-columns: minmax(0, 1fr) 1px minmax(0, 1fr); +} + +.document-stage[data-mode="split"] .split-divider { + background: var(--border); +} + +.document-editor, +.document-preview { + min-width: 0; + min-height: 0; + overflow: auto; +} + +.document-editor { + padding: 48px max(40px, calc((100% - 760px) / 2)) 80px; +} + +.document-preview { + padding: 48px max(40px, calc((100% - 760px) / 2)) 80px; +} + +.document-stage[data-mode="split"] .document-editor, +.document-stage[data-mode="split"] .document-preview { + padding-right: 36px; + padding-left: 36px; +} + +.page-title-input { + display: block; + width: 100%; + margin: 0; + padding: 0; + border: 0; + color: var(--text); + background: transparent; + font-family: var(--body-font); + font-size: 30px; + font-weight: 650; + line-height: 38px; +} + +.page-title-input:focus { + outline: 0; +} + +.page-meta-line { + display: flex; + align-items: center; + gap: 6px; + margin: 7px 0 34px; + color: var(--faint); + font-size: 12px; + line-height: 18px; +} + +.editor-content, +.document-preview { + color: var(--text); + font-family: var(--body-font); + font-size: 16px; + line-height: 28px; +} + +.editor-content:focus { + outline: 0; +} + +.editor-content h2, +.document-preview h2 { + margin: 34px 0 10px; + color: var(--text); + font-size: 21px; + font-weight: 650; + line-height: 30px; +} + +.editor-content p, +.document-preview p { + margin: 0 0 18px; +} + +.editor-content ul, +.document-preview ul { + margin: 0 0 22px; + padding-left: 23px; +} + +.editor-content li, +.document-preview li { + margin: 5px 0; + padding-left: 3px; +} + +.editor-content code, +.document-preview code { + padding: 2px 5px; + border-radius: 4px; + background: var(--code-bg); + color: var(--danger); + font-family: var(--code-font); + font-size: 0.9em; +} + +.editor-content a, +.document-preview a, +.wiki-link { + color: var(--link); + text-decoration: none; +} + +.editor-content blockquote, +.document-preview blockquote { + margin: 24px 0; + padding: 4px 0 4px 16px; + border-left: 3px solid var(--accent); + color: var(--muted); +} + +.document-preview > h1 { + margin: 0 0 34px; + font-size: 30px; + line-height: 38px; +} + +.status-bar { + display: flex; + align-items: center; + justify-content: space-between; + flex: 0 0 var(--status-height); + min-width: 0; + padding: 0 10px; + border-top: 1px solid var(--border); + color: var(--faint); + background: var(--canvas); + font-size: 10px; +} + +.status-group { + min-width: 0; + gap: 12px; +} + +.status-button { + display: flex; + align-items: center; + gap: 5px; + height: 22px; + padding: 0; + color: inherit; + background: transparent; + cursor: pointer; +} + +.status-button:hover { + color: var(--text); +} + +.status-button svg { + width: 13px; + height: 13px; +} + +.context-pane { + display: flex; + min-width: 0; + flex-direction: column; + grid-column: 6; + background: var(--canvas); + border-left: 1px solid var(--border); + overflow: hidden; +} + +.context-header { + flex: 0 0 var(--top-height); + padding: 0 7px 0 10px; + border-bottom: 1px solid var(--border); +} + +.context-tab { + display: grid; + place-items: center; + width: 32px; + height: 30px; + padding: 0; + border-radius: var(--radius); + color: var(--muted); + background: transparent; + cursor: pointer; +} + +.context-tab svg { + width: 16px; + height: 16px; +} + +.context-content { + flex: 1; + min-height: 0; + padding: 16px 14px 28px; + overflow: auto; +} + +.context-title { + margin: 0 0 14px; + color: var(--muted); + font-size: 11px; + font-weight: 700; + text-transform: uppercase; +} + +.outline-list, +.backlink-list, +.property-list, +.info-list { + display: flex; + flex-direction: column; + gap: 3px; +} + +.outline-item { + display: block; + width: 100%; + min-height: 29px; + padding: 5px 7px; + border-radius: var(--radius); + color: var(--muted); + background: transparent; + cursor: pointer; + text-align: left; + font-size: 12px; + line-height: 18px; +} + +.outline-item.is-sub { + padding-left: 20px; +} + +.outline-item:hover, +.outline-item.is-active { + color: var(--text); + background: var(--selection); +} + +.backlink-item { + padding: 8px 6px 10px; + border-bottom: 1px solid var(--border); +} + +.backlink-item:last-child { + border-bottom: 0; +} + +.backlink-source { + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 5px; + color: var(--text); + font-size: 12px; + font-weight: 600; +} + +.backlink-source svg { + width: 13px; + height: 13px; + color: var(--link); +} + +.backlink-excerpt { + margin: 0; + color: var(--muted); + font-size: 11px; + line-height: 18px; +} + +.property-row, +.info-row { + display: grid; + grid-template-columns: 78px minmax(0, 1fr); + gap: 8px; + min-height: 30px; + padding: 5px 6px; + border-bottom: 1px solid var(--border); + font-size: 11px; + line-height: 19px; +} + +.property-row dt, +.info-row dt { + color: var(--faint); +} + +.property-row dd, +.info-row dd { + min-width: 0; + margin: 0; + overflow-wrap: anywhere; +} + +.tag-chip { + display: inline-flex; + align-items: center; + height: 20px; + margin: 0 4px 4px 0; + padding: 0 7px; + border-radius: 4px; + color: var(--accent); + background: var(--accent-soft); + font-size: 10px; +} + +.overlay { + display: none; + position: fixed; + inset: 0; + z-index: 100; + align-items: flex-start; + justify-content: center; + padding-top: min(16vh, 140px); + background: rgb(15 19 16 / 28%); + backdrop-filter: blur(2px); +} + +.overlay.is-visible { + display: flex; +} + +.command-dialog, +.new-note-dialog { + width: min(620px, calc(100vw - 32px)); + max-height: min(600px, calc(100vh - 80px)); + border: 1px solid var(--border-strong); + border-radius: 8px; + background: var(--editor); + box-shadow: var(--shadow-modal); + overflow: hidden; +} + +.dialog-search-row { + display: flex; + align-items: center; + gap: 10px; + height: 52px; + padding: 0 14px; + border-bottom: 1px solid var(--border); +} + +.dialog-search-row svg { + width: 18px; + height: 18px; + color: var(--muted); +} + +.dialog-search-row input { + flex: 1; + min-width: 0; + height: 42px; + padding: 0; + border: 0; + outline: 0; + color: var(--text); + background: transparent; + font-size: 16px; +} + +.dialog-section-label { + padding: 12px 14px 5px; + color: var(--faint); + font-size: 10px; + font-weight: 700; +} + +.search-results { + max-height: 360px; + padding: 4px 7px 8px; + overflow: auto; +} + +.search-result { + min-height: 54px; + padding: 7px 9px; + color: var(--text); +} + +.search-result:hover, +.search-result.is-selected { + background: var(--selection); +} + +.search-result-icon { + display: grid; + place-items: center; + flex: 0 0 30px; + width: 30px; + height: 30px; + margin-right: 10px; + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--muted); + background: var(--canvas); +} + +.search-result-icon svg { + width: 15px; + height: 15px; +} + +.search-result-copy { + flex: 1; + min-width: 0; +} + +.search-result-title, +.search-result-path { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.search-result-title { + font-size: 13px; + font-weight: 600; +} + +.search-result-path { + margin-top: 2px; + color: var(--faint); + font-size: 11px; +} + +.dialog-footer { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 14px; + height: 36px; + padding: 0 12px; + border-top: 1px solid var(--border); + color: var(--faint); + background: var(--canvas); + font-size: 10px; +} + +.dialog-footer span { + display: inline-flex; + align-items: center; + gap: 3px; +} + +.new-note-dialog { + width: min(440px, calc(100vw - 32px)); + padding: 18px; +} + +.new-note-dialog header, +.new-note-dialog footer, +.mobile-sheet header { + display: flex; + align-items: center; + justify-content: space-between; +} + +.new-note-dialog label { + display: block; + margin-top: 18px; + color: var(--muted); + font-size: 12px; +} + +.new-note-dialog label span { + display: block; + margin-bottom: 6px; +} + +.new-note-dialog input, +.new-note-dialog select { + width: 100%; + height: 38px; + padding: 0 10px; + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--text); + background: var(--canvas); +} + +.new-note-dialog footer { + justify-content: flex-end; + gap: 8px; + margin-top: 22px; +} + +.primary-button, +.secondary-button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 7px; + height: 36px; + padding: 0 13px; + border-radius: var(--radius); + cursor: pointer; + font-weight: 600; + font-size: 12px; +} + +.primary-button { + color: var(--accent-contrast); + background: var(--accent); +} + +.primary-button:hover { + background: var(--accent-hover); +} + +.secondary-button { + border: 1px solid var(--border); + background: var(--canvas); +} + +.primary-button svg { + width: 15px; + height: 15px; +} + +.popover-menu { + display: none; + position: fixed; + z-index: 90; + top: 40px; + right: calc(var(--context-width) + 16px); + width: 210px; + padding: 5px; + border: 1px solid var(--border-strong); + border-radius: 7px; + background: var(--editor); + box-shadow: var(--shadow-modal); +} + +.app-shell.is-context-collapsed ~ .popover-menu, +.popover-menu.is-context-collapsed { + right: 12px; +} + +.popover-menu.is-visible { + display: block; +} + +.popover-menu button { + display: flex; + align-items: center; + gap: 9px; + width: 100%; + height: 32px; + padding: 0 8px; + border-radius: var(--radius); + color: var(--text); + background: transparent; + cursor: pointer; + text-align: left; + font-size: 12px; +} + +.popover-menu button:hover { + background: var(--selection); +} + +.popover-menu button span { + flex: 1; +} + +.popover-menu svg { + width: 15px; + height: 15px; + color: var(--muted); +} + +.mobile-navigation, +.mobile-sheet-overlay { + display: none; +} + +.app-shell.is-nav-collapsed { + grid-template-columns: var(--rail-width) 0 0 minmax(420px, 1fr) 4px var(--context-width); +} + +.app-shell.is-nav-collapsed .navigation-pane, +.app-shell.is-nav-collapsed .nav-resizer { + display: none; +} + +.app-shell.is-nav-collapsed .show-nav-button { + display: grid; +} + +.app-shell.is-context-collapsed { + grid-template-columns: var(--rail-width) var(--nav-width) 4px minmax(420px, 1fr) 0 0; +} + +.app-shell.is-context-collapsed .context-pane, +.app-shell.is-context-collapsed .context-resizer { + display: none; +} + +.app-shell.is-nav-collapsed.is-context-collapsed { + grid-template-columns: var(--rail-width) 0 0 minmax(420px, 1fr) 0 0; +} + +.empty-state { + display: flex; + min-height: 220px; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 28px; + color: var(--muted); + text-align: center; +} + +.empty-state svg { + width: 28px; + height: 28px; + margin-bottom: 12px; + color: var(--faint); +} + +.empty-state h3 { + margin: 0 0 6px; + color: var(--text); + font-size: 14px; +} + +.empty-state p { + max-width: 240px; + margin: 0; + font-size: 12px; + line-height: 19px; +} + +@media (max-width: 1180px) { + :root { + --context-width: 270px; + } + + .app-shell { + grid-template-columns: var(--rail-width) var(--nav-width) 4px minmax(420px, 1fr) 0 0; + } + + .context-pane, + .context-resizer { + display: none; + } + + .popover-menu { + right: 12px; + } +} + +@media (max-width: 760px) { + :root { + --top-height: 48px; + --status-height: 24px; + } + + .app-shell, + .app-shell.is-nav-collapsed, + .app-shell.is-context-collapsed, + .app-shell.is-nav-collapsed.is-context-collapsed { + display: block; + height: 100%; + } + + .activity-rail, + .navigation-pane, + .pane-resizer, + .context-pane, + .segmented-control, + #toggleContextButton, + #inPageSearchButton, + .save-state, + .status-bar { + display: none !important; + } + + .workspace { + height: 100%; + padding-bottom: calc(58px + env(safe-area-inset-bottom)); + } + + .workspace-toolbar { + padding: 0 8px; + } + + .toolbar-leading { + flex: 1; + } + + .show-nav-button { + display: grid !important; + } + + .breadcrumb { + max-width: calc(100vw - 154px); + color: var(--text); + font-size: 13px; + font-weight: 600; + } + + .breadcrumb span:not(:last-child), + .breadcrumb svg { + display: none; + } + + .document-stage, + .document-stage[data-mode="read"], + .document-stage[data-mode="split"] { + display: block; + } + + .document-editor { + height: 100%; + padding: 28px 22px 80px; + } + + .document-preview, + .split-divider { + display: none; + } + + .page-title-input { + font-size: 26px; + line-height: 34px; + } + + .page-meta-line { + margin-bottom: 28px; + } + + .editor-content { + font-size: 16px; + line-height: 27px; + } + + .offline-banner { + min-height: 38px; + flex-basis: auto; + padding: 7px 12px; + line-height: 17px; + } + + .mobile-navigation { + display: grid; + position: fixed; + z-index: 40; + right: 0; + bottom: 0; + left: 0; + grid-template-columns: repeat(5, 1fr); + height: calc(58px + env(safe-area-inset-bottom)); + padding: 4px 8px env(safe-area-inset-bottom); + border-top: 1px solid var(--border); + background: color-mix(in srgb, var(--editor) 94%, transparent); + backdrop-filter: blur(16px); + } + + .mobile-navigation button { + display: flex; + min-width: 0; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 2px; + height: 50px; + padding: 0; + color: var(--muted); + background: transparent; + cursor: pointer; + font-size: 10px; + } + + .mobile-navigation button:hover { + color: var(--accent); + } + + .mobile-navigation svg { + width: 19px; + height: 19px; + } + + .mobile-navigation .mobile-create { + align-self: center; + justify-self: center; + width: 44px; + height: 44px; + margin-top: -11px; + border-radius: 50%; + color: var(--accent-contrast); + background: var(--accent); + box-shadow: 0 5px 14px rgb(31 76 49 / 28%); + } + + .mobile-navigation .mobile-create svg { + width: 22px; + height: 22px; + } + + .overlay { + align-items: flex-end; + padding: 0; + } + + .command-dialog, + .new-note-dialog { + width: 100%; + max-height: min(82vh, 680px); + border-width: 1px 0 0; + border-radius: 8px 8px 0 0; + } + + .new-note-dialog { + padding-bottom: calc(18px + env(safe-area-inset-bottom)); + } + + .mobile-sheet-overlay.is-visible { + display: flex; + } + + .mobile-sheet { + width: 100%; + max-height: 86vh; + padding: 0 12px calc(18px + env(safe-area-inset-bottom)); + border-top: 1px solid var(--border-strong); + border-radius: 8px 8px 0 0; + background: var(--navigation); + box-shadow: var(--shadow-modal); + overflow: auto; + } + + .sheet-handle { + width: 34px; + height: 4px; + margin: 7px auto 4px; + border-radius: 4px; + background: var(--border-strong); + } + + .mobile-sheet header { + height: 48px; + padding: 0 2px 0 5px; + } + + .popover-menu { + top: 46px; + right: 8px; + } +} + +@media (max-width: 390px) { + .document-editor { + padding-right: 18px; + padding-left: 18px; + } + + .page-meta-line { + flex-wrap: wrap; + } +} + diff --git a/docs/design-v1/verify-demo.mjs b/docs/design-v1/verify-demo.mjs new file mode 100644 index 00000000..5125bbf0 --- /dev/null +++ b/docs/design-v1/verify-demo.mjs @@ -0,0 +1,85 @@ +import fs from "node:fs/promises"; +import { createRequire } from "node:module"; + +const require = createRequire(import.meta.url); +const { chromium } = require("playwright"); + +const baseUrl = process.env.PLAINLEAF_DEMO_URL || "http://127.0.0.1:4173/"; +const outputDir = "/tmp/plainleaf-design-v1"; +await fs.mkdir(outputDir, { recursive: true }); + +const browser = await chromium.launch({ headless: true }); +const viewports = [ + { name: "desktop-1440", width: 1440, height: 900 }, + { name: "desktop-1024", width: 1024, height: 768 }, + { name: "mobile-390", width: 390, height: 844 }, + { name: "mobile-430", width: 430, height: 932 }, +]; + +const failures = []; +const results = []; + +for (const viewport of viewports) { + const page = await browser.newPage({ viewport }); + const errors = []; + page.on("pageerror", (error) => errors.push(error.message)); + page.on("console", (message) => { + if (message.type() === "error") errors.push(message.text()); + }); + + await page.goto(baseUrl, { waitUntil: "networkidle" }); + await page.waitForSelector("#documentEditor"); + + const metrics = await page.evaluate(() => { + const visible = (element) => { + const style = getComputedStyle(element); + const rect = element.getBoundingClientRect(); + return style.display !== "none" && style.visibility !== "hidden" && rect.width > 0 && rect.height > 0; + }; + const overflow = [...document.querySelectorAll("button, .pane-header, .workspace-toolbar, .mobile-navigation, .page-title-input")] + .filter(visible) + .filter((element) => element.scrollWidth > element.clientWidth + 1 || element.scrollHeight > element.clientHeight + 1) + .map((element) => ({ + selector: element.id || element.className || element.tagName, + scrollWidth: element.scrollWidth, + clientWidth: element.clientWidth, + scrollHeight: element.scrollHeight, + clientHeight: element.clientHeight, + })); + const viewportOverflow = document.documentElement.scrollWidth > innerWidth + 1 || document.documentElement.scrollHeight > innerHeight + 1; + return { overflow, viewportOverflow, title: document.querySelector("#pageTitleInput")?.value }; + }); + + if (viewport.width > 760) { + await page.locator('[data-tool="search"]').click(); + await page.locator('[data-open-search="Plainleaf"]').click(); + await page.waitForSelector("#searchOverlay.is-visible"); + await page.keyboard.press("Escape"); + await page.locator('[data-tool="files"]').click(); + await page.locator('[data-note-id="zspace"]').first().click(); + const title = await page.locator("#pageTitleInput").inputValue(); + if (title !== "极空间部署") failures.push(`${viewport.name}: note navigation failed`); + await page.locator('[data-mode="split"]').click(); + await page.locator("#themeButton").click(); + } else { + await page.locator('[data-mobile-action="files"]').click(); + await page.waitForSelector("#mobileSheetOverlay.is-visible"); + await page.locator('#mobileSheetContent [data-note-id="daily"]').click(); + const title = await page.locator("#pageTitleInput").inputValue(); + if (title !== "2026-08-12") failures.push(`${viewport.name}: mobile note navigation failed`); + await page.locator('[data-mobile-action="search"]').click(); + await page.waitForSelector("#searchOverlay.is-visible"); + await page.keyboard.press("Escape"); + } + + await page.screenshot({ path: `${outputDir}/${viewport.name}.png`, fullPage: true }); + if (errors.length) failures.push(`${viewport.name}: ${errors.join(" | ")}`); + if (metrics.viewportOverflow) failures.push(`${viewport.name}: viewport overflow`); + if (metrics.overflow.length) failures.push(`${viewport.name}: element overflow ${JSON.stringify(metrics.overflow)}`); + results.push({ viewport: viewport.name, ...metrics, errors }); + await page.close(); +} + +await browser.close(); +console.log(JSON.stringify({ baseUrl, outputDir, results, failures }, null, 2)); +if (failures.length) process.exit(1); diff --git a/docs/product-design/decisions-v1.md b/docs/product-design/decisions-v1.md new file mode 100644 index 00000000..57241047 --- /dev/null +++ b/docs/product-design/decisions-v1.md @@ -0,0 +1,56 @@ +# Plainleaf V1 Product Decisions + +Status: Accepted defaults for interactive prototype + +## Product Position + +Plainleaf is a file-native personal knowledge workspace. It keeps +SilverBullet's Markdown, local-first, programmable foundation and presents it +through a familiar, discoverable workspace. + +The product is inspired by Obsidian's working model, but it is not an Obsidian +skin. Plainleaf should be easier to approach, quieter during writing, and retain +SilverBullet's scripting, query, and extension strengths. + +## Accepted Decisions + +1. **Compact workbench density.** Navigation is compact while document text + remains comfortable to read. +2. **Files-first entry.** The file tree is the default navigation surface. + Recent notes, favorites, tags, and tasks remain one click away. +3. **Single-document V1.** Tabs are deferred until the workspace shell and + editor lifecycle are stable. +4. **Plainleaf product identity.** The prototype uses the Plainleaf name and a + restrained leaf-green accent, while upstream attribution remains in About + and repository documentation. +5. **Markdown-first V1.** Plain text and sandboxed HTML editing are phase-two + capabilities. +6. **Native desktop shell, purpose-built mobile shell.** Mobile uses sheets and + bottom navigation instead of compressing desktop panes. +7. **No storage changes.** Ordinary files in the SilverBullet Space remain the + source of truth. + +## V1 Scope + +- Activity rail and persistent file navigation. +- Slash-path folder tree. +- Recent and favorite notes. +- Search and quick switcher. +- Existing CodeMirror editor in edit, reading, and split presentation modes. +- Collapsible/resizable navigation and context panes. +- Outline, backlinks, properties, and page information. +- Create, rename, move, and delete entry points backed by existing commands. +- Light and dark themes. +- Mobile files/search sheets and quick create. +- Existing command palette, plugs, Lua, queries, offline sync, and external + file reconciliation remain available. + +## Deferred + +- Multi-document tabs. +- AI features. +- Plugin marketplace. +- `.txt` and `.html` editing. +- Multi-window desktop behavior. +- A new server, database, or sync model. + diff --git a/docs/product-design/design-review-v1.md b/docs/product-design/design-review-v1.md new file mode 100644 index 00000000..78e4915e --- /dev/null +++ b/docs/product-design/design-review-v1.md @@ -0,0 +1,49 @@ +# Plainleaf V1 Design Review + +Status: Approved for interactive prototype + +## Designer Review + +- **Must fix before core implementation:** Validate long Chinese and English + filenames, nested folder depth, small desktop widths, and mobile keyboard + viewport behavior in the prototype. +- **Must fix before core implementation:** Make active, hover, focus, modified, + offline, and disabled states visually distinct without relying on color alone. +- **Recommended:** Keep the editor title at 30px rather than adopting oversized + document headings from marketing-oriented interfaces. +- **Later:** User-selectable density and typography presets. + +## Product Review + +- **Must fix before core implementation:** New note, search, rename, and delete + must be visible actions, not command-palette-only flows. +- **Must fix before core implementation:** The default launch state must work + with an empty Space and with thousands of pages. +- **Recommended:** Defer tabs. Recent history plus quick switcher is sufficient + to validate the workspace model. +- **Later:** A configurable home/dashboard page. + +## Architecture Review + +- **Must fix before core implementation:** Do not duplicate `allPages` into a + second persistent tree index. Derive a memoized tree view. +- **Must fix before core implementation:** Keep a single CodeMirror mount in V1. + Presentation modes must not create competing editor instances. +- **Must fix before core implementation:** UI commands must invoke existing + navigation and file operations so plugins and events continue to work. +- **Recommended:** Build the shell from new isolated Preact components and one + SCSS entry, minimizing edits to `editor_ui.tsx`. +- **Later:** Define stable plug extension slots after the shell behavior is + validated. + +## Prototype Gate + +Approved with these checks: + +1. Desktop at 1440x900 and 1024x768. +2. Mobile at 390x844 and 430x932. +3. File, search, tag, and task rail switching. +4. Note switching, folder expansion, pane collapse, resizing, and theme change. +5. No overlapping controls or clipped labels at tested sizes. +6. Keyboard focus and Escape behavior for overlays. + diff --git a/docs/product-design/design-spec-v1.md b/docs/product-design/design-spec-v1.md new file mode 100644 index 00000000..9e9bff33 --- /dev/null +++ b/docs/product-design/design-spec-v1.md @@ -0,0 +1,197 @@ +# Plainleaf Workspace Design Specification V1 + +Status: Prototype specification + +## 1. Experience Principles + +1. **Files stay legible.** UI labels and actions should reinforce that the user + is working with ordinary files and folders. +2. **Writing owns the canvas.** Navigation supports the editor and never turns + the product into a dashboard of decorative cards. +3. **Power is progressive.** Basic actions are visible; Lua, queries, commands, + and plug surfaces remain available without dominating first use. +4. **Stable geometry.** Toolbars, panes, counters, and controls use stable + dimensions so content changes do not shift the workspace. +5. **Mobile is for capture, search, and reading first.** Editing remains fully + available, but the layout prioritizes common narrow-screen workflows. + +## 2. Design System + +### Color + +Light theme: + +| Token | Value | Use | +| --- | --- | --- | +| Canvas | `#f7f8f6` | Application background | +| Editor | `#ffffff` | Document surface | +| Navigation | `#eef1ee` | Left navigation | +| Rail | `#202621` | Activity rail | +| Primary text | `#20241f` | Headings and body | +| Secondary text | `#687169` | Metadata | +| Border | `#dce2dc` | Pane and control separation | +| Accent | `#397453` | Selection, links, primary action | +| Accent soft | `#dcece2` | Selected navigation row | +| Informational | `#376d8a` | Links and sync information | +| Warning | `#a86718` | Unsaved or degraded state | +| Danger | `#b64a4a` | Destructive actions | + +Dark theme: + +| Token | Value | Use | +| --- | --- | --- | +| Canvas | `#171a18` | Application background | +| Editor | `#1e221f` | Document surface | +| Navigation | `#202521` | Left navigation | +| Rail | `#111411` | Activity rail | +| Primary text | `#e8ece8` | Headings and body | +| Secondary text | `#9ba69d` | Metadata | +| Border | `#343b35` | Pane and control separation | +| Accent | `#75b18d` | Selection, links, primary action | +| Accent soft | `#283c30` | Selected navigation row | + +Accent green is limited to active state and commands. Blue is reserved for +links and sync, amber for pending work, and red for destructive or failed +states. The interface should never read as a monochromatic green theme. + +### Typography + +- UI: system sans (`-apple-system`, `Segoe UI`, `Noto Sans SC`, sans-serif). +- Editor: `Inter`, `Noto Sans SC`, system sans by default. +- Code: `JetBrains Mono`, `SFMono-Regular`, monospace. +- Navigation: 13px / 20px. +- Controls: 13px / 18px. +- Body: 16px / 28px. +- Page title: 30px / 38px, weight 650. +- Section heading: 21px / 30px, weight 650. +- Letter spacing is always `0`. + +### Spacing and Geometry + +- Base spacing unit: 4px. +- Activity rail: 48px. +- Desktop top bar: 44px. +- Navigation pane: default 272px; range 220-380px. +- Context pane: default 296px; range 240-380px. +- Editor readable width: 760px, with 40px minimum lateral padding. +- Icon buttons: 32x32px; mobile primary create: 44x44px. +- Control radius: 5px; modal radius: 8px. +- Borders, not floating cards, define workspace regions. + +### Interaction + +- Hover: 120ms background/color transition. +- Pane expansion: 180ms ease-out. +- Sheet/modal entry: 180ms opacity and translate. +- Save status changes without moving the title or toolbar. +- Tooltips appear for icon-only controls after a short hover delay. +- Focus rings remain visible and use the accent color. + +## 3. Navigation Architecture + +```text +Plainleaf +├── Files +│ ├── Favorites +│ ├── Recent +│ └── Folder tree +├── Search +│ ├── Title and content search +│ └── Saved filters (later) +├── Tags +├── Tasks +├── Graph (existing capability, secondary) +├── Command palette +└── Settings +``` + +The activity rail changes the navigation pane's content. It does not navigate +away from the open document. + +## 4. Desktop Workspace + +### Activity Rail + +- Plainleaf mark at the top. +- Files, Search, Tags, Tasks, and Graph tools. +- Settings, theme, and account/about actions at the bottom. +- The active tool has a left indicator and a stronger icon color. + +### Navigation Pane + +- Header names the current tool and exposes contextual add/more actions. +- Files view shows quick search, favorites, recent, then the tree. +- Rows are 28px high. Folder disclosure, icon, label, and optional count have + fixed positions. +- Clicking a note opens it. Double-click behavior is not required in V1. +- Context menus expose rename, move, duplicate, and delete through existing + commands. + +### Document Workspace + +- Compact toolbar: breadcrumb, save state, presentation mode, search, more. +- Title is a clear input backed by the existing rename command. +- Existing CodeMirror is mounted without replacement. +- Edit is the default. Reading and split are presentation choices, not separate + copies of the note. +- A narrow status row shows words, links, cursor position, Markdown, and sync. + +### Context Pane + +- Tabs: Outline, Backlinks, Properties, Info. +- Outline follows document headings and highlights the current section. +- Backlinks show source page and a short matched excerpt. +- Properties edit frontmatter through existing editor operations. +- The pane is optional and hidden at widths below 1100px. + +## 5. Mobile Workspace + +- Top bar contains back/files, current page title, sync state, and more. +- Bottom navigation: Files, Search, New, Links, More. +- Files and search open in full-height sheets above the editor. +- New creates a note in the current folder when possible, then focuses the + editor. +- Outline, backlinks, properties, and commands live in sheets. +- Toolbar items remain at least 40px tall and account for safe-area insets. + +## 6. States + +- **Loading:** stable skeleton rows and document lines; no centered spinner + that changes layout. +- **Empty Space:** tree remains present with a single clear new-note command. +- **No Search Results:** preserves query and offers create-with-title. +- **Modified:** amber dot and `Saving...`; resolves to a quiet checkmark. +- **Offline:** persistent but nonblocking status; edits remain available. +- **Conflict:** blocks silent overwrite and opens a comparison action. +- **Read-only:** controls remain visible but disabled with a clear reason. + +## 7. Prototype Screens + +The interactive prototype must demonstrate: + +1. Desktop editor with files and outline. +2. Desktop dark theme with backlinks. +3. Search/command overlay. +4. Mobile editor. +5. Mobile file sheet. +6. Empty/offline/save states through interactive controls. + +No generated visual assets are required. Product identity is represented by a +small CSS mark and Lucide icons; the actual application experience is the +primary visual. + +## 8. Engineering Mapping + +| Prototype element | SilverBullet integration | +| --- | --- | +| File tree | Derive from `AppViewState.allPages` | +| Active page | `viewState.current` | +| Open page | `Client.navigate(parseToRef(name))` | +| Search | Page navigator and object index | +| Rename | Existing `index.renamePageCommand` | +| Delete | Existing Space/command flow | +| Editor | Existing `#sb-editor` CodeMirror mount | +| Outline/backlinks | Object index/query APIs | +| Pane state | Existing client store/config | +| Mobile state | Existing `isMobile` plus CSS media queries | +