docs: add interactive Plainleaf workspace demo
CI / config (push) Canceled after 0s
CI / test-frontend (push) Canceled after 0s
CI / test-rust (push) Canceled after 0s
CI / test-e2e (push) Canceled after 0s
CI / test-e2e-release (push) Canceled after 0s
CI / build (push) Canceled after 0s
CI / release (push) Canceled after 0s
CI / docker (push) Canceled after 0s
CI / docker-website (push) Canceled after 0s

This commit is contained in:
2026-08-12 10:52:04 +08:00
parent 28d4c1dc8c
commit 7bd42fa546
7 changed files with 2663 additions and 0 deletions
+548
View File
@@ -0,0 +1,548 @@
const notes = [
{
id: "plainleaf-plan",
title: "Plainleaf 改造计划",
path: "项目/Plainleaf/Plainleaf 改造计划",
updated: "今天 10:42",
favorite: true,
tags: ["产品", "设计", "SilverBullet"],
content: `
<p>Plainleaf 会保留 SilverBullet 最重要的基础:每一篇笔记仍然是 NAS 中可以直接打开的 Markdown 文件。</p>
<blockquote>目标不是复制 Obsidian,而是让文件、搜索和上下文工具变得可见,同时保留 SilverBullet 的查询与自动化能力。</blockquote>
<h2>第一阶段目标</h2>
<ul>
<li>建立常驻的文件树与快速搜索。</li>
<li>保留现有 <code>CodeMirror 6</code> 编辑器和实时预览。</li>
<li>增加大纲、反向链接、属性和页面信息。</li>
<li>为手机设计专用的快速记录流程。</li>
</ul>
<h2>数据原则</h2>
<p>界面只是工作层,文件才是长期资产。即使以后停止使用 Plainleaf,也可以通过 <a href="#">Obsidian</a>、VS Code 或普通文本编辑器继续使用全部内容。</p>
<h2>下一步</h2>
<p>交互 Demo 确认后,将工作区外壳接到 SilverBullet 的 <span class="wiki-link">[[页面索引]]</span> 与现有导航命令上。</p>
`,
outline: ["第一阶段目标", "数据原则", "下一步"],
backlinks: [
["产品路线图", "将 Plainleaf 改造计划作为第一阶段的体验基线。"],
["极空间部署", "新版上线时继续挂载同一个 Markdown Space。"],
["笔记工具对比", "最终选择基于 SilverBullet 构建 Plainleaf。"]
]
},
{
id: "zspace",
title: "极空间部署",
path: "知识库/Docker/极空间部署",
updated: "昨天 22:16",
favorite: true,
tags: ["Docker", "NAS", "部署"],
content: `
<p>极空间负责运行正式环境,Mac 用于开发和构建镜像。笔记目录通过卷挂载到容器的 <code>/space</code>。</p>
<h2>部署结构</h2>
<ul><li>固定镜像版本,避免使用 latest。</li><li>笔记目录和应用配置分别备份。</li><li>公网访问统一经过 HTTPS 与反向代理。</li></ul>
<h2>更新流程</h2>
<p>在本地完成构建和验证后,推送自定义镜像,再由极空间 Compose 切换版本。</p>
`,
outline: ["部署结构", "更新流程"],
backlinks: [["Plainleaf 改造计划", "正式版继续使用极空间现有的 Markdown 目录。"]]
},
{
id: "daily",
title: "2026-08-12",
path: "日记/2026/08/2026-08-12",
updated: "今天 08:30",
favorite: false,
tags: ["日记"],
content: `
<p>今天继续整理个人笔记系统,希望数据保持开放,同时让 Web 端变得更易用。</p>
<h2>今日事项</h2>
<ul><li>确认 Plainleaf 的工作区方向。</li><li>完成桌面与手机交互 Demo。</li><li>整理正式开发任务。</li></ul>
<h2>记录</h2>
<p>文件树应该承担定位,而不是让用户记住所有命令。高级能力可以继续通过命令面板进入。</p>
`,
outline: ["今日事项", "记录"],
backlinks: []
},
{
id: "docker-network",
title: "Docker 容器网络",
path: "知识库/Docker/Docker 容器网络",
updated: "8 月 10 日",
favorite: false,
tags: ["Docker", "网络"],
content: `
<p>同一个 Compose 项目中的服务应通过服务名通信,而不是依赖宿主机端口或孤立容器名称。</p>
<h2>服务发现</h2>
<p>容器加入同一网络后,可以使用 <code>service:port</code> 作为内部地址。</p>
<h2>排查顺序</h2>
<ul><li>确认两个服务属于同一 Docker 网络。</li><li>检查服务名能否解析。</li><li>检查目标端口是否监听在 0.0.0.0。</li></ul>
`,
outline: ["服务发现", "排查顺序"],
backlinks: [["极空间部署", "部署时先确认容器网络和服务发现。"]]
},
{
id: "tools",
title: "笔记工具对比",
path: "收件箱/笔记工具对比",
updated: "8 月 11 日",
favorite: false,
tags: ["调研", "笔记"],
content: `
<p>对比了 Blossom、Blinko、Notesnook、SilverBullet 和 Flatnotes。</p>
<h2>核心结论</h2>
<p>数据库优先的产品通常拥有更现代的成品体验,但文件原生模型更符合长期迁移与多工具使用的要求。</p>
<h2>选择</h2>
<p>以 SilverBullet 为文件与能力内核,在其上构建 Plainleaf 的现代工作区。</p>
`,
outline: ["核心结论", "选择"],
backlinks: [["Plainleaf 改造计划", "选型结论推动了 Plainleaf 项目。"]]
},
{
id: "ideas",
title: "产品灵感收件箱",
path: "收件箱/产品灵感收件箱",
updated: "8 月 9 日",
favorite: false,
tags: ["灵感"],
content: `
<p>临时想法先进入收件箱,稍后再归档到项目、知识库或日记。</p>
<h2>待整理</h2>
<ul><li>浏览器剪藏入口。</li><li>手机语音快速记录。</li><li>Markdown 文件历史对比。</li></ul>
`,
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 `<i data-lucide="${name}"${className ? ` class="${className}"` : ""}></i>`;
}
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 `
<div class="tree-row ${note.id === state.currentNoteId ? "is-active" : ""}" data-note-id="${note.id}" style="padding-left:${7 + depth * 15}px">
${icon("chevron-right", "disclosure is-placeholder")}
${icon("file-text")}
<span class="row-label">${note.title}</span>
</div>`;
}
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 `
<div class="tree-folder">
<div class="tree-row" data-folder-path="${path}" style="padding-left:${7 + depth * 15}px">
${icon(expanded ? "chevron-down" : "chevron-right", "disclosure")}
${icon(expanded ? "folder-open" : "folder")}
<span class="row-label">${node.name}</span>
${node.children.length ? `<span class="row-meta">${node.children.length}</span>` : ""}
</div>
<div class="folder-children ${expanded ? "" : "is-collapsed"}">${children}</div>
</div>`;
}
function filesViewMarkup() {
const favoriteRows = notes.filter((note) => note.favorite).map((note) => `
<div class="nav-row" data-note-id="${note.id}">
${icon("star")}
<span class="row-label">${note.title}</span>
</div>`).join("");
const recentRows = notes.slice(0, 3).map((note) => `
<div class="nav-row" data-note-id="${note.id}">
${icon("history")}
<span class="row-label">${note.title}</span>
<span class="row-meta">${note.updated.split(" ")[0]}</span>
</div>`).join("");
return `
<section class="nav-section">
<div class="section-heading"><span>收藏</span><button title="管理收藏" aria-label="管理收藏">${icon("ellipsis")}</button></div>
${favoriteRows}
</section>
<section class="nav-section">
<div class="section-heading"><span>最近</span><button title="清除最近记录" aria-label="清除最近记录">${icon("rotate-ccw")}</button></div>
${recentRows}
</section>
<section class="nav-section">
<div class="section-heading"><span>文件</span><button id="inlineNewNote" title="新建笔记" aria-label="新建笔记">${icon("plus")}</button></div>
<div class="file-tree">${tree.map((node) => renderTreeNode(node)).join("")}</div>
</section>`;
}
function searchViewMarkup() {
return `
<section class="nav-section">
<div class="section-heading"><span>搜索建议</span></div>
${["最近修改的笔记", "包含待办的页面", "没有标签的笔记", "本周创建的页面"].map((label, index) => `
<div class="nav-row" data-open-search="${label}">
${icon(index === 0 ? "clock-3" : index === 1 ? "list-checks" : index === 2 ? "tag" : "calendar-days")}
<span class="row-label">${label}</span>
</div>`).join("")}
</section>
<section class="nav-section">
<div class="section-heading"><span>搜索历史</span></div>
${["Docker", "Plainleaf", "部署"].map((label) => `<div class="nav-row" data-open-search="${label}">${icon("search")}<span class="row-label">${label}</span></div>`).join("")}
</section>`;
}
function tagsViewMarkup() {
const tags = [["Docker", 3], ["产品", 2], ["设计", 2], ["NAS", 2], ["日记", 1], ["灵感", 1], ["部署", 1]];
return `<section class="nav-section"><div class="section-heading"><span>所有标签</span><span>${tags.length}</span></div>${tags.map(([name, count]) => `<div class="tag-row">${icon("hash")}<span class="row-label">${name}</span><span class="row-meta">${count}</span></div>`).join("")}</section>`;
}
function tasksViewMarkup() {
const tasks = [["完成交互 Demo", false, "今天"], ["确认文件树布局", true, "今天"], ["整理开发任务", false, "明天"], ["构建极空间镜像", false, "稍后"]];
return `<section class="nav-section"><div class="section-heading"><span>开放任务</span><span>3</span></div>${tasks.map(([name, done, date]) => `<div class="task-row">${icon(done ? "circle-check-big" : "circle")}<span class="row-label" style="${done ? "text-decoration:line-through;opacity:.65" : ""}">${name}</span><span class="row-meta">${date}</span></div>`).join("")}</section>`;
}
function graphViewMarkup() {
return `<div class="empty-state">${icon("network")}<h3>关系图保留为高级视图</h3><p>正式实现将复用 SilverBullet 现有对象索引和关系图能力。</p></div>`;
}
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") : ""}<span>${segment}</span>`).join("");
}
function renderContext() {
const note = currentNote();
const heading = { outline: "大纲", backlinks: "反向链接", properties: "属性", info: "页面信息" }[state.context];
let body = "";
if (state.context === "outline") {
body = `<div class="outline-list">${note.outline.length ? note.outline.map((item, index) => `<button class="outline-item ${index === 0 ? "is-active" : ""}" type="button">${item}</button>`).join("") : `<div class="empty-state">${icon("list-tree")}<h3>没有大纲</h3><p>添加二级或三级标题后会显示在这里。</p></div>`}</div>`;
} else if (state.context === "backlinks") {
body = `<div class="backlink-list">${note.backlinks.length ? note.backlinks.map(([source, excerpt]) => `<article class="backlink-item"><div class="backlink-source">${icon("link-2")}<span>${source}</span></div><p class="backlink-excerpt">${excerpt}</p></article>`).join("") : `<div class="empty-state">${icon("links")}<h3>还没有反向链接</h3><p>其他页面引用当前笔记后会显示在这里。</p></div>`}</div>`;
} else if (state.context === "properties") {
body = `<dl class="property-list"><div class="property-row"><dt>tags</dt><dd>${note.tags.map((tag) => `<span class="tag-chip">${tag}</span>`).join("")}</dd></div><div class="property-row"><dt>status</dt><dd>active</dd></div><div class="property-row"><dt>created</dt><dd>2026-08-12</dd></div><div class="property-row"><dt>aliases</dt><dd>暂无</dd></div></dl>`;
} else {
body = `<dl class="info-list"><div class="info-row"><dt>文件</dt><dd>${note.path}.md</dd></div><div class="info-row"><dt>修改</dt><dd>${note.updated}</dd></div><div class="info-row"><dt>编码</dt><dd>UTF-8</dd></div><div class="info-row"><dt>大小</dt><dd>3.4 KB</dd></div><div class="info-row"><dt>权限</dt><dd>读写</dd></div></dl>`;
}
byId("contextContent").innerHTML = `<h2 class="context-title">${heading}</h2>${body}`;
refreshIcons();
}
function renderPreview(note) {
byId("documentPreview").innerHTML = `<h1>${note.title}</h1>${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")}<span id="connectionLabel">${offline ? "离线" : "已连接"}</span>`;
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")}<span>保存中</span>`;
refreshIcons();
saveTimer = setTimeout(() => {
byId("saveState").className = "save-state is-saved";
byId("saveState").innerHTML = `${icon("check")}<span>已保存</span>`;
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) => `
<div class="search-result ${index === state.searchIndex ? "is-selected" : ""}" data-search-note="${note.id}">
<div class="search-result-icon">${icon("file-text")}</div>
<div class="search-result-copy"><div class="search-result-title">${note.title}</div><div class="search-result-path">${note.path}.md</div></div>
<span class="row-meta">${note.updated}</span>
</div>`).join("") : `<div class="empty-state">${icon("search-x")}<h3>没有找到笔记</h3><p>保留当前搜索词,可以直接创建同名 Markdown 文件。</p></div>`;
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: `<p>开始记录...</p>`,
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 ? `<div class="backlink-list">${note.backlinks.map(([source, excerpt]) => `<article class="backlink-item"><div class="backlink-source">${icon("link-2")}<span>${source}</span></div><p class="backlink-excerpt">${excerpt}</p></article>`).join("")}</div>` : `<div class="empty-state">${icon("links")}<h3>还没有反向链接</h3><p>其他页面引用当前笔记后会显示在这里。</p></div>`;
} else {
byId("mobileSheetContent").innerHTML = `<div class="file-tree">${tree.map((node) => renderTreeNode(node)).join("")}</div>`;
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();
+258
View File
@@ -0,0 +1,258 @@
<!doctype html>
<html lang="zh-CN" data-theme="light">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#f7f8f6" />
<title>Plainleaf Workspace Demo</title>
<link rel="preconnect" href="https://unpkg.com" />
<link rel="stylesheet" href="./styles.css" />
</head>
<body>
<div class="app-shell" id="appShell">
<aside class="activity-rail" aria-label="工作区工具">
<button class="brand-mark" type="button" title="Plainleaf" aria-label="Plainleaf">
<i data-lucide="leaf"></i>
</button>
<nav class="rail-tools" aria-label="主要工具">
<button class="rail-button is-active" type="button" data-tool="files" title="文件" aria-label="文件">
<i data-lucide="files"></i>
</button>
<button class="rail-button" type="button" data-tool="search" title="搜索" aria-label="搜索">
<i data-lucide="search"></i>
</button>
<button class="rail-button" type="button" data-tool="tags" title="标签" aria-label="标签">
<i data-lucide="tags"></i>
</button>
<button class="rail-button" type="button" data-tool="tasks" title="任务" aria-label="任务">
<i data-lucide="circle-check-big"></i>
</button>
<button class="rail-button" type="button" data-tool="graph" title="关系图" aria-label="关系图">
<i data-lucide="network"></i>
</button>
</nav>
<div class="rail-footer">
<button class="rail-button" id="themeButton" type="button" title="切换主题" aria-label="切换主题">
<i data-lucide="moon"></i>
</button>
<button class="rail-button" type="button" title="设置" aria-label="设置">
<i data-lucide="settings"></i>
</button>
</div>
</aside>
<aside class="navigation-pane" id="navigationPane" aria-label="导航面板">
<div class="pane-header">
<div>
<p class="workspace-kicker">MY SPACE</p>
<h1 id="paneTitle">文件</h1>
</div>
<div class="header-actions">
<button class="icon-button" id="newNoteButton" type="button" title="新建笔记" aria-label="新建笔记">
<i data-lucide="file-plus-2"></i>
</button>
<button class="icon-button" type="button" title="更多文件操作" aria-label="更多文件操作">
<i data-lucide="ellipsis"></i>
</button>
</div>
</div>
<button class="search-trigger" id="searchTrigger" type="button">
<i data-lucide="search"></i>
<span>搜索笔记</span>
<kbd>⌘ K</kbd>
</button>
<div class="navigation-content" id="navigationContent"></div>
<div class="navigation-footer">
<div class="sync-summary">
<span class="status-dot is-online" id="navStatusDot"></span>
<span id="navStatusLabel">本地文件已同步</span>
</div>
<button class="icon-button" id="collapseNavButton" type="button" title="收起文件栏" aria-label="收起文件栏">
<i data-lucide="panel-left-close"></i>
</button>
</div>
</aside>
<div class="pane-resizer nav-resizer" id="navResizer" role="separator" aria-orientation="vertical"></div>
<main class="workspace" id="workspace">
<header class="workspace-toolbar">
<div class="toolbar-leading">
<button class="icon-button show-nav-button" id="showNavButton" type="button" title="显示文件栏" aria-label="显示文件栏">
<i data-lucide="panel-left-open"></i>
</button>
<div class="breadcrumb" id="breadcrumb" aria-label="当前路径"></div>
</div>
<div class="toolbar-actions">
<div class="save-state is-saved" id="saveState" aria-live="polite">
<i data-lucide="check"></i>
<span>已保存</span>
</div>
<div class="segmented-control" aria-label="文档模式">
<button class="segment is-active" type="button" data-mode="edit" title="编辑模式">编辑</button>
<button class="segment" type="button" data-mode="read" title="阅读模式">阅读</button>
<button class="segment" type="button" data-mode="split" title="分屏模式">分屏</button>
</div>
<button class="icon-button" id="inPageSearchButton" type="button" title="页内搜索" aria-label="页内搜索">
<i data-lucide="search"></i>
</button>
<button class="icon-button" id="toggleContextButton" type="button" title="切换上下文栏" aria-label="切换上下文栏">
<i data-lucide="panel-right"></i>
</button>
<button class="icon-button" id="moreButton" type="button" title="更多" aria-label="更多">
<i data-lucide="ellipsis"></i>
</button>
</div>
</header>
<div class="offline-banner" id="offlineBanner" role="status">
<i data-lucide="wifi-off"></i>
<span>当前离线,修改会保存在本机并在连接恢复后同步。</span>
</div>
<section class="document-stage" id="documentStage">
<article class="document-editor" id="documentEditor">
<input class="page-title-input" id="pageTitleInput" aria-label="页面标题" />
<div class="page-meta-line">
<span id="updatedTime">今天 10:42 更新</span>
<span aria-hidden="true">·</span>
<span id="pageLocation">项目 / Plainleaf</span>
</div>
<div class="editor-content" id="editorContent" contenteditable="true" spellcheck="false" aria-label="笔记正文"></div>
</article>
<div class="split-divider" aria-hidden="true"></div>
<article class="document-preview" id="documentPreview" aria-label="文档预览"></article>
</section>
<footer class="status-bar">
<div class="status-group">
<button class="status-button" id="connectionButton" type="button" title="切换离线演示状态">
<i data-lucide="cloud-check"></i>
<span id="connectionLabel">已连接</span>
</button>
<span id="wordCount">426 字</span>
<span>12 个链接</span>
</div>
<div class="status-group">
<span>第 18 行,第 6 列</span>
<span>Markdown</span>
</div>
</footer>
</main>
<div class="pane-resizer context-resizer" id="contextResizer" role="separator" aria-orientation="vertical"></div>
<aside class="context-pane" id="contextPane" aria-label="页面上下文">
<div class="context-header">
<div class="context-tabs" role="tablist" aria-label="上下文视图">
<button class="context-tab is-active" type="button" data-context="outline" title="大纲" aria-label="大纲">
<i data-lucide="list-tree"></i>
</button>
<button class="context-tab" type="button" data-context="backlinks" title="反向链接" aria-label="反向链接">
<i data-lucide="links"></i>
</button>
<button class="context-tab" type="button" data-context="properties" title="属性" aria-label="属性">
<i data-lucide="list-filter"></i>
</button>
<button class="context-tab" type="button" data-context="info" title="页面信息" aria-label="页面信息">
<i data-lucide="info"></i>
</button>
</div>
<button class="icon-button" id="closeContextButton" type="button" title="关闭上下文栏" aria-label="关闭上下文栏">
<i data-lucide="panel-right-close"></i>
</button>
</div>
<div class="context-content" id="contextContent"></div>
</aside>
<nav class="mobile-navigation" aria-label="手机导航">
<button type="button" data-mobile-action="files"><i data-lucide="files"></i><span>文件</span></button>
<button type="button" data-mobile-action="search"><i data-lucide="search"></i><span>搜索</span></button>
<button class="mobile-create" type="button" data-mobile-action="new" aria-label="新建笔记"><i data-lucide="plus"></i></button>
<button type="button" data-mobile-action="links"><i data-lucide="links"></i><span>链接</span></button>
<button type="button" data-mobile-action="more"><i data-lucide="ellipsis"></i><span>更多</span></button>
</nav>
</div>
<div class="overlay" id="searchOverlay" aria-hidden="true">
<section class="command-dialog" role="dialog" aria-modal="true" aria-labelledby="searchDialogTitle">
<div class="dialog-search-row">
<i data-lucide="search"></i>
<input id="globalSearchInput" type="search" placeholder="搜索标题、正文或标签" autocomplete="off" />
<kbd>ESC</kbd>
</div>
<div class="dialog-section-label" id="searchDialogTitle">快速打开</div>
<div class="search-results" id="searchResults"></div>
<footer class="dialog-footer">
<span><kbd>↑</kbd><kbd>↓</kbd> 选择</span>
<span><kbd>↵</kbd> 打开</span>
</footer>
</section>
</div>
<div class="overlay" id="newNoteOverlay" aria-hidden="true">
<section class="new-note-dialog" role="dialog" aria-modal="true" aria-labelledby="newNoteTitle">
<header>
<div>
<p class="dialog-kicker">NEW MARKDOWN FILE</p>
<h2 id="newNoteTitle">新建笔记</h2>
</div>
<button class="icon-button close-overlay-button" type="button" title="关闭" aria-label="关闭">
<i data-lucide="x"></i>
</button>
</header>
<label>
<span>文件名</span>
<input id="newNoteName" type="text" placeholder="例如:产品思路" autocomplete="off" />
</label>
<label>
<span>保存到</span>
<select id="newNoteFolder">
<option>收件箱</option>
<option>项目/Plainleaf</option>
<option>知识库/Docker</option>
<option>日记</option>
</select>
</label>
<footer>
<button class="secondary-button close-overlay-button" type="button">取消</button>
<button class="primary-button" id="createNoteButton" type="button">
<i data-lucide="file-plus-2"></i>
创建笔记
</button>
</footer>
</section>
</div>
<div class="overlay mobile-sheet-overlay" id="mobileSheetOverlay" aria-hidden="true">
<section class="mobile-sheet" role="dialog" aria-modal="true" aria-label="手机导航面板">
<div class="sheet-handle" aria-hidden="true"></div>
<header>
<h2 id="mobileSheetTitle">文件</h2>
<button class="icon-button" id="closeMobileSheet" type="button" title="关闭" aria-label="关闭">
<i data-lucide="x"></i>
</button>
</header>
<div id="mobileSheetContent"></div>
</section>
</div>
<div class="popover-menu" id="moreMenu" aria-hidden="true">
<button type="button" id="menuThemeButton"><i data-lucide="sun-moon"></i><span>切换主题</span></button>
<button type="button" id="menuOfflineButton"><i data-lucide="wifi-off"></i><span>模拟离线</span></button>
<button type="button"><i data-lucide="terminal"></i><span>命令面板</span><kbd>⌘ P</kbd></button>
<button type="button"><i data-lucide="history"></i><span>页面历史</span></button>
</div>
<script src="https://unpkg.com/lucide@0.468.0/dist/umd/lucide.min.js"></script>
<script src="./app.js"></script>
</body>
</html>
File diff suppressed because it is too large Load Diff
+85
View File
@@ -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);
+56
View File
@@ -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.
+49
View File
@@ -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.
+197
View File
@@ -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 |