diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1ea63b0..fa9016c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,12 @@
当前版本:1.5.0。以下按版本记录主要变更,当前行为以 README 和使用说明为准。
+## 外观设置
+
+- 新增外观设置:石墨紫、深海蓝、松林绿、暖白四套配色,支持快捷 / 自选强调色。
+- 支持 12–16 px 数据字号、紧凑 / 舒适表格密度和隔行底色;即时生效、本地记忆,并可一键恢复默认。
+- 表格、数据树、详情窗口和编辑标记统一使用主题颜色;字体变化时调整当前表格列宽。
+
## 1.5.0 后续优化
- 鼠标悬停字段整行后按 F2 直接编辑;表格按鼠标所在单元格定位,无需先点击数值。
diff --git a/README.md b/README.md
index c98e618..f5cd06a 100644
--- a/README.md
+++ b/README.md
@@ -56,6 +56,7 @@ Chrome DevTools 中的 JSON 接口调试工具。把响应展开成可搜索的
| 请求复现 | 请求详情、请求体 JSON、Bash / WSL / Git Bash 格式 cURL |
| 数据导出 | 原始响应、当前节点、全部筛选和排序后的 CSV / Markdown / JSON |
| 数值精度 | 普通超安全整数字面量精确保留,贯穿浏览、搜索、排序、复制和 JSON 导出 |
+| 个性化外观 | 四套配色、自选强调色、数据字号、表格密度和隔行底色;本地记忆,一键恢复默认 |
| 面板布局 | 独立收起左侧列表、拖动分栏、统一滚动条、布局与分页偏好记忆 |
表头字段保持单行,内容自动列宽通常为 88–380 px;手动列宽通常最高 960 px,超长表头可突破上限。长内容省略展示,隐藏列不影响导出。
diff --git a/appearance.js b/appearance.js
new file mode 100644
index 0000000..9c6f47f
--- /dev/null
+++ b/appearance.js
@@ -0,0 +1,67 @@
+// Apply saved colors before the first paint; appearance never changes captured data.
+const Appearance=(()=>{
+ const storageKey='api-table-viewer.appearance.v1';
+ const themes={graphite:{background:'#181a20',accent:'#8b9dff'},ocean:{background:'#171e28',accent:'#4ea1ff'},forest:{background:'#14201d',accent:'#52c9a5'},paper:{background:'#f3f5f9',accent:'#4f5bd5'}};
+ const defaults={theme:'graphite',accent:themes.graphite.accent,fontSize:13,density:'comfortable',striped:true};
+ function normalize(value){
+ const data=value && typeof value==='object'?value:{};
+ const theme=Object.hasOwn(themes,data.theme)?data.theme:defaults.theme;
+ return {theme,accent:typeof data.accent==='string' && /^#[\da-f]{6}$/i.test(data.accent)?data.accent:themes[theme].accent,
+ fontSize:[12,13,14,15,16].includes(data.fontSize)?data.fontSize:defaults.fontSize,
+ density:data.density==='compact'?'compact':'comfortable',striped:typeof data.striped==='boolean'?data.striped:true};
+ }
+ let prefs={...defaults};
+ try{prefs=normalize(JSON.parse(localStorage.getItem(storageKey)));}catch{}
+ function luminance(color){
+ const rgb=color.match(/[\da-f]{2}/gi).map(c=>parseInt(c,16)/255).map(c=>c<=.04045?c/12.92:((c+.055)/1.055)**2.4);
+ return rgb[0]*.2126+rgb[1]*.7152+rgb[2]*.0722;
+ }
+ function readableAccent(color,background){
+ const bg=luminance(background),contrast=c=>(Math.max(luminance(c),bg)+.05)/(Math.min(luminance(c),bg)+.05);
+ if(contrast(color)>=4.5)return color;
+ const rgb=color.match(/[\da-f]{2}/gi).map(c=>parseInt(c,16));
+ const end=bg>.179?0:255;
+ for(let step=1;step<=20;step++){
+ const mixed='#'+rgb.map(c=>Math.round(c+(end-c)*step/20).toString(16).padStart(2,'0')).join('');
+ if(contrast(mixed)>=4.5)return mixed;
+ }
+ return end?'#ffffff':'#000000';
+ }
+ function apply(){
+ const root=document.documentElement;
+ root.dataset.theme=prefs.theme;root.dataset.density=prefs.density;root.dataset.striped=String(prefs.striped);
+ root.style.setProperty('--blue',readableAccent(prefs.accent,themes[prefs.theme].background));
+ root.style.setProperty('--blue2',prefs.accent);
+ root.style.setProperty('--data-font-size',prefs.fontSize+'px');
+ }
+ apply();
+ function mount(){
+ const menu=document.getElementById('appearanceMenu'),status=document.getElementById('appearanceStatus');
+ const color=document.getElementById('appearanceAccent'),font=document.getElementById('appearanceFont'),density=document.getElementById('appearanceDensity'),striped=document.getElementById('appearanceStriped');
+ const palettes=[...menu.querySelectorAll('[data-theme-choice]')],accents=[...menu.querySelectorAll('[data-accent-choice]')];
+ function sync(){
+ color.value=prefs.accent;font.value=String(prefs.fontSize);density.value=prefs.density;striped.checked=prefs.striped;
+ for(const button of palettes)button.setAttribute('aria-pressed',String(button.dataset.themeChoice===prefs.theme));
+ for(const button of accents)button.setAttribute('aria-pressed',String(button.dataset.accentChoice.toLowerCase()===prefs.accent.toLowerCase()));
+ document.getElementById('appearanceAccentValue').textContent=prefs.accent.toUpperCase();
+ }
+ function update(changes){
+ const previousFont=prefs.fontSize;prefs=normalize({...prefs,...changes});apply();sync();
+ try{localStorage.setItem(storageKey,JSON.stringify(prefs));status.textContent='已保存,下次打开继续使用';}
+ catch{status.textContent='已应用,本次设置无法保存';}
+ if(previousFont!==prefs.fontSize)document.dispatchEvent(new CustomEvent('appearancechange'));
+ }
+ for(const button of palettes)button.onclick=()=>update({theme:button.dataset.themeChoice,accent:themes[button.dataset.themeChoice].accent});
+ for(const button of accents)button.onclick=()=>update({accent:button.dataset.accentChoice});
+ color.oninput=()=>update({accent:color.value});font.onchange=()=>update({fontSize:Number(font.value)});
+ density.onchange=()=>update({density:density.value});striped.onchange=()=>update({striped:striped.checked});
+ document.getElementById('appearanceReset').onclick=()=>update(defaults);
+ document.getElementById('appearanceClose').onclick=()=>close();
+ function close(){menu.open=false;document.getElementById('appearanceToggle').focus();}
+ document.addEventListener('pointerdown',e=>{if(menu.open && !menu.contains(e.target))menu.open=false;});
+ document.addEventListener('keydown',e=>{if(e.key==='Escape' && menu.open){e.preventDefault();e.stopPropagation();close();}},true);
+ sync();
+ }
+ if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',mount,{once:true});else mount();
+ return {get fontSize(){return prefs.fontSize;}};
+})();
diff --git a/docs/TESTING.md b/docs/TESTING.md
index 24a67d8..e5ead98 100644
--- a/docs/TESTING.md
+++ b/docs/TESTING.md
@@ -15,6 +15,7 @@ npm test
| `npm run test:fixtures` | 本地 HTTP 接口与 Worker 联动,包括节点阈值、50,000 条记录、精确整数和长文本 |
| `npm run test:preview` | 示例 HAR、预览 HTTP 路由,以及生成预览的脚本语法和 HTML 标识 |
| `npm run test:fields` | 15 项原位字段 Mock 检查:精确整数、类型、数组定位、大响应小补丁、Fetch / XHR、启动请求、会话恢复、站点隔离与 Worker 路径 |
+| `npm run test:appearance` | 5 项外观检查:设置持久化与恢复默认、异常配置回退、强调色可读性、面板关闭与焦点恢复、字号变化后调整列宽 |
| `npm run test:mock` | 18 项:Fetch / XHR 拦截、精确匹配、单次并发、取消、类型、规则更新、租约、面板控制和 HTTP 页面联动 |
回归测试在 Node.js 中运行 Worker 代码,通过模拟 DOM 和 DevTools 对象检查界面逻辑。接口测试使用随机端口,结束后自动关闭服务。2026-10-20 的自动检查结果:78 项回归、静态检查、8 个接口子案例通过。
diff --git a/docs/USAGE.md b/docs/USAGE.md
index 6bf0bc1..7c8c62d 100644
--- a/docs/USAGE.md
+++ b/docs/USAGE.md
@@ -71,3 +71,7 @@ $.data[0]["field.name"]
## 复制详情文本
进入字符串或其他简单值的详情后,内容区顶部显示“复制内容”。字符串按原文复制,保留真实换行和引号,不额外添加 JSON 外层引号或转义;即使正在查看某个万字符分段,也复制当前节点的全部内容。数字(包括超安全整数)、布尔和 null 使用对应的 JSON 数值文本。原有顶部“复制完整数据”继续复制 JSON 表示,便于保留类型。
+
+## 外观设置
+
+右上角“外观设置”可以选择石墨紫、深海蓝、松林绿或暖白,并自选强调色。数据字号为 12–16 px,表格可切换紧凑 / 舒适密度,隔行底色可单独关闭。选择即时生效,下次打开继续使用;“恢复默认”只重置外观,不清除接口记录或 Mock 规则。
diff --git a/package.json b/package.json
index c80c56e..fade8f5 100644
--- a/package.json
+++ b/package.json
@@ -9,13 +9,14 @@
},
"scripts": {
"preview": "node tests/preview/server.cjs",
- "test": "node tests/regression.cjs && node tests/package-check.cjs && npm run test:fixtures && npm run test:mock && npm run test:preview && npm run test:fields",
+ "test": "node tests/regression.cjs && node tests/package-check.cjs && npm run test:fixtures && npm run test:mock && npm run test:preview && npm run test:fields && npm run test:appearance",
"test:regression": "node tests/regression.cjs",
"test:package": "node tests/package-check.cjs",
"test:manual": "node tests/manual/server.cjs",
"test:fixtures": "node --test tests/manual/check.cjs",
"test:preview": "node --test tests/preview/check.cjs",
"test:mock": "node --test tests/response-mock.cjs",
- "test:fields": "node --test tests/field-mock.cjs"
+ "test:fields": "node --test tests/field-mock.cjs",
+ "test:appearance": "node --test tests/appearance.cjs"
}
}
diff --git a/panel.html b/panel.html
index 53023be..65a7397 100644
--- a/panel.html
+++ b/panel.html
@@ -5,12 +5,36 @@
diff --git a/scripts/package.py b/scripts/package.py
index 839748a..d2a3326 100644
--- a/scripts/package.py
+++ b/scripts/package.py
@@ -8,7 +8,7 @@
ROOT = Path(__file__).resolve().parent.parent
RUNTIME = (
"manifest.json", "devtools.html", "devtools.js", "panel.html", "panel.js",
- "style.css", "json-codec.js", "worker.js", "viewer.js", "viewer-expanded.js",
+ "style.css", "appearance.js", "json-codec.js", "worker.js", "viewer.js", "viewer-expanded.js",
"developer.js", "table-ui.js", "layout.js", "shortcuts.js", "response-editor.js",
"mock-page.js", "response-mock.js",
"field-patch-core.js", "field-mock.js", "field-mock-page.js", "field-mock-bridge.js", "field-mock-background.js",
diff --git a/style.css b/style.css
index f58ce25..503ff9c 100644
--- a/style.css
+++ b/style.css
@@ -243,3 +243,131 @@ select{background:#242426;color:#ddd;border:1px solid #444;border-radius:4px;pad
/* Scalar editing keeps modified fields visible without a full-response diff. */
[hidden]{display:none!important}.field-mock-bar{flex:none;padding:9px 14px;background:#243e3e;border-bottom:1px solid #49665e;color:#b8e0ce;max-height:210px;overflow:auto}.field-mock-heading{display:flex;gap:12px;align-items:center;justify-content:space-between;flex-wrap:wrap}.field-mock-bar summary{cursor:pointer;margin:7px 0}.field-mock-bar .hint{display:block;margin-top:5px}.field-mock-change{display:flex;gap:8px;align-items:center;padding:5px 0}.field-mock-change>span{flex:1;min-width:0;overflow-wrap:anywhere;white-space:pre-wrap;max-height:64px;overflow:auto}.field-mock-edited{background:#233b4d!important;box-shadow:inset 3px 0 #76bca7}.field-mock-edited .cell-text::after,[data-field-mock].field-mock-edited::after{content:' •';color:#8fddbf}[data-field-mock]{cursor:text}.field-mock-input{font:inherit;color:#e4f3ff;background:#152a3b;border:1px solid #78baa7;border-radius:3px;min-width:70px;max-width:100%;width:100%;padding:3px 5px;box-sizing:border-box}.field-mock-input[aria-invalid=true]{border-color:#f08c8c}
+
+/* Appearance: neutral surfaces, independent accents, and distinct data types. */
+:root{
+ --data-font-size:13px;--row-height:42px;--row-pad:8px;
+ --bg:#181a20;--panel:#20232c;--panel2:#2b303d;--line:#3d4352;--text:#e6e8ef;--muted:#a6adbd;
+ --hover:#2c3241;--selection:#343954;--control:#1b1e27;--header:#2b3040;--row:#1e222b;--row-alt:#252a35;
+ --number:#9fcaff;--string:#a3d5a4;--boolean:#edb974;--null:#b4a1d6;--key:#c3b4f3;
+ --edited:#213d37;--edited-line:#71c5a4;--match:#5a4820;--match-text:#fff0ba;--match-line:#edbf5d;
+ --scroll-track:var(--bg);--scroll-thumb:#50596e;--scroll-hover:#69758c;--scroll-active:#8793ac;
+}
+:root[data-theme=ocean]{
+ --bg:#171e28;--panel:#202b3b;--panel2:#293b52;--line:#43536c;--text:#e6edf8;--muted:#a5b4c8;
+ --hover:#2c4059;--selection:#2d4561;--control:#162130;--header:#293b52;--row:#1e2836;--row-alt:#253244;
+ --number:#a8d4ff;--string:#b8dda7;--boolean:#ffd193;--null:#c0afdc;--key:#b1d7ff;
+ --scroll-thumb:#536780;--scroll-hover:#718eaf;--scroll-active:#91b8e0;
+}
+:root[data-theme=forest]{
+ --bg:#14201d;--panel:#1c2a27;--panel2:#283b35;--line:#3c554c;--text:#e0eee7;--muted:#a3bcb0;
+ --hover:#2a4138;--selection:#2e4c40;--control:#17241f;--header:#293d35;--row:#192a23;--row-alt:#203329;
+ --number:#a7c8ff;--string:#a3dfa7;--boolean:#eec782;--null:#c7b2df;--key:#a5d7c5;
+ --scroll-thumb:#506d60;--scroll-hover:#6b8879;--scroll-active:#8ba797;
+}
+:root[data-theme=paper]{
+ color-scheme:light;--bg:#f3f5f9;--panel:#fff;--panel2:#e8edf6;--line:#cbd3e0;--text:#263249;--muted:#59677d;
+ --hover:#e8edf6;--selection:#dfe5f8;--control:#f7f9fc;--header:#e6ebf5;--row:#fff;--row-alt:#f0f3f9;
+ --number:#245ba6;--string:#246438;--boolean:#8b5100;--null:#775293;--key:#5a439e;
+ --green:#197145;--red:#b52c3b;--yellow:#8b5a00;--edited:#e0f2e9;--edited-line:#247b57;
+ --match:#ffe6a0;--match-text:#533300;--match-line:#946400;
+ --scroll-thumb:#b0bbce;--scroll-hover:#8e9db5;--scroll-active:#6f819d;
+}
+:root[data-density=compact]{--row-height:32px;--row-pad:3px}
+body{background:var(--bg);color:var(--text)}
+.topbar{background:linear-gradient(105deg,var(--panel),var(--panel2));border-color:var(--line)}
+.brand .logo{color:var(--blue)}
+.sidebar,.pagination,.request-head,.tabs,.side-title,.request-filters,.subtoolbar,.raw-toolbar,.viewer-toolbar,.modal-head,.modal-actions{background:var(--panel);border-color:var(--line)}
+.sidebar-resizer{background:var(--bg)}
+.sidebar-resizer:hover,.sidebar-resizer:focus-visible{background:var(--blue)}
+.side-title,.url,.req-url,.detail-value,.viewer-scalar,.node-head>.node-preview{color:var(--text)}
+.req-status,.req-time,.hint,.side-title .count,.th-kind,.table-meta,.page-info,.viewer-page,.detail-key,.node-head>.node-type,.request-search>label{color:var(--muted)}
+.count,.tab-badge,.method,.status{background:var(--panel2);color:var(--muted)}
+.method.get,.status.ok,.status-dot.ok{color:var(--green)}
+.method.post{color:var(--number)}.method.put,.method.patch{color:var(--boolean)}.method.delete,.status.bad{color:var(--red)}
+.request-item{border-color:var(--line)}
+.request-item:hover,.node-head:hover,.viewer-row:hover,.viewer-flat-row:hover{background:var(--hover)}
+.request-item.active{background:var(--selection);border-left-color:var(--blue);box-shadow:inset 0 0 0 1px var(--line)}
+.search,select,.detail-value,.field-mock-input{background:var(--control);border-color:var(--line);color:var(--text)}
+.search:focus,select:focus-visible{border-color:var(--blue);box-shadow:0 0 0 2px color-mix(in srgb,var(--blue) 20%,transparent)}
+.btn,.page-btn,.mini-btn,.copy-mini,.expand-cell,.table-wrap .expand-cell,.viewer-content-actions>.mini-btn{background:var(--panel2);border-color:var(--line);color:var(--text)}
+.btn.ghost{background:transparent}
+.btn:hover,.page-btn:not(:disabled):hover,.mini-btn:hover,.copy-mini:hover,.expand-cell:hover,.table-wrap .expand-cell:hover,.viewer-content-actions>.mini-btn:hover{background:var(--hover);border-color:var(--blue);color:var(--text)}
+.tab{color:var(--muted)}.tab.active,.tab:hover{color:var(--blue);border-bottom-color:var(--blue)}
+.tab-badge{color:var(--text)}
+.table-toolbar{background:var(--panel);border-color:var(--line)}
+.table-path,.viewer-path,.viewer-key,.request-section h3{color:var(--key)}
+.table-search-label,.tree-search-controls label{color:var(--muted)}
+.table-wrap{background:var(--bg);border-color:var(--line)}
+.table-wrap table,.table-wrap thead th,.tree,.raw,.viewer-scalar,.viewer-row,.viewer-flat-row{font-size:var(--data-font-size)}
+.table-wrap thead th{background:var(--header);color:var(--text);border-color:var(--line);border-bottom-color:var(--blue)}
+.table-wrap thead th:hover,.table-wrap thead th.sorted-column{background:var(--selection);border-bottom-color:var(--blue)}
+.th-sort,.sorted-column .th-sort{color:var(--blue)}
+.table-wrap tbody td{background:var(--row);color:var(--text);border-color:var(--line);height:var(--row-height);padding-top:var(--row-pad);padding-bottom:var(--row-pad)}
+.table-wrap tbody tr:nth-child(even) td{background:var(--row-alt)}
+:root[data-striped=false] .table-wrap tbody tr:nth-child(even) td{background:var(--row)}
+.table-wrap tbody tr:hover td,.table-wrap tbody td:focus-within{background:var(--hover)}
+.table-wrap tbody td:hover{box-shadow:inset 0 0 0 1px var(--blue)}
+.table-wrap td.cell-type-number .cell-text,.node-head>.node-type.type-number+.node-preview,.viewer-value.type-number,.viewer-flat-row .type-number{color:var(--number)}
+.table-wrap td.cell-type-string .cell-text,.node-head>.node-type.type-string+.node-preview,.viewer-value.type-string{color:var(--string)}
+.table-wrap td.cell-type-boolean .cell-text,.node-head>.node-type.type-boolean+.node-preview,.viewer-value.type-boolean{color:var(--boolean)}
+.table-wrap td.cell-type-null .cell-text,.viewer-value.type-null{color:var(--null)}
+.table-wrap td.cell-type-undefined .cell-text{color:var(--muted)}
+.table-wrap .cell-complex{color:var(--boolean)}.cell-viewable:hover{color:var(--text);text-decoration-color:var(--blue)}
+.raw,.modal-content,.request-details,.tree{background:var(--bg);color:var(--text)}
+.modal-card,.column-popover,.viewer-results button{background:var(--panel);border-color:var(--line);color:var(--text)}
+.request-section h3{background:var(--panel2)}
+.request-section,.detail-pair,.viewer-children,.viewer-flat-row{border-color:var(--line)}
+mark,.table-wrap mark{background:var(--match);color:var(--match-text)}
+.table-wrap tbody td.search-current,.table-wrap tbody tr:hover td.search-current,.viewer-row.viewer-search-target{background:var(--match);box-shadow:inset 0 0 0 2px var(--match-line);color:var(--match-text)}
+.viewer-search-target .viewer-key{color:var(--match-text)}
+.field-mock-bar{background:var(--edited);border-color:var(--edited-line);color:var(--text)}
+.field-mock-edited{background:var(--edited)!important;box-shadow:inset 3px 0 var(--edited-line)}
+.field-mock-edited .cell-text::after,[data-field-mock].field-mock-edited::after{color:var(--edited-line)}
+.field-mock-input{border-color:var(--edited-line)}
+.field-mock-selected{outline-color:var(--blue)}
+.toast{background:var(--panel2);border-color:var(--line);color:var(--text)}
+:root[data-theme=paper] .response-editor-card,:root[data-theme=paper] .response-editor-head,:root[data-theme=paper] .response-mock-controls{background:var(--panel);color:var(--text)}
+:root[data-theme=paper] .response-editor-head h2,:root[data-theme=paper] .response-editor-input,:root[data-theme=paper] .response-original{background:var(--control);color:var(--text)}
+
+/* A small disclosure stays available even when the data controls are collapsed. */
+.appearance-menu{margin-left:auto;flex:none;position:relative}
+.appearance-toggle{list-style:none;display:flex;align-items:center;gap:6px;cursor:pointer;white-space:nowrap}
+.appearance-toggle::-webkit-details-marker{display:none}
+.appearance-toggle>span{color:var(--blue);font-size:17px;line-height:17px}
+.appearance-menu[open]>.appearance-toggle{border-color:var(--blue);background:var(--selection)}
+.appearance-panel{position:absolute;right:0;top:calc(100% + 10px);z-index:20;width:min(322px,calc(100vw - 20px));max-height:calc(100vh - 62px);overflow:auto;overscroll-behavior:contain;white-space:normal;padding:16px;background:var(--panel);border:1px solid var(--line);border-radius:12px;box-shadow:0 16px 50px #0006;color:var(--text)}
+.appearance-heading{display:flex;align-items:flex-start;justify-content:space-between;gap:10px;margin-bottom:14px}
+.appearance-heading strong{font-size:14px}.appearance-heading p{margin:4px 0 0;color:var(--muted);font-size:11px}
+.appearance-panel fieldset{border:0;min-width:0;padding:0;margin:0 0 16px}
+.appearance-panel legend{padding:0;margin-bottom:8px;color:var(--muted);font-size:11px}
+.appearance-palettes{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:6px}
+.appearance-palette{border:1px solid var(--line);border-radius:7px;padding:5px;background:var(--control);color:var(--text);cursor:pointer;font-size:11px;font-family:inherit}
+.appearance-palette[aria-pressed=true]{border-color:var(--blue);box-shadow:0 0 0 1px var(--blue)}
+.palette-preview{height:32px;border-radius:4px;margin-bottom:5px;display:flex;gap:3px;align-items:flex-end;padding:5px;background:var(--swatch-bg)}
+.palette-preview i{flex:1;height:70%;background:var(--swatch-panel);border-radius:2px}.palette-preview i:nth-child(2){height:90%;background:var(--swatch-accent)}
+.palette-graphite{--swatch-bg:#181a20;--swatch-panel:#454b62;--swatch-accent:#8b9dff}
+.palette-ocean{--swatch-bg:#171e28;--swatch-panel:#365371;--swatch-accent:#4ea1ff}
+.palette-forest{--swatch-bg:#14201d;--swatch-panel:#365f4e;--swatch-accent:#52c9a5}
+.palette-paper{--swatch-bg:#eef1f7;--swatch-panel:#c1c9dc;--swatch-accent:#4f5bd5}
+.appearance-accents{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
+.accent-choice{height:24px;width:24px;padding:0;border:2px solid transparent;border-radius:50%;background:var(--swatch-accent);cursor:pointer;box-shadow:inset 0 0 0 1px #0002}
+.accent-choice[aria-pressed=true]{outline:2px solid var(--blue);outline-offset:2px;border-color:var(--panel)}
+.accent-violet{--swatch-accent:#8b9dff}.accent-blue{--swatch-accent:#4ea1ff}.accent-teal{--swatch-accent:#52c9a5}.accent-amber{--swatch-accent:#edb35c}.accent-rose{--swatch-accent:#ed87a8}
+.appearance-custom{display:flex;align-items:center;gap:5px;margin-left:auto;color:var(--muted);font-size:11px}
+.appearance-custom input{width:29px;height:26px;padding:1px;border:1px solid var(--line);border-radius:5px;background:var(--panel);cursor:pointer}
+.appearance-color-value{display:block;margin-top:8px;color:var(--muted);font:10px/1.4 Consolas,monospace}
+.appearance-options{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:14px}
+.appearance-options>label{display:flex;flex-direction:column;gap:6px;color:var(--muted);font-size:11px}
+.appearance-options select{width:100%;padding:6px;border-radius:5px;font:inherit;font-size:12px}
+.appearance-check{display:flex;align-items:center;gap:6px;font-size:12px;margin-bottom:15px}.appearance-check input{accent-color:var(--blue)}
+.appearance-footer{display:flex;align-items:center;justify-content:space-between;gap:8px;padding-top:12px;border-top:1px solid var(--line)}
+.appearance-footer>span{color:var(--muted);font-size:10px;max-width:170px}
+.appearance-footer .btn{white-space:nowrap;font-size:11px;padding:4px 6px}
+.appearance-menu :is(button,summary,input,select):focus-visible{outline:2px solid var(--blue);outline-offset:3px}
+@media(max-width:420px){.appearance-panel{position:fixed;top:50px;right:10px}.appearance-toggle{padding:5px 7px}}
+@media(forced-colors:active){.appearance-palette[aria-pressed=true],.accent-choice[aria-pressed=true]{outline:2px solid Highlight}.palette-preview{border:1px solid CanvasText}.appearance-panel{border:1px solid CanvasText}}
+
+:root[data-striped=false] .table-wrap tbody tr:nth-child(even):hover td{background:var(--hover)}
+.table-wrap td.bool-true .cell-text{color:var(--green)}
+.table-wrap td.bool-false .cell-text{color:var(--boolean)}
diff --git a/table-ui.js b/table-ui.js
index 1f2cd7c..87eb64f 100644
--- a/table-ui.js
+++ b/table-ui.js
@@ -1,10 +1,11 @@
// Content previews are capped; full field names always define the minimum width.
let tableMeasureContext;
function measureTableText(value,header=false){
+ const size=typeof Appearance!=='undefined'?Appearance.fontSize:13;
const text=header?String(value??''):String(value??'').slice(0,240);
if(tableMeasureContext===undefined){try{tableMeasureContext=document.createElement('canvas').getContext?.('2d')||null;}catch{tableMeasureContext=null;}}
- if(tableMeasureContext){tableMeasureContext.font=header?'650 13px "Segoe UI","Microsoft YaHei",sans-serif':'13px Consolas,Monaco,monospace';return tableMeasureContext.measureText(text).width;}
- return [...text].reduce((width,c)=>width+(c.codePointAt(0)>255?13:7.8),0);
+ if(tableMeasureContext){tableMeasureContext.font=header?`650 ${size}px "Segoe UI","Microsoft YaHei",sans-serif`:`${size}px Consolas,Monaco,monospace`;return tableMeasureContext.measureText(text).width;}
+ return [...text].reduce((width,c)=>width+(c.codePointAt(0)>255?size:size*.6),0);
}
function tableHeaderMinWidth(col){
// Padding, sort arrow, gap and resize handle must not cover the field name.
@@ -44,7 +45,7 @@ function createTableHeader(col,rows,width){
// Keep widths stable across paging/sorting, with a bounded cache per response and array.
const tableWidthStates=new Map();
let currentTableWidths=new Map(),currentTableColumns=[],currentWidthKey=null;
-function tableWidthKey(){return selected?.rid && tableState.path?JSON.stringify([selected.rid,tableState.path]):null;}
+function tableWidthKey(){return selected?.rid && tableState.path?JSON.stringify([selected.rid,tableState.path,typeof Appearance!=='undefined'?Appearance.fontSize:13]):null;}
function widthState(){
const key=tableWidthKey();if(!key)return {auto:new Map(),manual:new Map()};
if(!tableWidthStates.has(key)){tableWidthStates.set(key,{auto:new Map(),manual:new Map()});if(tableWidthStates.size>32)tableWidthStates.delete(tableWidthStates.keys().next().value);}
@@ -81,3 +82,18 @@ function bindColumnResize(th,col){
}
async function refitTableColumns(){const state=widthState();state.manual.clear();state.auto.clear();return renderTable();}
$('#refitColumns').onclick=safe(refitTableColumns);
+
+// A font change only resizes rendered columns; response parsing and DOM rows stay intact.
+document.addEventListener('appearancechange',()=>{
+ if(!currentTableColumns.length || !tableState.rows.length || !tableWidthKey())return;
+ const widths=resolvedTableWidths(currentTableColumns,tableState.rows);
+ for(const [index,col] of currentTableColumns.entries()){
+ const width=widths.get(col),column=$('#tableColumns').children[index],header=$('#dataTable thead').children[0]?.children[index];
+ if(column)column.style.width=width+'px';
+ if(header){
+ header.style.width=width+'px';const grip=header.querySelector('.column-resizer');
+ if(grip){grip.setAttribute('aria-valuenow',String(width));grip.setAttribute('aria-valuemin',String(tableHeaderMinWidth(col)));grip.setAttribute('aria-valuemax',String(Math.max(960,tableHeaderMinWidth(col))));}
+ }
+ }
+ $('#dataTable').style.width=[...widths.values()].reduce((sum,width)=>sum+width,0)+'px';
+});
diff --git a/tests/appearance.cjs b/tests/appearance.cjs
new file mode 100644
index 0000000..ba25d8c
--- /dev/null
+++ b/tests/appearance.cjs
@@ -0,0 +1,48 @@
+'use strict';
+const {test}=require('node:test'),assert=require('node:assert/strict'),fs=require('node:fs'),path=require('node:path'),vm=require('node:vm');
+const source=fs.readFileSync(path.join(__dirname,'../appearance.js'),'utf8');
+const key='api-table-viewer.appearance.v1';
+function panel(storage=new Map(),blocked=false){
+ const nodes=new Map(),events=new Map(),changes=[];
+ function element(data={}){return {dataset:data,value:'',checked:false,open:false,textContent:'',attributes:{},setAttribute(k,v){this.attributes[k]=v;},focus(){this.focused=true;},contains(target){return target===this;}};}
+ function get(id){if(!nodes.has(id))nodes.set(id,element());return nodes.get(id);}
+ const palettes=['graphite','ocean','forest','paper'].map(themeChoice=>element({themeChoice})),accents=['#8b9dff','#4ea1ff','#52c9a5','#edb35c','#ed87a8'].map(accentChoice=>element({accentChoice}));
+ get('appearanceMenu').querySelectorAll=s=>s==='[data-theme-choice]'?palettes:accents;
+ const root={dataset:{},style:{values:{},setProperty(k,v){this.values[k]=v;}}};
+ const context=vm.createContext({localStorage:{getItem:k=>{if(blocked)throw Error('blocked');return storage.get(k)??null;},setItem:(k,v)=>{if(blocked)throw Error('blocked');storage.set(k,v);}},
+ document:{documentElement:root,readyState:'complete',getElementById:get,addEventListener:(name,fn)=>events.set(name,fn),dispatchEvent:e=>changes.push(e.type)},CustomEvent:class{constructor(type){this.type=type;}}});
+ vm.runInContext(source,context);return {get,root,palettes,accents,changes,events,storage,context};
+}
+test('appearance choices survive reopening, preserve unrelated preferences and apply immediately',()=>{
+ const p=panel(new Map([['layout','keep']]));p.palettes[2].onclick();p.accents[3].onclick();p.get('appearanceFont').value='16';p.get('appearanceFont').onchange();p.get('appearanceDensity').value='compact';p.get('appearanceDensity').onchange();p.get('appearanceStriped').checked=false;p.get('appearanceStriped').onchange();
+ assert.equal(p.root.dataset.theme,'forest');assert.equal(p.root.dataset.density,'compact');assert.equal(p.root.dataset.striped,'false');assert.equal(p.root.style.values['--data-font-size'],'16px');assert.equal(p.changes.length,1);
+ const reopened=panel(p.storage);assert.equal(reopened.get('appearanceFont').value,'16');assert.equal(reopened.get('appearanceAccent').value,'#edb35c');assert.equal(reopened.palettes[2].attributes['aria-pressed'],'true');assert.equal(p.storage.get('layout'),'keep');
+ reopened.get('appearanceReset').onclick();const reset=JSON.parse(p.storage.get(key));assert.equal(reset.theme,'graphite');assert.equal(reset.fontSize,13);assert.equal(reset.striped,true);assert.equal(p.storage.get('layout'),'keep');
+});
+test('malformed, outdated and blocked storage leave the panel usable',()=>{
+ for(const raw of ['bad json','null','{"theme":"toString","accent":"url(bad)","fontSize":999,"striped":null}']){
+ const p=panel(new Map([[key,raw]]));assert.equal(p.root.dataset.theme,'graphite');assert.equal(p.get('appearanceFont').value,'13');assert.equal(p.get('appearanceAccent').value,'#8b9dff');
+ }
+ const p=panel(new Map(),true);p.palettes[3].onclick();assert.equal(p.root.dataset.theme,'paper');assert(p.get('appearanceStatus').textContent.includes('无法保存'));
+});
+test('extreme custom accents remain legible against light and dark backgrounds',()=>{
+ const p=panel();p.get('appearanceAccent').value='#000000';p.get('appearanceAccent').oninput();assert.notEqual(p.root.style.values['--blue'],'#000000');
+ p.palettes[3].onclick();p.get('appearanceAccent').value='#ffffff';p.get('appearanceAccent').oninput();assert.notEqual(p.root.style.values['--blue'],'#ffffff');assert.equal(p.get('appearanceAccent').value,'#ffffff');
+});
+test('Escape and an outside click close appearance settings; Escape restores focus',()=>{
+ const p=panel(),menu=p.get('appearanceMenu');menu.open=true;p.events.get('pointerdown')({target:menu});assert(menu.open);p.events.get('pointerdown')({target:{}});assert(!menu.open);
+ menu.open=true;let prevented=false;p.events.get('keydown')({key:'Escape',preventDefault(){prevented=true;},stopPropagation(){}});assert(!menu.open);assert(prevented);assert(p.get('appearanceToggle').focused);
+});
+
+test('larger data fonts expand header widths without replacing rows or parsing responses again',()=>{
+ let resized;const nodes=new Map();
+ const get=id=>{if(!nodes.has(id))nodes.set(id,{style:{},children:[],querySelector:()=>null});return nodes.get(id);};
+ const header={style:{},querySelector:()=>null};get('#tableColumns').children=[{style:{}}];get('#dataTable thead').children=[{children:[header]}];
+ const column='aLongHeaderThatMustStayOnOneLine';
+ const rows=[{[column]:{text:'short',type:'string'}}],state={path:['records'],rows};
+ const context=vm.createContext({Appearance:{fontSize:13},selected:{rid:'r'},tableState:state,$:get,safe:fn=>fn,document:{createElement:()=>({getContext:()=>null}),addEventListener:(_,fn)=>{resized=fn;}}});
+ vm.runInContext(fs.readFileSync(path.join(__dirname,'../table-ui.js'),'utf8'),context);context.column=column;
+ const before=vm.runInContext('resolvedTableWidths([column],tableState.rows).get(column)',context);
+ context.Appearance.fontSize=16;resized();
+ assert(parseFloat(header.style.width)>before);assert.equal(context.tableState.rows,rows);assert.equal(get('#tableColumns').children[0].style.width,header.style.width);
+});
diff --git a/tests/preview/check.cjs b/tests/preview/check.cjs
index 24f31f0..2b50602 100644
--- a/tests/preview/check.cjs
+++ b/tests/preview/check.cjs
@@ -30,7 +30,7 @@ test('preview server serves panel/runtime assets and rejects unrelated files', a
assert.equal(response.status, 200);
const html = await response.text();
assert(html.includes('/preview/env.js')); assert(html.includes('id="responseEditor"'));
- for (const route of ['/preview/env.js', '/preview/boot.js', '/worker.js', '/response-editor.js', '/style.css']) assert.equal((await fetch(origin + route)).status, 200);
+ for (const route of ['/preview/env.js', '/preview/boot.js', '/worker.js', '/appearance.js', '/response-editor.js', '/style.css']) assert.equal((await fetch(origin + route)).status, 200);
assert.equal((await fetch(origin + '/package.json')).status, 404);
assert.equal((await fetch(origin + '/', {method: 'POST'})).status, 405);
} finally {await new Promise(resolve => server.close(resolve));}
diff --git a/tests/preview/server.cjs b/tests/preview/server.cjs
index 4b4da14..33e1ea5 100644
--- a/tests/preview/server.cjs
+++ b/tests/preview/server.cjs
@@ -4,7 +4,7 @@ const http = require('node:http');
const fs = require('node:fs');
const path = require('node:path');
const root = path.resolve(__dirname, '../..');
-const runtime = new Set(['json-codec.js', 'panel.js', 'table-ui.js', 'viewer.js', 'viewer-expanded.js', 'developer.js', 'layout.js', 'shortcuts.js', 'response-editor.js', 'mock-page.js', 'response-mock.js', 'worker.js', 'style.css', 'field-patch-core.js', 'field-mock.js']);
+const runtime = new Set(['appearance.js', 'json-codec.js', 'panel.js', 'table-ui.js', 'viewer.js', 'viewer-expanded.js', 'developer.js', 'layout.js', 'shortcuts.js', 'response-editor.js', 'mock-page.js', 'response-mock.js', 'worker.js', 'style.css', 'field-patch-core.js', 'field-mock.js']);
function createPreviewServer() {
return http.createServer((req, res) => {
const pathname = new URL(req.url, 'http://localhost').pathname;