diff --git a/.changeset/web-settings-tokens.md b/.changeset/web-settings-tokens.md new file mode 100644 index 00000000..c938b8d4 --- /dev/null +++ b/.changeset/web-settings-tokens.md @@ -0,0 +1,5 @@ +--- +'@pymodel/pythinker-code': patch +--- + +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. diff --git a/apps/pythinker-web/src/components/settings/SettingsNav.vue b/apps/pythinker-web/src/components/settings/SettingsNav.vue index 7fd8a03f..98fab230 100644 --- a/apps/pythinker-web/src/components/settings/SettingsNav.vue +++ b/apps/pythinker-web/src/components/settings/SettingsNav.vue @@ -46,7 +46,7 @@ const { t } = useI18n(); .tab { padding: 8px 10px; border: none; - border-radius: 7px; + border-radius: var(--r-sm); background: transparent; color: var(--muted); font-family: var(--sans); diff --git a/apps/pythinker-web/src/components/settings/pages/AgentPage.vue b/apps/pythinker-web/src/components/settings/pages/AgentPage.vue index 5e573c06..e40d3d34 100644 --- a/apps/pythinker-web/src/components/settings/pages/AgentPage.vue +++ b/apps/pythinker-web/src/components/settings/pages/AgentPage.vue @@ -201,17 +201,17 @@ function toggleConfigBoolean(key: 'defaultThinking' | 'defaultPlanMode' | 'merge .sec-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 10px; } .sec-head .sec-title { margin-bottom: 0; } .saving { flex: none; color: var(--muted); font-family: var(--mono); font-size: var(--ui-font-size-xs); } -.seg { display: inline-flex; overflow: hidden; border: 1px solid var(--line); border-radius: 8px; } +.seg { display: inline-flex; overflow: hidden; border: 1px solid var(--line); border-radius: var(--r-sm); } .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; } .opt:first-child { border-left: none; } .opt:hover { color: var(--ink); } .opt.on { background: var(--soft); color: var(--blue2); font-weight: 600; } .opt:disabled { opacity: 0.55; cursor: not-allowed; } -.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); } +.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); } .select-field:disabled { opacity: 0.6; cursor: not-allowed; } .empty-config { padding: 4px 0; color: var(--muted); font-family: var(--sans); font-size: calc(var(--ui-font-size) - 1px); } .provider-list { display: flex; flex-direction: column; gap: 6px; margin-top: 10px; } -.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); } +.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); } .provider-main, .provider-meta { display: flex; align-items: center; gap: 8px; min-width: 0; } .provider-main { flex: 1; } .provider-meta { flex: none; max-width: 45%; } diff --git a/apps/pythinker-web/src/components/settings/pages/GeneralPage.vue b/apps/pythinker-web/src/components/settings/pages/GeneralPage.vue index 2bc8fad5..5e5d1460 100644 --- a/apps/pythinker-web/src/components/settings/pages/GeneralPage.vue +++ b/apps/pythinker-web/src/components/settings/pages/GeneralPage.vue @@ -209,7 +209,7 @@ onUnmounted(() => { height: 30px; padding: 0 8px; border: 1px solid var(--line); - border-radius: 8px; + border-radius: var(--r-sm); background: var(--bg); } .num-input { @@ -223,7 +223,7 @@ onUnmounted(() => { text-align: right; } .num-unit { color: var(--muted); font-family: var(--mono); font-size: var(--ui-font-size-xs); } -.seg { display: inline-flex; overflow: hidden; border: 1px solid var(--line); border-radius: 8px; } +.seg { display: inline-flex; overflow: hidden; border: 1px solid var(--line); border-radius: var(--r-sm); } .opt { padding: 5px 12px; border: none; diff --git a/apps/pythinker-web/src/components/settings/settings.css b/apps/pythinker-web/src/components/settings/settings.css index 3f4ffd2b..2f323959 100644 --- a/apps/pythinker-web/src/components/settings/settings.css +++ b/apps/pythinker-web/src/components/settings/settings.css @@ -25,7 +25,7 @@ align-items: center; justify-content: space-between; gap: 12px; - min-height: 34px; + min-height: calc(var(--ui-font-size) + 20px); padding: 3px 0; } .rlabel { @@ -48,9 +48,9 @@ .rvalue.mono { font-family: var(--mono); font-size: var(--ui-font-size-xs); } .hint { color: var(--faint); font-family: var(--sans); font-size: calc(var(--ui-font-size) - 3px); } .act { - padding: 6px 12px; + padding: calc(var(--ui-font-size) - 8px) calc(var(--ui-font-size) - 2px); border: 1px solid var(--line); - border-radius: 7px; + border-radius: var(--r-sm); background: var(--bg); color: var(--ink); font-family: var(--sans); @@ -61,10 +61,11 @@ .act.signin { background: var(--blue); color: var(--bg); border-color: var(--blue); } .act.signin:hover { background: var(--blue2); } .switch { + --switch-knob-size: calc(var(--ui-font-size) + 4px); position: relative; flex: none; - width: 40px; - height: 22px; + width: calc(var(--switch-knob-size) * 2 + 4px); + height: calc(var(--switch-knob-size) + 4px); padding: 0; border: 1px solid var(--line); border-radius: 999px; @@ -78,21 +79,25 @@ position: absolute; top: 1px; left: 1px; - width: 18px; - height: 18px; + width: var(--switch-knob-size); + height: var(--switch-knob-size); border-radius: 50%; background: var(--bg); - box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2); + box-shadow: 0 1px 2px color-mix(in srgb, var(--ink) 20%, transparent); transition: transform 0.16s; } -.switch.on .knob { transform: translateX(18px); } -.switch.sm { width: 30px; height: 17px; } -.switch.sm .knob { width: 13px; height: 13px; } -.switch.sm.on .knob { transform: translateX(13px); } +.switch.on .knob { transform: translateX(var(--switch-knob-size)); } +.switch.sm { + --switch-knob-size: calc(var(--ui-font-size) - 1px); + width: calc(var(--switch-knob-size) * 2 + 4px); + height: calc(var(--switch-knob-size) + 4px); +} +.switch.sm .knob { width: var(--switch-knob-size); height: var(--switch-knob-size); } +.switch.sm.on .knob { transform: translateX(var(--switch-knob-size)); } .tag { flex: none; padding: 1px 6px; - border-radius: 5px; + border-radius: var(--r-xs); background: var(--soft); color: var(--muted); font-size: calc(var(--ui-font-size) - 3px); @@ -119,7 +124,7 @@ margin: 0 0 12px; padding: 7px 10px; border: 1px solid var(--line); - border-radius: 8px; + border-radius: var(--r-sm); background: var(--bg); color: var(--ink); font-family: var(--sans); @@ -214,16 +219,19 @@ flex: none; align-items: center; justify-content: center; - width: 24px; - height: 24px; + width: calc(var(--ui-font-size) + 10px); + height: calc(var(--ui-font-size) + 10px); padding: 0; border: none; - border-radius: 7px; + border-radius: var(--r-sm); background: transparent; color: var(--faint); cursor: pointer; } -.icon-btn svg { width: 15px; height: 15px; } +.icon-btn svg { + width: calc(var(--ui-font-size) + 1px); + height: calc(var(--ui-font-size) + 1px); +} .icon-btn:hover { background: var(--soft); color: var(--ink); } .listing-error { color: var(--err); @@ -240,7 +248,7 @@ gap: 4px; padding: 12px; border: 1px solid var(--line); - border-radius: 10px; + border-radius: var(--r-md); background: var(--panel); } .stat-label { color: var(--muted); font-size: calc(var(--ui-font-size) - 2px); } diff --git a/apps/pythinker-web/test/settings-tokens.test.ts b/apps/pythinker-web/test/settings-tokens.test.ts new file mode 100644 index 00000000..85b908d2 --- /dev/null +++ b/apps/pythinker-web/test/settings-tokens.test.ts @@ -0,0 +1,185 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +const css = readFileSync( + resolve(import.meta.dirname, '../src/components/settings/settings.css'), + 'utf8', +); + +function rule(selector: string): string { + const escaped = selector.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const match = css.match(new RegExp(`(?:^|\\n)${escaped}\\s*\\{([^{}]*)\\}`, 'u')); + if (!match?.[1]) throw new Error(`Missing CSS rule: ${selector}`); + return match[1]; +} + +function declaration(selector: string, property: string): string { + const escaped = property.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const match = rule(selector).match(new RegExp(`${escaped}\\s*:\\s*([^;]+);`, 'u')); + if (!match?.[1]) throw new Error(`Missing ${property} in ${selector}`); + return match[1].trim(); +} + +function resolvePx(value: string, fontSize: number, variables: Record = {}): number { + let expression = value.trim().replace(/^calc\((.*)\)$/u, '$1'); + expression = expression.replaceAll(/var\((--[\w-]+)\)/gu, (_match: string, name: string) => { + if (name === '--ui-font-size') return String(fontSize); + const variable = variables[name]; + if (variable === undefined) throw new Error(`Missing test variable: ${name}`); + return String(resolvePx(variable, fontSize, variables)); + }); + expression = expression.replaceAll(/px\b/gu, ''); + if (!/^[\d\s.+*()-]+$/u.test(expression)) throw new Error(`Unsupported CSS arithmetic: ${value}`); + + // `calc()` requires whitespace around + and -, so splitting on the spaced + // operator is both safe and a check that the expression is valid CSS. + const parts = expression.split(/\s+([+-])\s+/u); + let sum = 0; + let sign = 1; + for (const part of parts) { + if (part === '+' || part === '-') { + sign = part === '+' ? 1 : -1; + continue; + } + const product = part.split(/\s*\*\s*/u).reduce((acc, factor) => acc * Number(factor), 1); + if (Number.isNaN(product)) throw new Error(`Unsupported CSS arithmetic: ${value}`); + sum += sign * product; + } + return sum; +} + +function transformDistance(selector: string, variables: Record, fontSize: number): number { + const transform = declaration(selector, 'transform'); + const match = transform.match(/^translateX\((.*)\)$/u); + if (!match?.[1]) throw new Error(`Missing translateX in ${selector}`); + return resolvePx(match[1], fontSize, variables); +} + +function splitCssValues(value: string): string[] { + const values: string[] = []; + let depth = 0; + let start = 0; + for (let index = 0; index < value.length; index += 1) { + if (value[index] === '(') depth += 1; + if (value[index] === ')') depth -= 1; + if (/\s/u.test(value[index]!) && depth === 0) { + if (start < index) values.push(value.slice(start, index)); + start = index + 1; + } + } + if (start < value.length) values.push(value.slice(start)); + return values; +} + +type SwitchSize = '' | 'base' | 'small'; + +function switchVariables(size: Exclude): Record { + return { + '--switch-knob-size': declaration(size === 'base' ? '.switch' : '.switch.sm', '--switch-knob-size'), + }; +} + +const derivedSizes = [ + ['.row', 'min-height', 34, {}], + ['.act', 'padding-top', 6, {}], + ['.act', 'padding-right', 12, {}], + ['.icon-btn', 'width', 24, {}], + ['.icon-btn', 'height', 24, {}], + ['.icon-btn svg', 'width', 15, {}], + ['.icon-btn svg', 'height', 15, {}], + ['.switch', 'width', 40, 'base'], + ['.switch', 'height', 22, 'base'], + ['.switch.sm', 'width', 30, 'small'], + ['.switch.sm', 'height', 17, 'small'], + ['.knob', 'width', 18, 'base'], + ['.knob', 'height', 18, 'base'], + ['.switch.sm .knob', 'width', 13, 'small'], + ['.switch.sm .knob', 'height', 13, 'small'], +] as const; + +function sizeValue(selector: string, property: string): string { + if (selector === '.act' && property.startsWith('padding-')) { + const padding = splitCssValues(declaration('.act', 'padding')); + return property === 'padding-top' || property === 'padding-bottom' ? padding[0]! : padding[1]!; + } + return declaration(selector, property); +} + +describe('settings design tokens', () => { + it('rejects raw colours and dark-mode utilities', () => { + expect(css.match(/#[\da-f]{3,8}\b/giu) ?? []).toHaveLength(0); + expect(css.match(/\brgba?\s*\(/gu) ?? []).toHaveLength(0); + expect(css.match(/\bdark:/gu) ?? []).toHaveLength(0); + }); + + it('allows only token, pill, and circle radii and maps the five named radii', () => { + const radii = [...css.matchAll(/border-radius\s*:\s*([^;]+);/gu)].map((match) => match[1]!.trim()); + + expect(radii.length).toBeGreaterThan(0); + expect(radii.filter((radius) => !/^(?:var\(--r-[\w-]+\)|999px|50%)$/u.test(radius))).toEqual([]); + + const mappings = { + '.tag': '--r-xs', + '.act': '--r-sm', + '.icon-btn': '--r-sm', + '.page-search': '--r-sm', + '.stat-card': '--r-md', + } as const; + for (const [selector, token] of Object.entries(mappings)) { + expect(declaration(selector, 'border-radius')).toBe(`var(${token})`); + } + }); + + it('uses the ink token for the knob shadow', () => { + expect(declaration('.knob', 'box-shadow')).toBe( + '0 1px 2px color-mix(in srgb, var(--ink) 20%, transparent)', + ); + }); + + it('keeps every listed control size at its current 14px value', () => { + for (const [selector, property, expected, size] of derivedSizes) { + const variables = size === '' ? {} : switchVariables(size); + const value = sizeValue(selector, property); + expect(value).toMatch(/calc\(|var\(--switch-knob-size\)/u); + expect(resolvePx(value, 14, variables)).toBeCloseTo(expected, 5); + } + + expect(resolvePx(declaration('.switch.on .knob', 'transform').match(/^translateX\((.*)\)$/u)![1]!, 14, switchVariables('base'))).toBeCloseTo(18, 5); + expect(transformDistance('.switch.sm.on .knob', switchVariables('small'), 14)).toBeCloseTo(13, 5); + }); + + it('grows every listed control size when the UI font grows to 20px', () => { + for (const [selector, property, _expected, size] of derivedSizes) { + const variables = size === '' ? {} : switchVariables(size); + expect(resolvePx(sizeValue(selector, property), 20, variables)).toBeGreaterThan( + resolvePx(sizeValue(selector, property), 14, variables), + ); + } + expect(transformDistance('.switch.on .knob', switchVariables('base'), 20)).toBeGreaterThan( + transformDistance('.switch.on .knob', switchVariables('base'), 14), + ); + expect(transformDistance('.switch.sm.on .knob', switchVariables('small'), 20)).toBeGreaterThan( + transformDistance('.switch.sm.on .knob', switchVariables('small'), 14), + ); + }); + + it('derives switch tracks and travel from the same knob size', () => { + for (const [fontSize, size, trackSelector, travelSelector] of [ + [14, 'base', '.switch', '.switch.on .knob'], + [14, 'small', '.switch.sm', '.switch.sm.on .knob'], + [20, 'base', '.switch', '.switch.on .knob'], + [20, 'small', '.switch.sm', '.switch.sm.on .knob'], + ] as const) { + const variables = switchVariables(size); + const knob = resolvePx(variables['--switch-knob-size'], fontSize, variables); + const trackWidth = resolvePx(declaration(trackSelector, 'width'), fontSize, variables); + const trackHeight = resolvePx(declaration(trackSelector, 'height'), fontSize, variables); + const travel = transformDistance(travelSelector, variables, fontSize); + + expect(trackWidth).toBeCloseTo(knob + travel + 4, 5); + expect(trackHeight).toBeCloseTo(knob + 4, 5); + expect(travel).toBeCloseTo(knob, 5); + } + }); +});