From 77f49e1056f2e8e182a5ef1fe7fe1203e70bef52 Mon Sep 17 00:00:00 2001
From: Eric <47561374+Luyii@users.noreply.github.com>
Date: Thu, 22 Oct 2026 18:02:34 +0800
Subject: [PATCH] feat: add configurable appearance settings
Add four palettes, custom accent colors, data font sizes, table density and alternating row backgrounds. Persist preferences locally and resize table columns when the data font changes. Include appearance checks and usage documentation.
---
CHANGELOG.md | 6 ++
README.md | 1 +
appearance.js | 67 ++++++++++++++++++++
docs/TESTING.md | 1 +
docs/USAGE.md | 4 ++
package.json | 5 +-
panel.html | 24 ++++++++
scripts/package.py | 2 +-
style.css | 128 +++++++++++++++++++++++++++++++++++++++
table-ui.js | 22 ++++++-
tests/appearance.cjs | 48 +++++++++++++++
tests/preview/check.cjs | 2 +-
tests/preview/server.cjs | 2 +-
13 files changed, 304 insertions(+), 8 deletions(-)
create mode 100644 appearance.js
create mode 100644 tests/appearance.cjs
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;