Skip to content

Commit 6d34c12

Browse files
committed
style(web): put the settings surface on the design tokens
The settings pages were built with hand-picked pixel values: five corner radii where --r-* tokens exist, a black switch shadow that is invisible in dark themes, and control sizes pinned in pixels that stayed small when a user raised the UI font size. Sizes are additive offsets from --ui-font-size, matching the house style, so every control keeps its exact value at the 14px default and grows from there.
1 parent f97b801 commit 6d34c12

6 files changed

Lines changed: 223 additions & 25 deletions

File tree

.changeset/web-settings-tokens.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@pymodel/pythinker-code': patch
3+
---
4+
5+
Make the web settings surface use the app design tokens: token corner radii, a theme-aware switch shadow, and control sizes that grow with the UI font size.

apps/pythinker-web/src/components/settings/SettingsNav.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ const { t } = useI18n();
4646
.tab {
4747
padding: 8px 10px;
4848
border: none;
49-
border-radius: 7px;
49+
border-radius: var(--r-sm);
5050
background: transparent;
5151
color: var(--muted);
5252
font-family: var(--sans);

apps/pythinker-web/src/components/settings/pages/AgentPage.vue

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -201,17 +201,17 @@ function toggleConfigBoolean(key: 'defaultThinking' | 'defaultPlanMode' | 'merge
201201
.sec-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 10px; }
202202
.sec-head .sec-title { margin-bottom: 0; }
203203
.saving { flex: none; color: var(--muted); font-family: var(--mono); font-size: var(--ui-font-size-xs); }
204-
.seg { display: inline-flex; overflow: hidden; border: 1px solid var(--line); border-radius: 8px; }
204+
.seg { display: inline-flex; overflow: hidden; border: 1px solid var(--line); border-radius: var(--r-sm); }
205205
.opt { padding: 5px 12px; border: none; border-left: 1px solid var(--line); background: var(--bg); color: var(--muted); font-family: var(--mono); font-size: var(--ui-font-size-xs); cursor: pointer; }
206206
.opt:first-child { border-left: none; }
207207
.opt:hover { color: var(--ink); }
208208
.opt.on { background: var(--soft); color: var(--blue2); font-weight: 600; }
209209
.opt:disabled { opacity: 0.55; cursor: not-allowed; }
210-
.select-field { min-width: 220px; max-width: min(320px, 50vw); height: 32px; padding: 0 8px; border: 1px solid var(--line); border-radius: 8px; background: var(--bg); color: var(--ink); font-family: var(--sans); font-size: calc(var(--ui-font-size) - 1.5px); }
210+
.select-field { min-width: 220px; max-width: min(320px, 50vw); height: 32px; padding: 0 8px; border: 1px solid var(--line); border-radius: var(--r-sm); background: var(--bg); color: var(--ink); font-family: var(--sans); font-size: calc(var(--ui-font-size) - 1.5px); }
211211
.select-field:disabled { opacity: 0.6; cursor: not-allowed; }
212212
.empty-config { padding: 4px 0; color: var(--muted); font-family: var(--sans); font-size: calc(var(--ui-font-size) - 1px); }
213213
.provider-list { display: flex; flex-direction: column; gap: 6px; margin-top: 10px; }
214-
.provider-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; min-width: 0; padding: 8px 10px; border: 1px solid var(--line); border-radius: 8px; background: var(--panel2); }
214+
.provider-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; min-width: 0; padding: 8px 10px; border: 1px solid var(--line); border-radius: var(--r-sm); background: var(--panel2); }
215215
.provider-main, .provider-meta { display: flex; align-items: center; gap: 8px; min-width: 0; }
216216
.provider-main { flex: 1; }
217217
.provider-meta { flex: none; max-width: 45%; }

apps/pythinker-web/src/components/settings/pages/GeneralPage.vue

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -209,7 +209,7 @@ onUnmounted(() => {
209209
height: 30px;
210210
padding: 0 8px;
211211
border: 1px solid var(--line);
212-
border-radius: 8px;
212+
border-radius: var(--r-sm);
213213
background: var(--bg);
214214
}
215215
.num-input {
@@ -223,7 +223,7 @@ onUnmounted(() => {
223223
text-align: right;
224224
}
225225
.num-unit { color: var(--muted); font-family: var(--mono); font-size: var(--ui-font-size-xs); }
226-
.seg { display: inline-flex; overflow: hidden; border: 1px solid var(--line); border-radius: 8px; }
226+
.seg { display: inline-flex; overflow: hidden; border: 1px solid var(--line); border-radius: var(--r-sm); }
227227
.opt {
228228
padding: 5px 12px;
229229
border: none;

apps/pythinker-web/src/components/settings/settings.css

Lines changed: 27 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
align-items: center;
2626
justify-content: space-between;
2727
gap: 12px;
28-
min-height: 34px;
28+
min-height: calc(var(--ui-font-size) + 20px);
2929
padding: 3px 0;
3030
}
3131
.rlabel {
@@ -48,9 +48,9 @@
4848
.rvalue.mono { font-family: var(--mono); font-size: var(--ui-font-size-xs); }
4949
.hint { color: var(--faint); font-family: var(--sans); font-size: calc(var(--ui-font-size) - 3px); }
5050
.act {
51-
padding: 6px 12px;
51+
padding: calc(var(--ui-font-size) - 8px) calc(var(--ui-font-size) - 2px);
5252
border: 1px solid var(--line);
53-
border-radius: 7px;
53+
border-radius: var(--r-sm);
5454
background: var(--bg);
5555
color: var(--ink);
5656
font-family: var(--sans);
@@ -61,10 +61,11 @@
6161
.act.signin { background: var(--blue); color: var(--bg); border-color: var(--blue); }
6262
.act.signin:hover { background: var(--blue2); }
6363
.switch {
64+
--switch-knob-size: calc(var(--ui-font-size) + 4px);
6465
position: relative;
6566
flex: none;
66-
width: 40px;
67-
height: 22px;
67+
width: calc(var(--switch-knob-size) * 2 + 4px);
68+
height: calc(var(--switch-knob-size) + 4px);
6869
padding: 0;
6970
border: 1px solid var(--line);
7071
border-radius: 999px;
@@ -78,21 +79,25 @@
7879
position: absolute;
7980
top: 1px;
8081
left: 1px;
81-
width: 18px;
82-
height: 18px;
82+
width: var(--switch-knob-size);
83+
height: var(--switch-knob-size);
8384
border-radius: 50%;
8485
background: var(--bg);
85-
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
86+
box-shadow: 0 1px 2px color-mix(in srgb, var(--ink) 20%, transparent);
8687
transition: transform 0.16s;
8788
}
88-
.switch.on .knob { transform: translateX(18px); }
89-
.switch.sm { width: 30px; height: 17px; }
90-
.switch.sm .knob { width: 13px; height: 13px; }
91-
.switch.sm.on .knob { transform: translateX(13px); }
89+
.switch.on .knob { transform: translateX(var(--switch-knob-size)); }
90+
.switch.sm {
91+
--switch-knob-size: calc(var(--ui-font-size) - 1px);
92+
width: calc(var(--switch-knob-size) * 2 + 4px);
93+
height: calc(var(--switch-knob-size) + 4px);
94+
}
95+
.switch.sm .knob { width: var(--switch-knob-size); height: var(--switch-knob-size); }
96+
.switch.sm.on .knob { transform: translateX(var(--switch-knob-size)); }
9297
.tag {
9398
flex: none;
9499
padding: 1px 6px;
95-
border-radius: 5px;
100+
border-radius: var(--r-xs);
96101
background: var(--soft);
97102
color: var(--muted);
98103
font-size: calc(var(--ui-font-size) - 3px);
@@ -119,7 +124,7 @@
119124
margin: 0 0 12px;
120125
padding: 7px 10px;
121126
border: 1px solid var(--line);
122-
border-radius: 8px;
127+
border-radius: var(--r-sm);
123128
background: var(--bg);
124129
color: var(--ink);
125130
font-family: var(--sans);
@@ -214,16 +219,19 @@
214219
flex: none;
215220
align-items: center;
216221
justify-content: center;
217-
width: 24px;
218-
height: 24px;
222+
width: calc(var(--ui-font-size) + 10px);
223+
height: calc(var(--ui-font-size) + 10px);
219224
padding: 0;
220225
border: none;
221-
border-radius: 7px;
226+
border-radius: var(--r-sm);
222227
background: transparent;
223228
color: var(--faint);
224229
cursor: pointer;
225230
}
226-
.icon-btn svg { width: 15px; height: 15px; }
231+
.icon-btn svg {
232+
width: calc(var(--ui-font-size) + 1px);
233+
height: calc(var(--ui-font-size) + 1px);
234+
}
227235
.icon-btn:hover { background: var(--soft); color: var(--ink); }
228236
.listing-error {
229237
color: var(--err);
@@ -240,7 +248,7 @@
240248
gap: 4px;
241249
padding: 12px;
242250
border: 1px solid var(--line);
243-
border-radius: 10px;
251+
border-radius: var(--r-md);
244252
background: var(--panel);
245253
}
246254
.stat-label { color: var(--muted); font-size: calc(var(--ui-font-size) - 2px); }
Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
import { readFileSync } from 'node:fs';
2+
import { resolve } from 'node:path';
3+
import { describe, expect, it } from 'vitest';
4+
5+
const css = readFileSync(
6+
resolve(import.meta.dirname, '../src/components/settings/settings.css'),
7+
'utf8',
8+
);
9+
10+
function rule(selector: string): string {
11+
const escaped = selector.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&');
12+
const match = css.match(new RegExp(`(?:^|\\n)${escaped}\\s*\\{([^{}]*)\\}`, 'u'));
13+
if (!match?.[1]) throw new Error(`Missing CSS rule: ${selector}`);
14+
return match[1];
15+
}
16+
17+
function declaration(selector: string, property: string): string {
18+
const escaped = property.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&');
19+
const match = rule(selector).match(new RegExp(`${escaped}\\s*:\\s*([^;]+);`, 'u'));
20+
if (!match?.[1]) throw new Error(`Missing ${property} in ${selector}`);
21+
return match[1].trim();
22+
}
23+
24+
function resolvePx(value: string, fontSize: number, variables: Record<string, string> = {}): number {
25+
let expression = value.trim().replace(/^calc\((.*)\)$/u, '$1');
26+
expression = expression.replaceAll(/var\((--[\w-]+)\)/gu, (_match: string, name: string) => {
27+
if (name === '--ui-font-size') return String(fontSize);
28+
const variable = variables[name];
29+
if (variable === undefined) throw new Error(`Missing test variable: ${name}`);
30+
return String(resolvePx(variable, fontSize, variables));
31+
});
32+
expression = expression.replaceAll(/px\b/gu, '');
33+
if (!/^[\d\s.+*()-]+$/u.test(expression)) throw new Error(`Unsupported CSS arithmetic: ${value}`);
34+
35+
// `calc()` requires whitespace around + and -, so splitting on the spaced
36+
// operator is both safe and a check that the expression is valid CSS.
37+
const parts = expression.split(/\s+([+-])\s+/u);
38+
let sum = 0;
39+
let sign = 1;
40+
for (const part of parts) {
41+
if (part === '+' || part === '-') {
42+
sign = part === '+' ? 1 : -1;
43+
continue;
44+
}
45+
const product = part.split(/\s*\*\s*/u).reduce((acc, factor) => acc * Number(factor), 1);
46+
if (Number.isNaN(product)) throw new Error(`Unsupported CSS arithmetic: ${value}`);
47+
sum += sign * product;
48+
}
49+
return sum;
50+
}
51+
52+
function transformDistance(selector: string, variables: Record<string, string>, fontSize: number): number {
53+
const transform = declaration(selector, 'transform');
54+
const match = transform.match(/^translateX\((.*)\)$/u);
55+
if (!match?.[1]) throw new Error(`Missing translateX in ${selector}`);
56+
return resolvePx(match[1], fontSize, variables);
57+
}
58+
59+
function splitCssValues(value: string): string[] {
60+
const values: string[] = [];
61+
let depth = 0;
62+
let start = 0;
63+
for (let index = 0; index < value.length; index += 1) {
64+
if (value[index] === '(') depth += 1;
65+
if (value[index] === ')') depth -= 1;
66+
if (/\s/u.test(value[index]!) && depth === 0) {
67+
if (start < index) values.push(value.slice(start, index));
68+
start = index + 1;
69+
}
70+
}
71+
if (start < value.length) values.push(value.slice(start));
72+
return values;
73+
}
74+
75+
type SwitchSize = '' | 'base' | 'small';
76+
77+
function switchVariables(size: Exclude<SwitchSize, ''>): Record<string, string> {
78+
return {
79+
'--switch-knob-size': declaration(size === 'base' ? '.switch' : '.switch.sm', '--switch-knob-size'),
80+
};
81+
}
82+
83+
const derivedSizes = [
84+
['.row', 'min-height', 34, {}],
85+
['.act', 'padding-top', 6, {}],
86+
['.act', 'padding-right', 12, {}],
87+
['.icon-btn', 'width', 24, {}],
88+
['.icon-btn', 'height', 24, {}],
89+
['.icon-btn svg', 'width', 15, {}],
90+
['.icon-btn svg', 'height', 15, {}],
91+
['.switch', 'width', 40, 'base'],
92+
['.switch', 'height', 22, 'base'],
93+
['.switch.sm', 'width', 30, 'small'],
94+
['.switch.sm', 'height', 17, 'small'],
95+
['.knob', 'width', 18, 'base'],
96+
['.knob', 'height', 18, 'base'],
97+
['.switch.sm .knob', 'width', 13, 'small'],
98+
['.switch.sm .knob', 'height', 13, 'small'],
99+
] as const;
100+
101+
function sizeValue(selector: string, property: string): string {
102+
if (selector === '.act' && property.startsWith('padding-')) {
103+
const padding = splitCssValues(declaration('.act', 'padding'));
104+
return property === 'padding-top' || property === 'padding-bottom' ? padding[0]! : padding[1]!;
105+
}
106+
return declaration(selector, property);
107+
}
108+
109+
describe('settings design tokens', () => {
110+
it('rejects raw colours and dark-mode utilities', () => {
111+
expect(css.match(/#[\da-f]{3,8}\b/giu) ?? []).toHaveLength(0);
112+
expect(css.match(/\brgba?\s*\(/gu) ?? []).toHaveLength(0);
113+
expect(css.match(/\bdark:/gu) ?? []).toHaveLength(0);
114+
});
115+
116+
it('allows only token, pill, and circle radii and maps the five named radii', () => {
117+
const radii = [...css.matchAll(/border-radius\s*:\s*([^;]+);/gu)].map((match) => match[1]!.trim());
118+
119+
expect(radii.length).toBeGreaterThan(0);
120+
expect(radii.filter((radius) => !/^(?:var\(--r-[\w-]+\)|999px|50%)$/u.test(radius))).toEqual([]);
121+
122+
const mappings = {
123+
'.tag': '--r-xs',
124+
'.act': '--r-sm',
125+
'.icon-btn': '--r-sm',
126+
'.page-search': '--r-sm',
127+
'.stat-card': '--r-md',
128+
} as const;
129+
for (const [selector, token] of Object.entries(mappings)) {
130+
expect(declaration(selector, 'border-radius')).toBe(`var(${token})`);
131+
}
132+
});
133+
134+
it('uses the ink token for the knob shadow', () => {
135+
expect(declaration('.knob', 'box-shadow')).toBe(
136+
'0 1px 2px color-mix(in srgb, var(--ink) 20%, transparent)',
137+
);
138+
});
139+
140+
it('keeps every listed control size at its current 14px value', () => {
141+
for (const [selector, property, expected, size] of derivedSizes) {
142+
const variables = size === '' ? {} : switchVariables(size);
143+
const value = sizeValue(selector, property);
144+
expect(value).toMatch(/calc\(|var\(--switch-knob-size\)/u);
145+
expect(resolvePx(value, 14, variables)).toBeCloseTo(expected, 5);
146+
}
147+
148+
expect(resolvePx(declaration('.switch.on .knob', 'transform').match(/^translateX\((.*)\)$/u)![1]!, 14, switchVariables('base'))).toBeCloseTo(18, 5);
149+
expect(transformDistance('.switch.sm.on .knob', switchVariables('small'), 14)).toBeCloseTo(13, 5);
150+
});
151+
152+
it('grows every listed control size when the UI font grows to 20px', () => {
153+
for (const [selector, property, _expected, size] of derivedSizes) {
154+
const variables = size === '' ? {} : switchVariables(size);
155+
expect(resolvePx(sizeValue(selector, property), 20, variables)).toBeGreaterThan(
156+
resolvePx(sizeValue(selector, property), 14, variables),
157+
);
158+
}
159+
expect(transformDistance('.switch.on .knob', switchVariables('base'), 20)).toBeGreaterThan(
160+
transformDistance('.switch.on .knob', switchVariables('base'), 14),
161+
);
162+
expect(transformDistance('.switch.sm.on .knob', switchVariables('small'), 20)).toBeGreaterThan(
163+
transformDistance('.switch.sm.on .knob', switchVariables('small'), 14),
164+
);
165+
});
166+
167+
it('derives switch tracks and travel from the same knob size', () => {
168+
for (const [fontSize, size, trackSelector, travelSelector] of [
169+
[14, 'base', '.switch', '.switch.on .knob'],
170+
[14, 'small', '.switch.sm', '.switch.sm.on .knob'],
171+
[20, 'base', '.switch', '.switch.on .knob'],
172+
[20, 'small', '.switch.sm', '.switch.sm.on .knob'],
173+
] as const) {
174+
const variables = switchVariables(size);
175+
const knob = resolvePx(variables['--switch-knob-size'], fontSize, variables);
176+
const trackWidth = resolvePx(declaration(trackSelector, 'width'), fontSize, variables);
177+
const trackHeight = resolvePx(declaration(trackSelector, 'height'), fontSize, variables);
178+
const travel = transformDistance(travelSelector, variables, fontSize);
179+
180+
expect(trackWidth).toBeCloseTo(knob + travel + 4, 5);
181+
expect(trackHeight).toBeCloseTo(knob + 4, 5);
182+
expect(travel).toBeCloseTo(knob, 5);
183+
}
184+
});
185+
});

0 commit comments

Comments
 (0)