632 lines
42 KiB
HTML
632 lines
42 KiB
HTML
<!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>
|