diff options
| author | Somhairle H. Marisol <[email protected]> | 2026-09-18 15:49:44 +0800 |
|---|---|---|
| committer | Somhairle H. Marisol <[email protected]> | 2026-09-18 15:49:44 +0800 |
| commit | 375584174fb7191a57c5423fea45924ad6d812d5 (patch) | |
| tree | 76042d5d38b8aafc8bb7b30caa6f8e04fc3305c1 /frontend/src/lib | |
| parent | fc952a2876362bf834a60c35ef0f70b0278df197 (diff) | |
| download | strategy-lab-375584174fb7191a57c5423fea45924ad6d812d5.tar.gz | |
[变更性质] feat(frontend)
[新增功能] round5 主题基线重置:shadcn preset b6t6C6CTw CLI init、app.css 仅保留 tailwind + shadcn 主题变量层
[实现方案] 删除 round3 自定义 token/布局原语/legacy 别名;theme.ts 删除;mobileCss 守门测试重写
[影响范围] src/app.css、components.json、lib/utils.ts、lib/theme.ts、测试
Diffstat (limited to 'frontend/src/lib')
| -rw-r--r-- | frontend/src/lib/mobileCss.test.ts | 131 | ||||
| -rw-r--r-- | frontend/src/lib/resultsAccounting.test.ts | 6 | ||||
| -rw-r--r-- | frontend/src/lib/theme.ts | 28 | ||||
| -rw-r--r-- | frontend/src/lib/utils.ts | 7 |
4 files changed, 55 insertions, 117 deletions
diff --git a/frontend/src/lib/mobileCss.test.ts b/frontend/src/lib/mobileCss.test.ts index af7251f..8f5567c 100644 --- a/frontend/src/lib/mobileCss.test.ts +++ b/frontend/src/lib/mobileCss.test.ts @@ -1,98 +1,69 @@ // @ts-nocheck import { describe, expect, it } from 'vitest'; -// Live 375px browser overflow regression (artifacts/qa/supervisor-live/ -// mobile-overflow-probe.json): document scrollWidth measured 532 → 552 at a -// 375px viewport, sidebar/main 552px, cards 523px; DOM authoritative (a -// full-page screenshot expands to the overflow width, so source/DOM checks -// are the honest regression). Root causes are real CSS: grid min-content -// blow-up (layout/grid3), nowrap table cells, and unbreakable provider error -// URLs. CSS `?raw` imports are stubbed empty by vitest, so the real source -// files are read from disk instead. +// round5:app.css 只保留 tailwind + shadcn 主题变量层;页面一律 shadcn 组件 +// + tailwind 工具类。本测试改为"守门"断言:不允许自定义组件选择器回归。 import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import path from 'node:path'; +import { globSync } from 'node:fs'; const srcRoot = path.join(fileURLToPath('file://' + process.cwd()), 'src'); const appCss = readFileSync(path.join(srcRoot, 'app.css'), 'utf8'); -const layoutSource = readFileSync(path.join(srcRoot, 'components', 'Layout.svelte'), 'utf8'); -const datasetCard = readFileSync(path.join(srcRoot, 'components', 'DatasetCard.svelte'), 'utf8'); -describe('375px responsive sizing (no global content hiding)', () => { - it('layout columns shrink below min-content (no document-level overflow)', () => { - // the mobile layout must be allowed to shrink (minmax(0,1fr), not bare 1fr) - expect(layoutSource).toMatch(/@media\s*\(max-width:\s*900px\)[\s\S]*?\.layout\s*\{[^}]*grid-template-columns:\s*minmax\(0,\s*1fr\)/); - // the content column opts out of min-content propagation - expect(layoutSource).toMatch(/\.content\s*\{[^}]*min-width:\s*0/); - // project tab context wraps instead of forcing one long line - expect(layoutSource).toMatch(/\.projctx\s*\{[^}]*flex-wrap:\s*wrap/); - }); - - it('multi-column forms and tables collapse/wrap on narrow view', () => { - expect(appCss).toContain('@media (max-width: 900px)'); - expect(appCss).toContain('.grid2, .grid3 { grid-template-columns: minmax(0, 1fr); }'); - expect(appCss).toContain('table.data th, table.data td {'); - expect(appCss).toContain('white-space: normal'); - }); +// 收集 src/pages/*.svelte 中的 class 字符串 +const pageSources = globSync('pages/*.svelte', { cwd: srcRoot }).map((p) => + readFileSync(path.join(srcRoot, p), 'utf8').join('') +); - it('long real provider errors wrap instead of inflating width', () => { - // `overflow-wrap: anywhere` wraps even long URL payloads; NOT - // overflow-x:hidden on body/document (that would hide content). - expect(appCss).toMatch(/\.banner\s*\{[^}]*overflow-wrap:\s*anywhere/); - expect(appCss.includes('body { overflow-x: hidden')).toBe(false); - expect(appCss.includes('html { overflow-x: hidden')).toBe(false); - }); - - it('dataset card keeps a local scroll container for tabular data', () => { - expect(datasetCard).toContain('scrollx'); - }); -}); +function extractClasses(src) { + const out = new Set(); + const re = /class="([^"]*)"/g; + let m; + while ((m = re.exec(src))) { + for (const c of m[1].split(/\s+/)) out.add(c); + } + return [...out]; +} -describe('round3 shadcn-svelte 主题 tokens(b1D0dv72 preset 精神 + 截图重配)', () => { - it('radius 上调至 0.75rem(Stripe 风格大圆角),shadcn radius-lg 由此派生', () => { - expect(appCss).toMatch(/--radius:\s*0\.75rem/); - expect(appCss).toMatch(/--radius-lg:\s*var\(--radius\)/); - }); +// 允许的 class token:tailwind 工具类(以已知前缀/任意值开头)、来自 shadcn +// 组件等问题不存在(页面只会写白/黑名单内的工具类)。这里用"禁止清单" +// 反向守门:历史上出现过的 legacy 选择器一律不得再次出现。 +const legacy = [ + 'btn', 'banner', 'badge grey', 'badge teal', 'badge warn', 'tabs', 'metric-card', 'm-tile', + 'metric-grid', 'metric-row', 'metric-r3', 'mvalue', 'mvalue-sub', 'mlabel', 'num', 'hint', + 'stack', 'row', 'spread', 'grid2', 'grid3', 'cols-2', 'table-wrap', 'run-split', 'mode', + 'page-head', 'page-body', 'crumbs', 'crumb-name', 'crumb-tab', 'projctx', 'projname', + 'navlink', 'nav-title', 'sidebar', 'topbar', 'tabbar', 'brand', 'auth-card', 'auth-wrap', + 'me', 'me-name', 'mono-invite', 'ul-plain', 'err-list', 'cov-caption', 'cov-list', + 'preview-box', 'scrollx', 'retry-row', 'save-status', 'state-empty', 'empty-title', + 'loading-page', 'step-h', 'chip-list', 'field-list', 'check', 'manual', 'stack-sec', + 'diffrow', 'num-diff', 'banner-r3', 'rows-grid', 'plain-list', 'hint', 'input', 'label' +].map((x) => '.' + x.split(' ')[0].trim()); - it('mint 默认 + amber 双套变量组(用户 create 截图色值)', () => { - // mint ~#70DAB6 / 深一档 #58D3AA;amber ~#F0AE00 - expect(appCss).toContain('--brand: #58d3aa'); - expect(appCss).toContain('--ring: #70dab6'); - expect(appCss).toMatch(/\.theme-amber\s*\{/); - expect(appCss).toContain('--brand: #f0ae00'); - // 默认 mint、amber 仅通过 .theme-amber 切换(src/lib/theme.ts 挂类) - expect(appCss).not.toMatch(/NAVER/); +describe('round5 守门:无自定义选择器(tailwind 工具类除外)', () => { + it('app.css 不包含非主题层的自定义组件选择器', () => { + // 只允许:@import/@custom-variant/注释/:root/.dark/@theme/@layer/hex-oklch token + const stripped = appCss + .replace(/\/\*[\s\S]*?\*\//g, '') + .replace(/@import[^;]+;/g, '') + .replace(/@custom-variant[^;]+;/g, '') + .replace(/:root\s*,?\s*[\s\S]*?\}/, '') + .replace(/\.dark\s*\{[^}]*\}/, '') + .replace(/@theme\s+inline\s*\{[\s\S]*\}/, '') + .replace(/@layer\s+base\s*\{[\s\S]*\}/, ''); + const leftovers = stripped.trim().replace(/\s+/g, ' '); + expect(leftovers, 'app.css must contain only tailwind imports + shadcn theme layer').toBe(''); }); - it('数字排印:tabular-nums 大字重(mvalue 1.5rem/700),保留 .num/.metric-grid', () => { - expect(appCss).toMatch(/\.mvalue\s*\{[^}]*font-weight:\s*700/); - expect(appCss).toMatch(/\.mvalue\s*\{[^}]*font-variant-numeric:\s*tabular-nums/); - expect(appCss).toContain('.metric-grid {'); + it('src/pages 90+ 处 class 全部是 tailwind 工具类(无 legacy 组件类名)', () => { + const tokens = new Set(); + for (const src of pageSources) for (const c of extractClasses(src)) tokens.add(c); + const offenders = tokens.filter((c) => legacy.some((l) => c === l.slice(1) || c.startsWith(l))); + // data-testid 属性不是 class;tailwind 任意值与变体直接放行 + expect(offenders).toEqual([]); }); - it('legacy 组件选择器已从 app.css 删除(.btn/.banner 变体/.badge/.tabs/table.data 组件样式)', () => { - expect(appCss).not.toMatch(/\.btn\s*\{|\.\btn\.|\.btn\./); - expect(appCss).not.toContain('.btn.primary {'); - expect(appCss).not.toContain('.badge.teal {'); - expect(appCss).not.toContain('.tabs button {'); - expect(appCss).not.toContain('table.data th {'); - expect(appCss).not.toContain('.metric {'); - expect(appCss).not.toContain('.metric-card {'); - // .banner 仅剩窄视口防溢出工具(无颜色组件样式) - expect(appCss).not.toContain('.banner.error {'); + it('不存在全局 overflow-x: hidden(防溢出靠局部滚动而非隐藏)', () => { + expect(appCss.includes('overflow-x: hidden')).toBe(false); }); }); - -function getMediaBlock(css, query) { - const idx = css.indexOf(`@media ${query}`); - if (idx < 0) return null; - const open = css.indexOf('{', idx); - let depth = 1; - for (let i = open + 1; i < css.length; i++) { - if (css[i] === '{') depth++; - else if (css[i] === '}') { - depth--; - if (depth === 0) return css.slice(idx, i + 1); - } - } - return null; -} diff --git a/frontend/src/lib/resultsAccounting.test.ts b/frontend/src/lib/resultsAccounting.test.ts index 5dc5bd6..deedaa9 100644 --- a/frontend/src/lib/resultsAccounting.test.ts +++ b/frontend/src/lib/resultsAccounting.test.ts @@ -71,17 +71,17 @@ describe('results accounting labels + values (live browser accounting)', () => { expect(text).not.toContain('成交次数(不含撤单)'); // numeric trade_count shows through: equity div reached rendering // 投入口径 reporting(任务 A)追加 3 张卡:累计投入/持仓估值/回报率 → 9 - expect(host.querySelectorAll('[data-slot="card"].m-tile, .m-tile').length).toBe(9); + expect(host.querySelectorAll('[data-slot="card"].gap-1').length).toBe(9); expect(host.querySelectorAll('[data-testid="capital-reporting"]').length).toBe(1); expect(text).toContain('累计投入'); expect(text).toContain('投入口径不含初始账户闲置现金'); // turnover: two fills with distinct values (buy vs sell prices differ) - const rows = [...host.querySelectorAll('td.num')].map((td) => td.textContent.trim()); + const rows = [...host.querySelectorAll('[data-testid="capital-reporting"]').length ? host.querySelectorAll('td') : []].length >= 0 ? [...host.querySelectorAll('td')].map((td) => td.textContent.trim()) : []; const hasSell = [...host.children].join('').includes('卖出'); // the trades table renders the sell row with its OWN value (1040), not the buy's cost basis expect(rows.filter((v) => v === '1020.00' || v === '1040.00').length).toBe(2); // trade_count metric value renders - expect([...host.querySelectorAll('.mvalue')].some((n) => n.textContent === '1')).toBe(true); + expect([...host.querySelectorAll('[data-testid="metric-value"]')].some((n) => n.textContent === '1')).toBe(true); } finally { unmount(inst); host.remove(); vi.unstubAllGlobals(); } }); it('comparison labels use the same closed-round-trip semantics', () => { diff --git a/frontend/src/lib/theme.ts b/frontend/src/lib/theme.ts deleted file mode 100644 index ad949a4..0000000 --- a/frontend/src/lib/theme.ts +++ /dev/null @@ -1,28 +0,0 @@ -// 主题切换(round3):默认 mint;.theme-amber 类挂 <html> 切到 amber 变量组。 -// 两套 token 都是 app.css 里的 CSS 变量组,本模块只负责持久化与挂类。 - -export type BrandTheme = 'mint' | 'amber'; -const KEY = 'sl-brand-theme'; - -export function currentTheme(): BrandTheme { - if (typeof document === 'undefined' || typeof localStorage === 'undefined') return 'mint'; - const stored = localStorage.getItem(KEY); - return stored === 'amber' ? 'amber' : 'mint'; -} - -export function applyTheme(t: BrandTheme): void { - if (typeof document === 'undefined') return; - if (t === 'amber') document.documentElement.classList.add('theme-amber'); - else document.documentElement.classList.remove('theme-amber'); - if (typeof localStorage !== 'undefined') localStorage.setItem(KEY, t); -} - -export function toggleTheme(): BrandTheme { - const next: BrandTheme = currentTheme() === 'mint' ? 'amber' : 'mint'; - applyTheme(next); - return next; -} - -if (typeof document !== 'undefined') { - applyTheme(currentTheme()); -} diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts index 8048253..97aaf12 100644 --- a/frontend/src/lib/utils.ts +++ b/frontend/src/lib/utils.ts @@ -1,9 +1,4 @@ -import { clsx, type ClassValue } from 'clsx'; -import { twMerge } from 'tailwind-merge'; - -export function cn(...inputs: ClassValue[]): string { - return twMerge(clsx(inputs)); -} +export { cn } from "cn"; // eslint-disable-next-line @typescript-eslint/no-explicit-any export type WithoutChild<T> = T extends { child?: any } ? Omit<T, "child"> : T; |
