mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-08-15 11:45:52 +00:00
* 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
246 lines
8.9 KiB
JavaScript
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);
|