kimi-code/apps/kimi-web/scripts/check-style.mjs
qer 01b65bdddc
feat(web): open design-system easter egg at /design-system route (#1328)
* feat(web): open design-system easter egg at /design-system route

Replace the long-press-logo iframe overlay, which loaded a separately maintained static design-system.html, with a real /design-system route. The new view aliases to the product design tokens, so the design system is maintained in one place. Adds vue-router for the route, removes the duplicate static HTML copies, and exempts the showcase view from the style scanner.

* fix(web): lazy-load the design-system view

Address Codex review: load the 2.4k-line showcase via defineAsyncComponent so it is code-split and fetched only when /design-system is visited, instead of bloating the initial bundle for every load.

* chore(nix): update pnpmDeps hash for vue-router

Adding vue-router changed pnpm-lock.yaml, which invalidated the fetchPnpmDeps hash. Set it to the value reported by the nix build.

* fix(web): return to app root when closing the design system

In-page nav anchors push hash history entries, so router.back() only stepped through them and required multiple clicks to leave. Navigate to / directly; the client lives above the route so session state is preserved.

* feat(web): let /design-system bypass the auth gate

Render the design-system route ahead of the auth/server gates and skip the /login rewrite for it, so a direct deep link shows the showcase even before the app is OAuth-ready. The view is read-only and holds no user data, matching the old static page behavior.

* fix(web): make the design-system root scrollable

The route renders inside .app-shell (height:100dvh; overflow:hidden), so a position:fixed root could be clipped. Make .ds-page a flex item that fills the shell and scrolls internally, so later sections and hash navigation remain reachable.

* fix(web): preserve /design-system during initial session load

On load the app auto-selects the first session and rewrites the URL to /sessions/<id>, which clobbered a deep-linked /design-system. Skip the session URL write while on the design-system route so refreshing keeps the route.

* fix(web): restore the prior session URL when leaving the design system

Record the URL on entry to /design-system and navigate back to it on close, so a /sessions/<id> URL is preserved instead of falling back to /. This also keeps the earlier fix that sidesteps in-page hash anchors.

* fix(web): capture the real browser URL before opening the design system

Session URLs are rewritten via the native history API, so vue-router's from.fullPath can be stale ('/' after a session is selected). Read window.location on entry instead, falling back to / for a direct deep link.

* fix(web): sync the active session URL when leaving the design system

After a direct deep link to /design-system the app auto-selects a session but the address stays '/'. On close, fall back to the active session's canonical URL so the address bar matches the displayed session.

* fix(web): capture the design-system return path at logo entry

Capture window.location in the logo long-press handler instead of a navigation guard. The guard fired on browser Back/Forward too and overwrote the return path with the design-system URL itself; capturing only at the explicit entry action avoids that.

* fix(web): replace the design-system route when closing

Use router.replace instead of push for the captured return URL, so closing does not append a second app URL after /design-system and the browser Back button returns to the page before the easter egg.

* revert(web): drop the design-system auth-gate bypass

Keep /design-system behind the auth gate so it has a single in-app entry (logo long-press). This removes the deep-link machinery (auth exemption, active-session fallback) that drove most of the URL/session edge cases, while keeping the lazy-loaded route, the flex scroll fix, the logo-entry return-path capture, and replace-on-close.

* refactor(web): rebuild the design-system easter egg as an in-app overlay

The easter egg is a hidden, read-only spec viewer opened by long-pressing the logo; it does not need to be a URL route. Replace the vue-router approach with an overlay: Sidebar opens a lazy-loaded DesignSystemView in a body-teleported full-screen overlay, dismissed by the Back button or Escape. This removes vue-router, the route, the auth-gate exemption, the session-URL guards, and the return-path machinery — none of which the feature needs.

* chore(nix): restore pnpmDeps hash after removing vue-router
2026-07-03 13:42:27 +08:00

246 lines
8.9 KiB
JavaScript

#!/usr/bin/env node
// check-style.mjs — design-system §06 anti-pattern guard for apps/kimi-web.
//
// Scans src/** for the rules in the design system (§06 of the DesignSystemView spec):
// no-gradient-text, no-glassmorphism (.frost exempt), no-color-glow,
// icon-from-registry (hand-written <svg>; Icon/Spinner/MoonSpinner + the
// 32x22 brand mark exempt), no-emoji-icon (moon in MoonSpinner exempt),
// no-hardcoded-hex (DiffView/DiffLines/Terminal domain colors + var()
// fallbacks exempt), no-hardcoded-font (token definitions exempt),
// radius-from-scale, z-from-scale, weight-from-scale.
//
// Default mode: report a baseline and exit 0 (warnings only). Pass --strict
// to exit 1 when any finding exists (flipped on in P3 enforcement).
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, '..');
const SRC = path.join(ROOT, 'src');
const STRICT = process.argv.includes('--strict');
const DOMAIN_HEX_EXEMPT = new Set([
'chat/DiffView.vue',
'chat/DiffLines.vue',
'Terminal.vue',
]);
// Files that legitimately render their own <svg>: the icon primitive itself,
// bespoke data-viz / colored illustrations, the spinner, and brand marks
// (the Kimi wordmark on the loading screen). Everything else should use
// lib/icons.ts via <Icon>/iconSvg(). The 32x22 Kimi eye logo is also exempted
// inline (matched by viewBox).
const ICON_EXEMPT = new Set([
'components/ui/Icon.vue',
'components/ui/Spinner.vue',
'components/ui/MoonSpinner.vue',
'components/ui/ContextRing.vue',
'components/ui/AuthStateIcon.vue',
'components/GlobalLoading.vue',
]);
// Files entirely exempt from the §06 scan. The design-system showcase view is
// documentation/demo CSS (forced-dark previews, syntax-highlighting palettes,
// illustrative mockups) rather than product UI, so the anti-pattern rules do not
// apply to it.
const FILE_EXEMPT = new Set(['views/DesignSystemView.vue']);
const RADIUS_SCALE = new Set([4, 6, 8, 12, 16, 20, 999]);
const WEIGHT_OK = new Set([
'400', '500',
'normal', 'bolder', 'lighter',
'inherit', 'initial', 'unset', 'revert',
]);
const Z_OK = new Set(['0', '1', '-1', 'auto']);
/** @type {{ rule: string, file: string, line: number, detail: string }[]} */
const findings = [];
function walk(dir, out = []) {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (entry.name === 'node_modules' || entry.name === 'dist') continue;
const full = path.join(dir, entry.name);
if (entry.isDirectory()) walk(full, out);
else if (/\.(vue|css)$/.test(entry.name)) out.push(full);
}
return out;
}
function rel(abs) {
return path.relative(SRC, abs).replaceAll(path.sep, '/');
}
function lineOf(text, index) {
let n = 1;
for (let i = 0; i < index; i++) if (text.charCodeAt(i) === 10) n++;
return n;
}
function add(rule, file, line, detail) {
findings.push({ rule, file, line, detail });
}
function stripVarSpans(line) {
// Remove var(...) substrings so var() fallbacks don't trip hex checks.
return line.replace(/var\([^()]*(?:\([^()]*\)[^()]*)*\)/g, '');
}
function extractStyleBlocks(content) {
// For .vue: return [{text, baseLine}] for each <style> block.
// For .css: single block = whole file.
const blocks = [];
const re = /<style\b[^>]*>([\s\S]*?)<\/style>/gi;
let m;
while ((m = re.exec(content)) !== null) {
blocks.push({ text: m[1], baseLine: lineOf(content, m.index) });
}
return blocks;
}
function checkFile(abs) {
const content = fs.readFileSync(abs, 'utf8');
const file = rel(abs);
if (FILE_EXEMPT.has(file)) return;
const isCss = abs.endsWith('.css');
const blocks = isCss ? [{ text: content, baseLine: 1 }] : extractStyleBlocks(content);
const domainExempt = DOMAIN_HEX_EXEMPT.has(file);
for (const { text, baseLine } of blocks) {
const lines = text.split('\n');
for (let i = 0; i < lines.length; i++) {
const raw = lines[i];
const line = baseLine + i;
const trimmed = raw.trim();
const isTokenDef = /^\s*--[\w-]+\s*:/.test(raw);
// no-gradient-text
if (/\b(?:linear|radial|conic)-gradient\s*\(/i.test(raw)) {
add('no-gradient-text', file, line, trimmed.slice(0, 80));
}
// no-glassmorphism (TopBar frost variant exempt)
if (/backdrop-filter\s*:/i.test(raw) && !/\bfrost\b/.test(text)) {
add('no-glassmorphism', file, line, trimmed.slice(0, 80));
}
// no-hardcoded-font (skip token definitions)
if (/font-family\s*:/i.test(raw) && !isTokenDef) {
const val = raw.split(':').slice(1).join(':');
if (!/var\(/.test(val) && /["']/.test(val)) {
add('no-hardcoded-font', file, line, trimmed.slice(0, 80));
}
}
// no-hardcoded-hex (token sheet *.css + domain files + var() fallbacks exempt)
if (!domainExempt && !isCss) {
const scannable = stripVarSpans(raw);
const hexRe = /#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})\b/g;
let h;
while ((h = hexRe.exec(scannable)) !== null) {
add('no-hardcoded-hex', file, line, `${h[0]} · ${trimmed.slice(0, 70)}`);
}
}
// radius-from-scale (report once per declaration)
const rMatch = raw.match(/border-radius\s*:\s*([^;}]+)/i);
if (rMatch) {
const tokens = rMatch[1].trim().split(/\s+/);
const bad = [];
for (const t of tokens) {
if (t.startsWith('var(') || t === '0' || t === '0px' || t.endsWith('%')) continue;
const px = t.match(/^(\d+(?:\.\d+)?)px$/);
if (px && RADIUS_SCALE.has(Number(px[1]))) continue;
if (!bad.includes(t)) bad.push(t);
}
if (bad.length) add('radius-from-scale', file, line, `${bad.join(' ')} · ${trimmed.slice(0, 50)}`);
}
// z-from-scale
const zMatch = raw.match(/z-index\s*:\s*([^;}]+)/i);
if (zMatch) {
const v = zMatch[1].trim();
if (!(v.startsWith('var(') || Z_OK.has(v))) {
add('z-from-scale', file, line, `${v} · ${trimmed.slice(0, 60)}`);
}
}
// weight-from-scale
const wMatch = raw.match(/font-weight\s*:\s*([^;}]+)/i);
if (wMatch) {
const v = wMatch[1].trim();
if (!(v.startsWith('var(') || WEIGHT_OK.has(v))) {
add('weight-from-scale', file, line, `${v} · ${trimmed.slice(0, 60)}`);
}
}
}
// no-color-glow (block-level heuristic: colored shadow with large blur) — warning only
const shadowRe = /box-shadow\s*:[^;}]*?(?:rgba?\([^)]*?\)|hsla?\([^)]*?\)|#[0-9a-fA-F]{3,8})[^;}]*?(?:\d{2,})px/gi;
let s;
while ((s = shadowRe.exec(text)) !== null) {
const glowLine = baseLine + lineOf(text, s.index) - 1;
add('no-color-glow(warn)', file, glowLine, s[0].slice(0, 80));
}
}
// icon-from-registry (warning only): hand-written <svg> in templates should
// come from lib/icons.ts via <Icon>/iconSvg(). Exempt the brand mark (32x22
// logo) and the primitive components listed in ICON_EXEMPT. Skips <svg> that
// falls inside <style>/<script> blocks.
if (!isCss && !ICON_EXEMPT.has(file)) {
const blockRanges = [...content.matchAll(/<(?:style|script)\b[^>]*>[\s\S]*?<\/(?:style|script)>/gi)]
.map((m) => [m.index, m.index + m[0].length]);
const inBlock = (idx) => blockRanges.some(([a, b]) => idx >= a && idx < b);
const svgRe = /<svg\b[^>]*>/gi;
let m;
while ((m = svgRe.exec(content)) !== null) {
if (inBlock(m.index)) continue;
if (/viewBox="0 0 32 22"/.test(m[0])) continue; // Kimi brand mark
add('icon-from-registry(warn)', file, lineOf(content, m.index), m[0].slice(0, 80));
}
}
}
const files = walk(SRC);
for (const f of files) checkFile(f);
// Report
const byRule = new Map();
for (const f of findings) {
if (!byRule.has(f.rule)) byRule.set(f.rule, []);
byRule.get(f.rule).push(f);
}
const order = [
'no-gradient-text', 'no-glassmorphism', 'no-color-glow(warn)',
'icon-from-registry(warn)',
'no-hardcoded-hex', 'no-hardcoded-font', 'radius-from-scale',
'z-from-scale', 'weight-from-scale',
];
let total = 0;
for (const rule of order) {
const list = byRule.get(rule) || [];
if (list.length === 0) continue;
total += list.length;
console.log(`\n${rule}${list.length}`);
for (const f of list.slice(0, 12)) {
console.log(` ${f.file}:${f.line} ${f.detail}`);
}
if (list.length > 12) console.log(` … and ${list.length - 12} more`);
}
// Any rules not in the explicit order
for (const [rule, list] of byRule) {
if (order.includes(rule)) continue;
total += list.length;
console.log(`\n${rule}${list.length}`);
for (const f of list.slice(0, 12)) console.log(` ${f.file}:${f.line} ${f.detail}`);
}
const warnOnly = [...byRule.keys()].every((r) => r.endsWith('(warn)'));
console.log(`\ncheck-style: ${total} finding(s) across ${byRule.size} rule(s).${STRICT ? '' : ' (baseline mode — not failing)'}`);
if (STRICT && total > 0 && !warnOnly) process.exit(1);