Files
Notes/index.html
2026-10-16 16:34:15 +08:00

632 lines
42 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<!--
lang="zh-CN" + dir="ltr"(Round 12 P2 audit):CJK 默认 ltr,但 a11y lint
通常要求显式 dir。Electron 桌面应用虽不像移动 web 那样卡 viewport 缩放,
但 viewport meta 仍然是为未来 web 模式合规铺路(P1 audit)。
-->
<html lang="zh-CN" dir="ltr">
<head>
<meta charset="UTF-8">
<!-- viewport meta (Round 12 P1 audit):桌面 Electron 也有 webview / web mode 兼容场景 -->
<meta name="viewport" content="width=device-width, initial-scale=1">
<!--
Content Security Policy (加固版 · audit P0-2):
- default-src 'self' 只允许加载同源资源
- style-src 'self' 'unsafe-inline' 允许 inline style(marked 渲染的代码块可能会用到)
- script-src 'self' 'sha256-XXX=' 仅同源 + importmap 内联块(hash 锁死)
- img-src 'self' data: file: 允许 markdown 内嵌图片(本地 + base64)
- object-src 'none' 禁止 <object>/<embed>/<applet>(插件 XSS 通道)
- base-uri 'none' 禁止 <base> 标签劫持相对 URL
- form-action 'none' 禁止 form 提交外发(即使有 <form> 也是死链)
关于 script-src:原 'unsafe-inline' 留下 XSS 后续路径(任何位置注入的
inline script 都被放行)。本应用唯一的内联脚本是 importmap(用于映射
CodeMirror 包),所以给 importmap inner content 算 SHA-256,写到 CSP
里精确放行它一个。改 importmap 必须重算 hash 并同步本行 —— 否则 renderer
启动会「Refused to execute inline script」/ importmap 失效 → 大量
「Failed to resolve module specifier」。测试 csp-importmap-hash.test.js
锁死这条契约。
⚠️ CRLF 坑(2026-10-16 实踩):本仓库所有源文件是 CRLF 换行,但浏览器
HTML tokenizer 在算 CSP hash 前会把脚本内容里的 \r\n / \r 归一化成 \n。
所以 CSP 里的 hash 必须是「CRLF→LF 归一化后」的 importmap 内容 SHA-256,
而不是 Node 直接读原始 CRLF 字节算出的 hash——两者不同(旧值 7pe/... 是
CRLF 字节 hash,浏览器实际算出来是 Fmcb...)。csp-importmap-hash.test.js
已改为先归一化换行再算 hash,与浏览器行为一致,避免再次静默漂移。
结论:永远用浏览器归一化后的 hash;改 importmap 后必须重算并同步本行。
-->
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'sha256-FmcbAaTwesLByPTe0spZSvAlhuyc5I6BR4OJt6o0fB0='; img-src 'self' data: file:; connect-src 'self'; object-src 'none'; base-uri 'none'; form-action 'none';">
<title>Notes</title>
<!--
Import map: 把 CodeMirror 6 及其传递依赖映射到本地 node_modules。
浏览器原生 ESM 不解析 package.json 的 exports 字段,所以必须写完整相对路径。
style-mod 的 ESM 在 src/ 而不是 dist/(dist/ 只有 CJS)—— 这是常见坑。
保留的条目是 src/ 直接 import + lang-markdown 间接需要的传递依赖 + basicSetup
顶层 import 拉进来的 search/autocomplete/lint:
- 直接 import:view / state / theme-one-dark / lang-markdown
- lang-markdown 间接(用于 markdown 内嵌 HTML/CSS/JS 代码块高亮):
lang-html / lang-css / lang-javascript / lezer/{html,css,javascript}
- basicSetup 顶层 import(codemirror/dist/index.js:1-8 用到):
search / autocomplete / lint
- 其他:commands / language / lezer/{common,highlight,markdown,lr}
/ marijn/find-cluster-break / style-mod / w3c-keyname / crelt
审计 T-7 曾把 search/autocomplete/lint + lang-{html,css,javascript} +
lezer-{html,css,javascript} 都当作"无引用"删掉 —— 但这些全是 side-effect
链:basicSetup 顶层 import 拉 search/autocomplete/lint;lang-markdown
顶层 import 拉 lang-html → 继续拉 lang-css / lang-javascript → 继续拉
lezer-{html,css,javascript}。markdown-editor.js 用了 basicSetup + lang-markdown,
这 9 条 importmap 条目都不能少,缺一个 renderer 立即
"Failed to resolve module specifier '@...'"。
-->
<script type="importmap">
{
"imports": {
"codemirror": "./node_modules/codemirror/dist/index.js",
"@codemirror/state": "./node_modules/@codemirror/state/dist/index.js",
"@codemirror/view": "./node_modules/@codemirror/view/dist/index.js",
"@codemirror/commands": "./node_modules/@codemirror/commands/dist/index.js",
"@codemirror/language": "./node_modules/@codemirror/language/dist/index.js",
"@codemirror/lang-markdown": "./node_modules/@codemirror/lang-markdown/dist/index.js",
"@codemirror/lang-html": "./node_modules/@codemirror/lang-html/dist/index.js",
"@codemirror/lang-css": "./node_modules/@codemirror/lang-css/dist/index.js",
"@codemirror/lang-javascript": "./node_modules/@codemirror/lang-javascript/dist/index.js",
"@codemirror/search": "./node_modules/@codemirror/search/dist/index.js",
"@codemirror/autocomplete": "./node_modules/@codemirror/autocomplete/dist/index.js",
"@codemirror/lint": "./node_modules/@codemirror/lint/dist/index.js",
"@codemirror/theme-one-dark": "./node_modules/@codemirror/theme-one-dark/dist/index.js",
"@lezer/common": "./node_modules/@lezer/common/dist/index.js",
"@lezer/highlight": "./node_modules/@lezer/highlight/dist/index.js",
"@lezer/markdown": "./node_modules/@lezer/markdown/dist/index.js",
"@lezer/html": "./node_modules/@lezer/html/dist/index.js",
"@lezer/css": "./node_modules/@lezer/css/dist/index.js",
"@lezer/javascript": "./node_modules/@lezer/javascript/dist/index.js",
"@lezer/lr": "./node_modules/@lezer/lr/dist/index.js",
"@marijn/find-cluster-break": "./node_modules/@marijn/find-cluster-break/src/index.js",
"style-mod": "./node_modules/style-mod/src/style-mod.js",
"w3c-keyname": "./node_modules/w3c-keyname/index.js",
"crelt": "./node_modules/crelt/index.js"
}
}
</script>
<!-- P2-8(audit):12 个分片直接 <link> 并列,替代 styles.css 单入口 + 12 条 @import。
@import 是「解析到才发现」的串行瀑布(styles.css 回来 → 才发现 01 → …);
12 个 <link> 在 HTML parse 时一次性全部发现,并行 fetch。
不用 audit 建议的 preload + onload=this.rel='stylesheet' 异步 hack ——
那是「不阻塞渲染」换 FOUC(无样式闪烁),应用外壳首屏白一下比 12 个
本地文件并行 fetch 的 ~10ms 更难看。link 仍阻塞渲染,但并行后总耗时
≈ 最慢一片,而非 12 片串行之和。
级联顺序 = 文档顺序,与原 @import 顺序严格一致(01 tokens 最先 … 12 print 最后)。
新增分片时:这里 + src/styles.css(tests/manual 页面仍走单入口)两处同步。 -->
<link rel="stylesheet" href="src/styles/01-tokens.css">
<link rel="stylesheet" href="src/styles/02-palettes.css">
<link rel="stylesheet" href="src/styles/03-base.css">
<link rel="stylesheet" href="src/styles/04-toolbar.css">
<link rel="stylesheet" href="src/styles/05-sidebar.css">
<link rel="stylesheet" href="src/styles/06-markdown.css">
<link rel="stylesheet" href="src/styles/07-statusbar.css">
<link rel="stylesheet" href="src/styles/08-modal.css">
<link rel="stylesheet" href="src/styles/09-misc.css">
<link rel="stylesheet" href="src/styles/10-a11y.css">
<link rel="stylesheet" href="src/styles/11-ai.css">
<link rel="stylesheet" href="src/styles/12-print.css">
</head>
<body data-theme="dark" data-theme-palette="default" data-focus-mode="false">
<!-- 聚焦模式顶边热区:触发时滑出工具栏 -->
<div class="focus-hotzone" aria-hidden="true"></div>
<!--
Skip link (Phase M a11y):
键盘用户 Tab 时第一站直达主内容,跳过工具栏 9 个按钮 + 侧栏若干入口。
默认视觉隐藏(不占布局),focus 时滑出右上角。target 是 #viewer-main,
- 标记 <main id="viewer-main" tabindex="-1">:让非交互元素也能 focus,
以承接 hash 跳转 + 屏幕阅读器宣告「进入主区域」。
-->
<a href="#viewer-main" class="skip-link">跳到主内容</a>
<!-- 启动错误兜底(window.api 不可用 / bootstrap 抛错时显示) -->
<!-- a11y #2(critical):role="alert" + aria-live="assertive" 让屏幕阅读器在出现
bootstrap 失败的瞬间立即播报,不会漏掉关键故障。hidden 时虽然 [hidden] 会
display:none,但 role/aria-live 仍声明「一旦显示就立即播报」。 -->
<div id="boot-error" hidden role="alert" aria-live="assertive">
<h2>⚠ 启动失败</h2>
<p>无法连接到主进程。请重启应用。</p>
<p class="boot-error-detail"></p>
</div>
<!-- 自定义标题栏:可拖拽 + 窗口控件 -->
<header class="toolbar">
<div class="toolbar-left drag-region">
<!-- 应用图标:由 preload 读 icon.ico 转 PNG data URL,渲染端注入 src。
hidden 直到 src load 成功,避免破图占位闪烁。src 失败 → 走 .app-title::before 占位 -->
<img id="app-icon" class="app-icon" alt="" draggable="false" hidden>
<!--
长文件名截断(fix:长文件名导致 toolbar 偏移):
- flex 容器里的直接文本节点 min-content 是整段文本,无法自然收缩;
必须把文本包到 .app-title-text 里,给它 min-width:0 + ellipsis 才能截断
- 渲染端(updateWindowTitle)写 textContent 到 #app-title-text;
没有这个 span 时退回 #app-title textContent(向后兼容)
-->
<span id="app-title" class="app-title"><span id="app-title-text" class="app-title-text">Notes</span></span>
</div>
<div class="toolbar-right no-drag">
<!--
搜索文件入口(设计优化 2026-09):
- 从侧栏上移到底部顶栏首位,腾出侧栏垂直空间给文件列表
- 紧贴侧栏上方,视觉上明确"作用于侧栏"
- flex:0 1 240px + min-width:160px:窗口过窄时让搜索框先收缩,
不挤掉右侧窗口控制按钮
-->
<div class="toolbar-search">
<svg aria-hidden="true" class="search-icon" viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="7"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
</svg>
<!-- aria-controls="file-list":让屏幕阅读器在输入时宣告"过滤文件列表",
也让用户知道输入框控制的列表元素 id(audit P2-22)。
仅声明关系,不改变视觉/键盘行为。 -->
<input
type="search"
id="search-input"
class="search-input"
placeholder="搜索文件..."
aria-label="搜索文件"
aria-controls="file-list"
>
</div>
<span class="toolbar-divider" aria-hidden="true"></span>
<!-- 应用组:主题 + 置顶 -->
<div class="toolbar-group">
<div class="btn-group" role="group" aria-label="外观">
<button class="btn-icon btn-group-item" id="btn-theme" title="切换主题 (当前: 暗色主题)" aria-label="切换主题" data-shortcut="Ctrl+Shift+T">
<svg class="theme-icon theme-icon-sun" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="5"></circle>
<line x1="12" y1="1" x2="12" y2="3"></line>
<line x1="12" y1="21" x2="12" y2="23"></line>
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line>
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line>
<line x1="1" y1="12" x2="3" y2="12"></line>
<line x1="21" y1="12" x2="23" y2="12"></line>
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line>
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line>
</svg>
<svg class="theme-icon theme-icon-moon" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path>
</svg>
</button>
<button class="btn-icon btn-group-item" id="btn-pin" title="始终置顶" aria-label="始终置顶">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<line x1="12" y1="17" x2="12" y2="22"></line>
<path d="M5 17h14v-1.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V6h-6v4.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V17z"></path>
</svg>
</button>
</div>
</div>
<span class="toolbar-divider" aria-hidden="true"></span>
<!-- 视图组:预览 / 编辑 / 双栏(图标 + 文字按钮,比纯图标更易识别) -->
<div class="toolbar-group">
<div class="btn-group btn-group-text" role="group" aria-label="视图模式">
<button class="btn-icon-text btn-group-item" id="btn-mode-preview" title="预览模式 (Ctrl+E 切换)" aria-label="预览模式" aria-pressed="true" data-shortcut="Ctrl+E">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path>
<circle cx="12" cy="12" r="3"></circle>
</svg>
<span class="btn-label">预览</span>
</button>
<button class="btn-icon-text btn-group-item" id="btn-mode-edit" title="编辑模式 (Ctrl+E 切换)" aria-label="编辑模式" aria-pressed="false" data-shortcut="Ctrl+E">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M12 20h9"></path>
<path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"></path>
</svg>
<span class="btn-label">编辑</span>
</button>
<button class="btn-icon-text btn-group-item" id="btn-mode-split" title="双栏模式 (Ctrl+E 切换)" aria-label="双栏模式" aria-pressed="false" data-shortcut="Ctrl+E">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect x="3" y="3" width="18" height="18" rx="2"></rect>
<line x1="12" y1="3" x2="12" y2="21"></line>
</svg>
<span class="btn-label">双栏</span>
</button>
</div>
</div>
<span class="toolbar-divider" aria-hidden="true"></span>
<!-- 布局组:恢复默认侧栏宽度 + 双栏比例 -->
<div class="toolbar-group" id="tg-reset-layout">
<button class="btn-icon" id="btn-reset-layout" title="重置布局(恢复默认侧栏宽度 + 双栏比例)" aria-label="重置布局">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect x="3" y="3" width="18" height="18" rx="2"></rect>
<line x1="12" y1="3" x2="12" y2="21"></line>
</svg>
</button>
</div>
<span class="toolbar-divider" aria-hidden="true"></span>
<!-- 行为组:自动保存开关(贴近编辑器,作用在编辑器 → 放顶部工具栏;见 [[feedback-prefer-near-affected-area]])。
iOS pill switch 替代原先的「按钮 + status dot」,让开关语义更明显。
role="switch" 让屏幕阅读器读「开关 开/关」而非「按钮 按/未按」。
初始 aria-pressed="true" 与 settings-schema DEFAULT_SETTINGS.autoSaveDebounceMs=500 一致,
避免首帧 FOUC 闪一下「关 → 开」(audit D1)。 -->
<div class="toolbar-group">
<button class="btn-icon-text toolbar-toggle" id="toolbar-autosave" type="button"
title="自动保存:开启(停打后 500ms 保存)· 点击关闭"
aria-label="自动保存"
aria-pressed="true"
role="switch">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="9"></circle>
<polyline points="12 7 12 12 15 14"></polyline>
</svg>
<span class="btn-label">自动保存</span>
<span class="toggle-switch" aria-hidden="true">
<span class="toggle-switch-track"></span>
<span class="toggle-switch-thumb"></span>
</span>
</button>
</div>
<span class="toolbar-divider" aria-hidden="true"></span>
<!-- 操作组:保存 + 关闭当前文件 + 设置 -->
<div class="toolbar-group">
<!--
保存按钮始终显示在工具栏;需要保存(脏)时进入 is-dirty 提醒态(warning 色 + 脉动)。
见 [[feedback-save-button-always-visible]]。
-->
<button class="btn-icon" id="btn-save" title="保存 (Ctrl+S)" aria-label="保存" data-shortcut="Ctrl+S">
<svg aria-hidden="true" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"></path>
<polyline points="17 21 17 13 7 13 7 21"></polyline>
<polyline points="7 3 7 8 15 8"></polyline>
</svg>
</button>
<!--
关闭当前文件:仅清空视图,不删磁盘文件。disabled 态走 .btn-icon 现有规则
(fg-tertiary + opacity 0.4 + cursor not-allowed);无文件时由 syncCloseFileButton
切到 disabled,HTML 默认 disabled 保证首帧 FOUC 也对。
与右上角窗口 ×(id=btn-close)区分:那个关的是 BrowserWindow,这个关的是「当前打开的文件」。
-->
<button class="btn-icon" id="btn-close-file" type="button" title="关闭当前文件(无打开文件)" aria-label="关闭当前文件(无打开文件)" aria-disabled="true" disabled>
<svg aria-hidden="true" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<!-- Lucide file-x 风格:文档 + 右上角 × -->
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
<polyline points="14 2 14 8 20 8"></polyline>
<line x1="9.5" y1="13" x2="14.5" y2="18"></line>
<line x1="14.5" y1="13" x2="9.5" y2="18"></line>
</svg>
</button>
<button class="btn-icon" id="btn-settings" title="设置 (Ctrl+,)" aria-label="设置" data-shortcut="Ctrl+,">
<svg aria-hidden="true" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="3"></circle>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
</svg>
</button>
</div>
<span class="toolbar-divider" aria-hidden="true"></span>
<!-- 窗口组:最小化 / 最大化 / 关闭 -->
<div class="toolbar-group">
<div class="window-controls">
<button class="btn-window" id="btn-min" title="最小化" aria-label="最小化">
<svg aria-hidden="true" viewBox="0 0 10 10" width="10" height="10"><line x1="0" y1="5" x2="10" y2="5" stroke="currentColor" stroke-width="1"/></svg>
</button>
<button class="btn-window" id="btn-max" title="最大化" aria-label="最大化">
<svg aria-hidden="true" viewBox="0 0 10 10" width="10" height="10"><rect x="0.5" y="0.5" width="9" height="9" fill="none" stroke="currentColor" stroke-width="1"/></svg>
</button>
<button class="btn-window btn-close" id="btn-close" title="关闭" aria-label="关闭">
<svg aria-hidden="true" viewBox="0 0 10 10" width="10" height="10"><line x1="0" y1="0" x2="10" y2="10" stroke="currentColor" stroke-width="1"/><line x1="10" y1="0" x2="0" y2="10" stroke="currentColor" stroke-width="1"/></svg>
</button>
</div>
</div>
</div>
</header>
<!-- 主区:侧边栏 + (可选编辑器 + 分割条) + 阅读区 -->
<!--
id="app" 是 modal.js:126 背景 inert 隔离机制的锚点:modal 打开时给 #app
打 inert 让背景 DOM(侧栏/工具栏/状态栏)失活,避免键盘 Tab 跳出 modal、
屏幕阅读器朗读背景。HTML 里必须显式存在该 id,否则 getElementById 返回
null,整套 inert 隔离静默失效(Round 12 P0 audit 发现)。
不要把 inert 挂在 body —— 那会让 modal 自身也失活;#app 是 app-shell,
跟 modal-root(独立容器)平级。
-->
<div class="app-shell" id="app">
<aside class="sidebar" id="sidebar" aria-label="文件与目录">
<!-- 搜索框已上移到工具栏(见 .toolbar-search),侧栏不再保留 header。
排序工具条作为首屏唯一入口,垂直空间专注于文件列表 -->
<!-- 排序工具条:紧贴它作用的列表,避免埋进设置对话框(见 [[feedback-prefer-near-affected-area]])。
新建文件 / 新建文件夹已挪到右键菜单(侧栏空白右键、文件夹条目右键),
侧栏首屏只剩「排序工具条」一行,主操作 + 列表工具就近成组,视觉密度更紧凑。
排序模式改用下拉:触发器显示当前排序 + 向下箭头,点击展开 4 项菜单
(名称 A→Z / Z→A、修改时间 新→旧 / 旧→新)。多选项用下拉比并排按钮更紧凑,
加新排序时只要往 SETTINGS_UI_OPTIONS.fileListSort 加一项。 -->
<div class="sidebar-sort" role="group" aria-label="文件列表排序">
<div class="sidebar-sort-dropdown" id="sidebar-sort-dropdown">
<button id="btn-sort-trigger" type="button" class="sidebar-sort-trigger" aria-haspopup="menu" aria-expanded="false" aria-controls="sidebar-sort-menu" title="点击切换排序方式">
<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M3 6h13M3 12h9M3 18h5"></path>
<path d="M19 4v16M16 17l3 3 3-3"></path>
</svg>
<span class="sidebar-sort-trigger-label" id="sidebar-sort-trigger-label">排序:按名称</span>
<svg class="sidebar-sort-trigger-chevron" viewBox="0 0 24 24" width="11" height="11" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<polyline points="6 9 12 15 18 9"></polyline>
</svg>
</button>
<ul class="sidebar-sort-menu" id="sidebar-sort-menu" role="menu" hidden>
<!-- 4 个选项互斥单选(同一时刻只有一种 sort),
role="menuitemradio" + aria-checked 是 ARIA 1.1 标准模式,
比 menuitem 更准(menuitem 用于「执行动作」的项) -->
<li role="menuitemradio" class="sidebar-sort-menu-item" data-sort="name" tabindex="-1" aria-checked="true">
<span class="sidebar-sort-menu-check" aria-hidden="true">
<svg viewBox="0 0 24 24" width="11" height="11" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>
</span>
<span class="sidebar-sort-menu-text">按名称<span class="sidebar-sort-menu-hint">A → Z</span></span>
</li>
<li role="menuitemradio" class="sidebar-sort-menu-item" data-sort="name-desc" tabindex="-1" aria-checked="false">
<span class="sidebar-sort-menu-check" aria-hidden="true">
<svg viewBox="0 0 24 24" width="11" height="11" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>
</span>
<span class="sidebar-sort-menu-text">按名称<span class="sidebar-sort-menu-hint">Z → A</span></span>
</li>
<!-- 分组分隔:「名称」与「修改时间」两类排序之间加 1px line —— 选项一多时
视觉分组让扫读速度显著提升;separator role 给读屏「这两组」语义 -->
<li role="separator" class="sidebar-sort-menu-separator" aria-hidden="true"></li>
<li role="menuitemradio" class="sidebar-sort-menu-item" data-sort="mtime-desc" tabindex="-1" aria-checked="false">
<span class="sidebar-sort-menu-check" aria-hidden="true">
<svg viewBox="0 0 24 24" width="11" height="11" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>
</span>
<span class="sidebar-sort-menu-text">按修改时间<span class="sidebar-sort-menu-hint">新 → 旧</span></span>
</li>
<li role="menuitemradio" class="sidebar-sort-menu-item" data-sort="mtime-asc" tabindex="-1" aria-checked="false">
<span class="sidebar-sort-menu-check" aria-hidden="true">
<svg viewBox="0 0 24 24" width="11" height="11" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>
</span>
<span class="sidebar-sort-menu-text">按修改时间<span class="sidebar-sort-menu-hint">旧 → 新</span></span>
</li>
</ul>
</div>
</div>
<!-- Folder Browser(Stage 8):面包屑 + 「回到根」按钮,贴近它作用的文件列表。
根目录时整段 hidden,避免空栏占位。渲染端控制 .sidebar-breadcrumb[data-rel-dir] 与各段按钮 -->
<nav id="sidebar-breadcrumb" class="sidebar-breadcrumb" aria-label="当前目录" hidden>
<button id="btn-breadcrumb-root" type="button" class="sidebar-breadcrumb-root" title="返回数据文件夹根目录" aria-label="返回根目录">
<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M3 12L12 4l9 8"></path>
<path d="M5 10v9h14v-9"></path>
</svg>
<span>根</span>
</button>
<span class="sidebar-breadcrumb-sep" aria-hidden="true">/</span>
<span id="sidebar-breadcrumb-trail" class="sidebar-breadcrumb-trail"></span>
</nav>
<ul id="file-list" class="file-list" role="listbox" aria-label="文件列表" tabindex="-1" data-empty="此文件夹为空">
<!-- 文件项由 file-list.js 注入 -->
</ul>
<div class="sidebar-footer">
<!-- AI 入口:侧栏唯一保留的"次级主操作"按钮。
文件夹管理(打开/切换/回到默认)已搬到状态栏左段(见 [[feedback-folder-actions-to-statusbar]]),
与 status-path 组成「当前位置 + 操作」语义组;状态栏只有 38px 高,
文字版挤在侧栏底部会浪费列表空间,现在侧栏更"纯净"。 -->
<button id="btn-ai" class="sidebar-new-btn" type="button" title="AI 修改 (Ctrl+Shift+A)" aria-label="AI 修改" aria-pressed="false">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M12 2a4 4 0 0 0-4 4v1H6a3 3 0 0 0-3 3v9a3 3 0 0 0 3 3h12a3 3 0 0 0 3-3v-9a3 3 0 0 0-3-3h-2V6a4 4 0 0 0-4-4z"></path>
<circle cx="9" cy="13" r="1.2" fill="currentColor" stroke="none"></circle>
<circle cx="15" cy="13" r="1.2" fill="currentColor" stroke="none"></circle>
<path d="M9 17h6"></path>
</svg>
<span>AI 修改</span>
</button>
</div>
</aside>
<!-- 侧栏拖拽分割条(拖动调侧栏宽度)。
视觉宽度 4px,命中区通过 -4px margin 扩到 12px。
内嵌 ↺ 按钮:hover splitter 时显形,点击把侧栏恢复到 CSS 默认宽度(220px),
并写回 sidebarWidth=null 让下次启动也走默认。 -->
<div id="sidebar-splitter" class="sidebar-splitter" role="separator" aria-orientation="vertical" aria-label="拖动调整侧栏宽度" aria-controls="sidebar" title="拖动调整侧栏宽度">
<button id="btn-sidebar-reset-width" class="sidebar-splitter-reset" type="button" title="恢复默认侧栏宽度" aria-label="恢复默认侧栏宽度" tabindex="-1">
<svg viewBox="0 0 24 24" width="11" height="11" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M3 12a9 9 0 1 0 3-6.7L3 8"></path>
<polyline points="3 3 3 8 8 8"></polyline>
</svg>
</button>
</div>
<div id="editor-pane" class="editor-pane">
<!-- CodeMirror 挂载到这里 -->
</div>
<!-- fix(audit 2026-08):#splitter 加 ARIA separator role,键盘用户能感知
可拖拽边界(与 #sidebar-splitter / #ai-splitter 一致) -->
<div id="splitter" class="splitter" role="separator" aria-orientation="vertical" aria-label="拖动调整编辑器与阅读视图宽度" aria-controls="editor-pane viewer-main"></div>
<!--
重要:CSS Grid auto-placement 按 DOM 顺序处理。
.app-ai (grid-column:5) / .ai-splitter (grid-column:6) 都是显式钉位,
.viewer 是 auto。
若 .viewer 出现在 .app-ai 之前,DOM 处理时 .viewer 会先占 col 5,
.app-ai 显式 col 5 与 .viewer 冲突、被挤到 row 2(错位)。
所以 .app-ai / .ai-splitter 必须在 .viewer 之前。
此外 .app-ai 必须先于 .ai-splitter —— cursor 处理到 .app-ai (col 5)
后再前进到 col 6,.ai-splitter (col 6) 顺势放下,最后 .viewer
auto 落到 col 7;倒过来 .ai-splitter 先显式占 col 6,.app-ai 再
显式占 col 5,cursor 被推到 col 7,.viewer 反过来覆盖 .app-ai、
把 .app-ai 挤到 row 2(实测验证)。
-->
<!--
AI 修改差异中间面板:
- 默认 hidden,由 ai-controller 切换 body.dataset.aiDiffOpen 控制可见性
- 渲染走 ai-diff-panel.js,与 viewer 共享 markdown-body 渲染管线
- 必须在 #ai-splitter 之前(CSS Grid auto-placement 按 DOM 顺序处理,
app-ai 显式 grid-column:5 必须先于 viewer 处理,详见 splitter 上方注释)
-->
<aside id="app-ai" class="app-ai" hidden aria-label="AI 修改预览"></aside>
<!--
AI 拖动分割条(拖动调整 AI 中间面板宽度)。
与 #splitter / #sidebar-splitter 同设计:视觉 4px,命中区由 margin 扩展。
由 body[data-ai-diff-open="true"] 显示;与 .app-ai 紧邻。
- 必须在 .app-ai 之后、.viewer 之前(CSS Grid auto-placement:
app-ai col 5 放好后,ai-splitter col 6 用光 row 1 的中段,
viewer 才能 auto 落到 col 7;倒过来顺序会让 ai-splitter 把 col 5
让给 viewer,app-ai 显式 col 5 与 viewer 冲突、被挤到 row 2)。
-->
<div id="ai-splitter" class="ai-splitter" role="separator" aria-orientation="vertical" aria-label="拖动调整 AI 面板宽度" aria-controls="app-ai" title="拖动调整 AI 面板宽度" hidden></div>
<main class="viewer" id="viewer-main" tabindex="-1">
<!-- 阅读进度条:紧贴阅读区顶部,随滚动从左向右填充。
视觉上把「当前读到哪」从状态栏的百分比文本升级成一条细线,
比数字更直觉;stored 在 .viewer 滚动容器里,滚动时钉在顶部不随内容走。 -->
<div class="reading-progress" id="reading-progress" aria-hidden="true"><span class="reading-progress-bar" id="reading-progress-bar"></span></div>
<article id="markdown-body" class="markdown-body" hidden>
<!-- markdown 渲染输出 -->
</article>
<div id="empty-state" class="empty-state">
<div class="empty-state-icon">
<svg aria-hidden="true" viewBox="0 0 24 24" width="36" height="36" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
<polyline points="14 2 14 8 20 8"></polyline>
<line x1="9" y1="13" x2="15" y2="13"></line>
<line x1="9" y1="17" x2="15" y2="17"></line>
</svg>
</div>
<p>选择一个 Markdown 文件开始阅读</p>
<p class="hint">
把 <code>.md</code> 文件放入数据文件夹后,<br>
会自动出现在左侧列表。
</p>
<!-- 空状态 CTA:让"还没动手"的用户有一个最直接的下一步。
与 .sidebar-new-btn 共享配色 token,但不复用 width:100% —— 这是居中布局,
紧凑的「auto width」按钮视觉重心更聚焦;hover/active 反馈继承全局 .btn 语义。 -->
<button id="empty-state-new-btn" class="empty-state-cta" type="button" aria-label="新建笔记 (Ctrl+N)">
<svg aria-hidden="true" viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="12" y1="5" x2="12" y2="19"></line>
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
<span>新建笔记</span>
</button>
<p class="hint hint-secondary">
<kbd>Ctrl</kbd>+<kbd>N</kbd> 新建笔记 · <kbd>Ctrl</kbd>+<kbd>F</kbd> 搜索<br>
底部状态栏可切换数据文件夹
</p>
</div>
</main>
</div>
<!--
AI 对话输入面板(底部 dock):
- 位于 body grid 第 3 行(app-shell 与 statusbar 之间),
由 03-base.css 的 .ai-chat-dock { grid-row: 3 } 显式钉位
- 默认 hidden,点工具栏 btn-ai 展开;展开时挤占 app-shell 高度,
状态栏自动让位、始终钉底
- 与中部 diff 面板联动(diff 由 ai-controller 写入 .app-ai)
-->
<div class="ai-chat-dock" id="ai-chat-dock" hidden aria-label="AI 修改输入">
<div class="ai-chat-form-row">
<button class="ai-chat-toggle" type="button" title="关闭 AI 面板" aria-label="关闭 AI 面板" aria-controls="ai-chat-dock">×</button>
<div class="ai-chat-input-wrap">
<input
type="text"
class="ai-chat-input"
placeholder="描述你想如何修改当前文档..."
aria-label="AI 修改提示"
autocomplete="off"
autocapitalize="off"
spellcheck="false"
/>
<!-- P2-23:未配置时显示,点击直接打开设置对话框(onOpenSettings 回调) -->
<button class="ai-chat-configure-link" type="button" hidden>⚙ 设置</button>
</div>
<button class="ai-chat-submit" type="button" data-mode="submit" disabled>生成修改</button>
</div>
</div>
<!-- 状态栏:3 段式(路径弱化 / 字数核心 / 模式 + 进度 + 文件数)
segment 之间用 .status-spacer 推开,让眼睛先扫到核心信息。
状态栏左段同时承担「数据文件夹管理」入口(打开/切换/回到默认),
见 [[feedback-folder-actions-to-statusbar]] —— icon-only 紧凑按钮,
紧贴 status-path,与路径组成「当前位置 + 操作」语义组。 -->
<footer class="statusbar">
<div class="status-segment status-segment-left">
<button id="status-path" class="status-path status-path-clickable" type="button" title="在文件管理器中显示" aria-label="在文件管理器中显示当前文件">—</button>
<span class="status-divider"></span>
<button type="button" id="link-open-folder" class="status-folder-btn" title="在系统文件管理器中打开数据文件夹" aria-label="在系统文件管理器中打开数据文件夹">
<svg aria-hidden="true" viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path>
</svg>
</button>
<button type="button" id="btn-switch-folder" class="status-folder-btn" title="切换到其他数据文件夹" aria-label="切换到其他数据文件夹">
<!-- folder + 双向 swap 箭头 (Lucide folder-sync 风格) -->
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1-2-2h5l2 3h9a2 2 0 0 1 2 2z"></path>
<polyline points="16 3 19 3 19 6"></polyline>
<polyline points="8 21 5 21 5 18"></polyline>
<line x1="19" y1="3" x2="13" y2="9"></line>
<line x1="5" y1="21" x2="11" y2="15"></line>
</svg>
</button>
<!-- auto-fallback 2026-08:「回到默认」按钮 —— 清空持久化的 dataDir 并切回 ~/Notes。
始终显示:用户切到非默认目录后想回到默认不需要先「切换」再选;救命的入口
不能藏在条件分支里。当已经在默认文件夹时按钮可点(toast 提示「已在默认」)。 -->
<button type="button" id="btn-reset-folder" class="status-folder-btn" title="回到默认数据文件夹(~/Notes)" aria-label="回到默认数据文件夹(~/Notes)">
<!-- home + 屋顶(表达「回到原始默认」) -->
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M3 12l9-9 9 9"></path>
<path d="M5 10v10h14V10"></path>
</svg>
</button>
<span class="status-divider"></span>
<span id="status-meta" class="status-meta"></span>
</div>
<div class="status-spacer"></div>
<div class="status-segment status-segment-center">
<span id="status-stats" class="status-chip status-chip-stats" hidden></span>
<span id="status-heading" class="status-heading" hidden></span>
</div>
<div class="status-spacer"></div>
<div class="status-segment status-segment-right">
<span id="status-scroll" class="status-chip status-chip-muted" hidden>0%</span>
<button id="status-mode" class="status-chip status-chip-clickable status-chip-mode" data-mode="preview" type="button" title="切换视图模式 (Ctrl+E)" aria-label="切换视图模式(当前:预览)">
<svg viewBox="0 0 24 24" width="11" height="11" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<polyline points="17 1 21 5 17 9"></polyline>
<path d="M3 11V9a4 4 0 0 1 4-4h14"></path>
<polyline points="7 23 3 19 7 15"></polyline>
<path d="M21 13v2a4 4 0 0 1-4 4H3"></path>
</svg>
<span id="status-mode-label">预览</span>
</button>
<button id="status-ai" class="status-chip status-chip-clickable status-chip-ai" data-ai-state="idle" type="button" title="AI 修改" aria-label="AI 修改" aria-live="polite">
<span id="status-ai-label" class="status-chip-ai-text">AI</span>
</button>
<!-- 未保存指示器(prefers-reduced-motion 下也保留,比 toolbar is-dirty 按钮更显眼)
aria-live=polite 让屏幕阅读器在 dirty 状态切换时朗读「未保存」(Phase N a11y 修复)。-->
<span id="status-unsaved" class="status-chip status-chip-unsaved" hidden aria-live="polite">未保存</span>
<span class="status-divider"></span>
<span id="status-count" class="status-count"></span>
</div>
</footer>
<!-- Toast 容器 -->
<div id="toast-container" class="toast-container" aria-live="polite"></div>
<!-- 设置对话框模板(由 settings-dialog.js 复用填充) -->
<div id="modal-root"></div>
<!-- 聚焦模式退出提示:鼠标悬停时显出,按 Ctrl+Shift+F 退出 -->
<div class="focus-exit-hint" aria-hidden="true">按 <kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>F</kbd> 退出聚焦</div>
<script type="module" src="src/app.js"></script>
</body>
</html>