Skip to content

Commit 8f9caca

Browse files
committed
feat(config-editor): add Catppuccin themes with scoped component styles
Move component styling into Svelte scoped <style> blocks with BEM class names, replace the global stylesheets with theme, base, and primitive layers, and drive four Catppuccin flavors from a ThemeStore registry. Assisted-by: opencode:deepseek-flash
1 parent c8672a0 commit 8f9caca

26 files changed

Lines changed: 898 additions & 701 deletions

‎AGENTS.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ npm ci --prefix config-editor
1818
npm run dev --prefix config-editor
1919
```
2020

21-
Open `http://127.0.0.1:8080/`. No Python or Zensical server is needed. Its application-schema selector currently provides independent TUIC server and client editors, covering multiple users, three certificate modes, SOCKS5 authentication, logging, connections, TCP/UDP forwarding, Quinn/quiche backends, outbound and ACL routing, DNS/GeoData, RESTful management, and HTTP/3 masquerading. The page uses a responsive workbench: a keyboard-accessible section directory with active-section and error indicators, a searchable card-based editor form, and a sticky preview region that renders the live generated config with syntax highlighting, per-line XML explanations on hover or keyboard focus, validation, and copy/download. Medium screens use a compact section selector; narrow screens switch between editing and preview/export while retaining input and scroll positions. Search matches visible field labels, hints, and paths without indexing input values. Error links switch back to editing before focusing the field. Invalid inputs keep the preview available: flagged values are shown as `<placeholder>` alongside the validation warnings, while copy and download stay disabled until every field is valid.
21+
Open `http://127.0.0.1:8080/`. No Python or Zensical server is needed. Its application-schema selector currently provides independent TUIC server and client editors, covering multiple users, three certificate modes, SOCKS5 authentication, logging, connections, TCP/UDP forwarding, Quinn/quiche backends, outbound and ACL routing, DNS/GeoData, RESTful management, and HTTP/3 masquerading. The page uses a responsive workbench: a keyboard-accessible section directory with active-section and error indicators, a searchable card-based editor form, and a sticky preview region that renders the live generated config with syntax highlighting, per-line XML explanations on hover or keyboard focus, validation, and copy/download. Medium screens use a compact section selector; narrow screens switch between editing and preview/export while retaining input and scroll positions. Search matches visible field labels, hints, and paths without indexing input values. Error links switch back to editing before focusing the field. Invalid inputs keep the preview available: flagged values are shown as `<placeholder>` alongside the validation warnings, while copy and download stay disabled until every field is valid. A header selector switches between the embedded Catppuccin flavors (Latte, Frappé, Macchiato, Mocha), driven by a registry in `ThemeStore` and semantic design tokens.
2222

2323
When [just](https://just.systems/) is installed, the repository root provides shortcuts:
2424

@@ -130,7 +130,7 @@ Configuration state is modified only by the Rust `Session`. Svelte submits gener
130130
| `tuic/zensical.toml` / `tuic/docs/` | TUIC Chinese documentation, navigation, field descriptions, and DSL documentation |
131131
| `tuic/overrides/` | TUIC theme overrides and 404 page |
132132
| `config-editor/` | Independently buildable Rust WASM + Svelte single-page application |
133-
| `config-editor/ui/` | Layered frontend: `bridge/` (WASM/JSON boundary), `state/` (session, workbench, theme, viewport, URL), `lib/` (DOM and Prism helpers), `components/`, `styles/`, and the `types.ts` display contract; unit tests use Vitest + jsdom |
133+
| `config-editor/ui/` | Layered frontend: `bridge/` (WASM/JSON boundary), `state/` (session, workbench, theme registry, viewport, URL), `lib/` (DOM and Prism helpers), `components/` (BEM + Svelte scoped styles), `styles/` (`theme.css` Catppuccin flavors and semantic tokens, `base.css`, `primitives.css`), and the `types.ts` display contract; unit tests use Vitest + jsdom |
134134
| `config-editor/src/session.rs` / `session/view.rs` | Natively testable editing operations, form view, and preview export |
135135
| `config-editor/src/wasm.rs` | WASM interface and browser Crypto API randomness adapter |
136136
| `config-editor/package.json` / `vite.config.js` | Locked frontend tooling and static asset bundling |

‎config-editor/AGENTS.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ Svelte 通过 WASM `Engine` 提交 `set`、`set-row`、`add`、`remove`、`gener
2525

2626
前端采用分区目录、卡片表单和固定预览组成的响应式工作台。宽屏显示三栏,中等宽度将目录收为分区选择器,小屏幕通过“编辑配置/预览与导出”切换并保留各自滚动位置和表单状态。目录高亮当前分区并显示可见字段错误数;查找配置项仅匹配当前可见字段的标签、说明和路径,不索引输入值。目录与搜索操作会展开目标分区并移动键盘焦点;错误列表会先切回编辑视图,再展开字段所在分区并定位输入框。集合新增后聚焦新行,删除后聚焦相邻可见行。主题和展开状态仅存在于当前页面,不持久化。
2727

28-
前端代码按职责分层:`ui/bridge/` 只负责 WASM `Engine` 生命周期与 JSON 边界;`ui/state/` 提供 `SessionStore`(会话、命令与派生选择器)、`WorkbenchStore`(分栏、滚动位置与跨栏导航)、`ThemeStore`、`ViewportStore`、URL 同步和 Svelte context;`ui/lib/` 提供 DOM 焦点/下载与 Prism 高亮工具;`ui/components/` 渲染 `AppHeader`、`SectionNav`、`FieldSearch`、`FormSection`、`FieldControl`、`CollectionEditor`/`CollectionRow`、`PreviewPanel`、`PreviewDocument`、`ExportActions` 和 `Notices`。组件通过 context 读取 `SessionStore`,不再逐层传递控制器;外观由 `ui/styles/` 的设计令牌驱动浅色与深色主题。前端单元测试位于 `ui/**/*.spec.ts`,使用 Vitest + jsdom 覆盖桥接、状态与工具模块;`tests/decoupled.rs` 的无产品标识检查覆盖全部生产前端文件。
28+
前端代码按职责分层:`ui/bridge/` 只负责 WASM `Engine` 生命周期与 JSON 边界;`ui/state/` 提供 `SessionStore`(会话、命令与派生选择器)、`WorkbenchStore`(分栏、滚动位置与跨栏导航)、`ThemeStore`、`ViewportStore`、URL 同步和 Svelte context;`ui/lib/` 提供 DOM 焦点/下载与 Prism 高亮工具;`ui/components/` 渲染 `AppHeader`、`SectionNav`、`FieldSearch`、`FormSection`、`FieldControl`、`CollectionEditor`/`CollectionRow`、`PreviewPanel`、`PreviewDocument`、`ExportActions` 和 `Notices`。组件通过 context 读取 `SessionStore`,不再逐层传递控制器。组件样式用 Svelte 原生 `<style>`(scoped)写在各自文件内,类名采用 BEM,跨组件的字段与按钮基元集中在 `ui/styles/primitives.css`;`ui/styles/theme.css` 内嵌 Catppuccin(MIT)的 Latte/Frappé/Macchiato/Mocha 四套 flavor 的 `--ctp-*` 原始调色板,并映射为语义 `--color-*`/`--syntax-*` 令牌,`base.css` 只保留元素基样式与启动占位。主题由 `ThemeStore` 注册表驱动,新增主题只需一条注册项和一个 `[data-theme]` 调色板块;无新增运行时依赖。前端单元测试位于 `ui/**/*.spec.ts`,使用 Vitest + jsdom 覆盖桥接、状态与工具模块;`tests/decoupled.rs` 的无产品标识检查覆盖全部生产前端文件。
2929

3030
## DSL 概要
3131

‎config-editor/ui/App.svelte‎

Lines changed: 161 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -22,31 +22,31 @@
2222

2323
<svelte:head><title>{view.ui.brand} {view.ui.title}</title></svelte:head>
2424

25-
<div class="ce-shell" data-theme={theme.dark ? 'dark' : 'light'}>
26-
<a class="ce-skip" href="#ce-editor" onclick={(event) => { event.preventDefault(); workbench.skipToEditor(); }}>跳转到配置表单</a>
25+
<div class="shell" data-theme={theme.current}>
26+
<a class="skip-link" href="#ce-editor" onclick={(event) => { event.preventDefault(); workbench.skipToEditor(); }}>跳转到配置表单</a>
2727
<AppHeader {theme} />
2828
<main id="config-editor" data-ready="true">
29-
<div class="ce-heading">
30-
<div><p class="ce-eyebrow">{view.ui.eyebrow}</p><h1>{view.ui.title}</h1><p class="ce-intro">{view.ui.description}</p></div>
31-
<p class="ce-privacy"><span class="ce-dot"></span>本地处理<span>不保存输入 · 不上传凭据</span></p>
29+
<div class="page-heading">
30+
<div><p class="page-heading__eyebrow">{view.ui.eyebrow}</p><h1>{view.ui.title}</h1><p class="page-heading__intro">{view.ui.description}</p></div>
31+
<p class="privacy-note"><span class="privacy-note__dot"></span>本地处理<span>不保存输入 · 不上传凭据</span></p>
3232
</div>
3333
{#if view.mode}
3434
{@const mode = view.mode}
35-
<div class="ce-modes" role="group" aria-label={mode.label}>
35+
<div class="schema-modes" role="group" aria-label={mode.label}>
3636
{#each mode.options as [value, label] (value)}
3737
<button type="button" data-mode={value} aria-pressed={mode.value === value}
3838
onclick={() => session.dispatch({ type: 'set', field: mode.key, value })}>{label}</button>
3939
{/each}
4040
</div>
4141
{/if}
42-
<div class="ce-pane-switch" role="group" aria-label="工作区视图">
42+
<div class="pane-switch" role="group" aria-label="工作区视图">
4343
<button type="button" aria-pressed={workbench.pane === 'editor'} onclick={() => workbench.switchPane('editor')}>编辑配置</button>
4444
<button type="button" aria-pressed={workbench.pane === 'preview'} onclick={() => workbench.switchPane('preview')}>预览与导出{view.valid ? '' : ` · ${session.errors.length}`}</button>
4545
</div>
46-
<div class="ce-workspace" data-pane={workbench.pane}>
46+
<div class="workspace" data-pane={workbench.pane}>
4747
<SectionNav />
48-
<form id="ce-editor" class="ce-form" autocomplete="off" onsubmit={(event) => event.preventDefault()}>
49-
<div class="ce-region-heading"><h2>选择配置</h2><span>修改后实时更新预览</span></div>
48+
<form id="ce-editor" class="editor-form" autocomplete="off" onsubmit={(event) => event.preventDefault()}>
49+
<div class="region-heading"><h2>选择配置</h2><span>修改后实时更新预览</span></div>
5050
{#key session.schema}
5151
<FieldSearch />
5252
{#each view.sections as section, index (section.name)}
@@ -56,7 +56,157 @@
5656
</form>
5757
<PreviewPanel />
5858
</div>
59-
<div class="ce-status" class:ce-status-visible={!!session.status} role="status" aria-live="polite">{session.status}</div>
59+
<div class={`status-bar${session.status ? ' status-bar--visible' : ''}`} role="status" aria-live="polite">{session.status}</div>
6060
<footer><span>{view.ui.brand} 配置工具</span><span>本地生成,按需导出。</span></footer>
6161
</main>
6262
</div>
63+
64+
<style>
65+
.shell {
66+
min-height: 100vh;
67+
color: var(--color-text);
68+
background:
69+
radial-gradient(1100px 520px at 82% -12%, color-mix(in srgb, var(--color-accent) 12%, transparent), transparent 70%),
70+
var(--color-bg);
71+
font-size: 14px;
72+
line-height: 1.6;
73+
}
74+
.skip-link {
75+
position: fixed;
76+
top: -100px;
77+
left: 16px;
78+
z-index: 50;
79+
padding: 12px 16px;
80+
border: 1px solid var(--color-border);
81+
border-radius: var(--radius-sm);
82+
background: var(--color-surface);
83+
color: var(--color-text);
84+
box-shadow: var(--shadow-pop);
85+
}
86+
.skip-link:focus { top: 10px; }
87+
#config-editor {
88+
max-width: 1616px;
89+
padding: 26px 28px 22px;
90+
margin: auto;
91+
}
92+
.page-heading {
93+
display: flex;
94+
align-items: center;
95+
justify-content: space-between;
96+
gap: 24px;
97+
margin-bottom: 22px;
98+
}
99+
.page-heading > div { max-width: 800px; }
100+
.page-heading__eyebrow {
101+
color: var(--color-accent);
102+
font-size: 11px;
103+
font-weight: 700;
104+
letter-spacing: .12em;
105+
text-transform: uppercase;
106+
margin: 0 0 8px;
107+
}
108+
.page-heading h1 {
109+
font-size: clamp(22px, 2.2vw, 30px);
110+
letter-spacing: -.03em;
111+
line-height: 1.25;
112+
margin: 0 0 12px;
113+
}
114+
.page-heading__intro { color: var(--color-text-muted); margin: 0; font-size: 13px; }
115+
.privacy-note {
116+
flex: none;
117+
display: grid;
118+
grid-template-columns: 9px auto;
119+
align-items: center;
120+
gap: 4px 8px;
121+
padding: 10px 14px;
122+
border: 1px solid var(--color-border);
123+
border-radius: 999px;
124+
background: var(--color-surface);
125+
font-size: 12px;
126+
color: var(--color-success);
127+
}
128+
.privacy-note > span:last-child { grid-column: 2; font-size: 11px; color: var(--color-text-muted); }
129+
.privacy-note__dot {
130+
width: 8px;
131+
height: 8px;
132+
border-radius: 50%;
133+
background: var(--color-success);
134+
box-shadow: 0 0 0 4px color-mix(in srgb, var(--color-success) 18%, transparent);
135+
}
136+
.schema-modes { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 24px; }
137+
.schema-modes button { font-size: 12px; padding-inline: 16px; }
138+
.pane-switch { display: none; }
139+
.workspace {
140+
display: grid;
141+
grid-template-columns: 188px minmax(0, 1fr) minmax(0, .78fr);
142+
gap: 20px;
143+
align-items: start;
144+
}
145+
.editor-form { min-width: 0; scroll-margin-top: 24px; }
146+
.region-heading {
147+
display: flex;
148+
justify-content: space-between;
149+
align-items: center;
150+
margin: 0 0 16px;
151+
gap: 12px;
152+
}
153+
.region-heading h2 { margin: 0; font-size: 14px; }
154+
.region-heading > span { color: var(--color-text-muted); font-size: 11px; }
155+
.status-bar {
156+
position: fixed;
157+
bottom: 24px;
158+
left: 50%;
159+
transform: translateX(-50%);
160+
z-index: 30;
161+
max-width: calc(100vw - 32px);
162+
font-size: 12px;
163+
}
164+
.status-bar--visible {
165+
padding: 12px 18px;
166+
border: 1px solid var(--color-border-strong);
167+
border-radius: 999px;
168+
background: var(--color-text);
169+
color: var(--color-surface);
170+
box-shadow: var(--shadow-float);
171+
}
172+
footer {
173+
display: flex;
174+
justify-content: space-between;
175+
gap: 12px;
176+
margin-top: 32px;
177+
padding-top: 18px;
178+
border-top: 1px solid var(--color-border);
179+
color: var(--color-text-muted);
180+
font-size: 11px;
181+
}
182+
@media (max-width: 1279px) {
183+
.workspace { grid-template-columns: minmax(0, 1fr) minmax(0, .78fr); }
184+
.page-heading { align-items: flex-start; }
185+
}
186+
@media (max-width: 959px) {
187+
.workspace { grid-template-columns: minmax(0, 1fr); gap: 16px; }
188+
.page-heading { display: block; }
189+
.privacy-note { display: flex; flex-wrap: wrap; margin: 12px 0 0; width: fit-content; }
190+
.pane-switch {
191+
position: sticky;
192+
top: 0;
193+
z-index: 15;
194+
display: flex;
195+
gap: 8px;
196+
padding: 10px 0;
197+
margin-bottom: 16px;
198+
background: var(--color-bg);
199+
}
200+
.pane-switch button { flex: 1; }
201+
.pane-switch button[aria-pressed="true"] { background: var(--color-accent); color: var(--color-on-accent); border-color: var(--color-accent); }
202+
[data-pane="editor"] > :global(.preview),
203+
[data-pane="preview"] > .editor-form,
204+
[data-pane="preview"] > :global(.section-nav) { display: none; }
205+
}
206+
@media (max-width: 480px) {
207+
#config-editor { padding: 18px 16px; }
208+
.page-heading { margin-bottom: 12px; }
209+
.region-heading { flex-wrap: wrap; }
210+
}
211+
.pane-switch button { transition: none; }
212+
</style>
Lines changed: 63 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
<script lang="ts">
22
import { useSession } from '../state/context';
3-
import type { ThemeStore } from '../state/theme.svelte';
3+
import { THEMES, type ThemeStore } from '../state/theme.svelte';
44
import { writeSchemaParam } from '../state/url';
55
66
let { theme }: { theme: ThemeStore } = $props();
@@ -13,18 +13,74 @@
1313
}
1414
</script>
1515

16-
<header class="ce-header">
17-
<a class="ce-brand" href="./" aria-label={`${ui.title}首页`}>
18-
<span class="ce-mark" aria-hidden="true">{ui.mark}</span><span>{ui.brand}<small>配置工作台</small></span>
16+
<header class="app-header">
17+
<a class="app-header__brand" href="./" aria-label={`${ui.title}首页`}>
18+
<span class="app-header__mark" aria-hidden="true">{ui.mark}</span><span>{ui.brand}<small>配置工作台</small></span>
1919
</a>
20-
<div class="ce-header-controls">
20+
<div class="app-header__controls">
2121
{#if session.schemas.length}
22-
<label class="ce-schema" for="ce-schema"><span>配置方案</span>
22+
<label class="app-header__schema" for="ce-schema"><span>配置方案</span>
2323
<select id="ce-schema" value={session.schema} onchange={(event) => selectSchema(event.currentTarget.value)}>
2424
{#each session.schemas as item (item.name)}<option value={item.name}>{item.label}</option>{/each}
2525
</select>
2626
</label>
2727
{/if}
28-
<button type="button" class="ce-theme" aria-label="切换主题" aria-pressed={theme.dark} onclick={() => theme.toggle()}>{theme.dark ? '浅色' : '深色'}</button>
28+
<label class="app-header__theme" for="ce-theme"><span>主题</span>
29+
<select id="ce-theme" value={theme.current} onchange={(event) => theme.select(event.currentTarget.value)}>
30+
{#each THEMES as option (option.id)}<option value={option.id}>{option.label}</option>{/each}
31+
</select>
32+
</label>
2933
</div>
3034
</header>
35+
36+
<style>
37+
.app-header {
38+
display: flex;
39+
justify-content: space-between;
40+
align-items: center;
41+
gap: 16px;
42+
padding: 16px max(28px, calc((100vw - 1560px) / 2));
43+
background: color-mix(in srgb, var(--color-surface) 88%, transparent);
44+
border-bottom: 1px solid var(--color-border);
45+
backdrop-filter: blur(10px);
46+
}
47+
.app-header__brand {
48+
display: flex;
49+
align-items: center;
50+
gap: 12px;
51+
color: var(--color-text);
52+
font-size: 19px;
53+
font-weight: 750;
54+
text-decoration: none;
55+
}
56+
.app-header__brand small {
57+
display: block;
58+
color: var(--color-text-muted);
59+
font-size: 10px;
60+
font-weight: 500;
61+
letter-spacing: .14em;
62+
}
63+
.app-header__mark {
64+
display: grid;
65+
place-items: center;
66+
width: 42px;
67+
height: 42px;
68+
border-radius: var(--radius-md);
69+
background: linear-gradient(145deg, var(--color-accent), var(--color-accent-strong));
70+
color: var(--color-on-accent);
71+
box-shadow: 0 8px 18px -10px color-mix(in srgb, var(--color-accent) 80%, transparent);
72+
}
73+
.app-header__controls, .app-header__schema { display: flex; align-items: center; gap: 12px; }
74+
.app-header__schema { font-size: 12px; color: var(--color-text-muted); }
75+
.app-header__theme { display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--color-text-muted); }
76+
.app-header__theme select { max-width: 150px; }
77+
@media (max-width: 480px) {
78+
.app-header { padding: 12px 16px; gap: 12px; flex-wrap: wrap; }
79+
.app-header__brand { font-size: 16px; gap: 8px; }
80+
.app-header__mark { width: 34px; height: 34px; }
81+
.app-header__brand small { font-size: 9px; }
82+
.app-header__controls { gap: 8px; flex-wrap: wrap; }
83+
.app-header__schema { gap: 8px; }
84+
.app-header__schema select { max-width: 170px; }
85+
}
86+
</style>

0 commit comments

Comments
 (0)