chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:12:33 +08:00
commit aacb60a4af
3387 changed files with 981307 additions and 0 deletions
@@ -0,0 +1,78 @@
import { readFileSync } from 'node:fs'
import { join, relative } from 'node:path'
import { fileURLToPath } from 'node:url'
import { walkFiles } from './lib/fs-walk.mjs'
const root = fileURLToPath(new URL('..', import.meta.url))
const srcRoot = join(root, 'src')
const allowedDesktopGlobal = new Set([
'src/platform/capabilities.ts',
'src/platform/desktop.ts',
'src/vite-env.d.ts',
])
const bannedPatterns = [
{
pattern: 'window.opensquillaDesktop',
allow: allowedDesktopGlobal,
message: 'Electron preload access must stay behind src/platform/.',
},
]
const stalePlatformPatterns = [
'activeProfile',
'cloudUrl',
'getDesktopRpcConnection',
'desktop:rpc-connection',
]
// live-turn fold fence: the append-only turn log and its pure reducer are an internal
// live-turn detail. Keep their imports inside the chat composables (where the
// legacy live refs live) plus ChatView, so the new path cannot leak into other
// layers before it is ever authoritative.
function isUnderChatComposables(rel) {
const normalized = rel.split('\\').join('/')
return normalized.startsWith('src/composables/chat/')
}
function isChatView(rel) {
return rel.split('\\').join('/') === 'src/views/ChatView.vue'
}
const turnLogModulePatterns = [
'@/composables/chat/useChatTurnLog',
'@/composables/chat/turnParity',
'@/utils/chat/foldTurn',
]
// Test files exercise the fenced modules directly (that is their job) and are
// not a runtime layer, so they are exempt from the import fence below.
function isTestFile(entry) {
return /\.(test|spec)\.(ts|tsx)$/.test(entry)
}
const failures = []
for (const file of walkFiles(srcRoot, /\.(ts|vue)$/, { skipFile: isTestFile })) {
const rel = relative(root, file).replace(/\\/g, '/')
const body = readFileSync(file, 'utf8')
for (const rule of bannedPatterns) {
if (body.includes(rule.pattern) && !rule.allow.has(rel)) {
failures.push(`${rel}: ${rule.message} Found "${rule.pattern}".`)
}
}
for (const pattern of stalePlatformPatterns) {
if (body.includes(pattern)) {
failures.push(`${rel}: stale desktop/cloud platform pattern found: "${pattern}".`)
}
}
if (!isUnderChatComposables(rel) && !isChatView(rel)) {
for (const moduleId of turnLogModulePatterns) {
if (body.includes(moduleId)) {
failures.push(`${rel}: live-turn log "${moduleId}" must stay within src/composables/chat/ or views/ChatView.vue.`)
}
}
}
}
if (failures.length > 0) {
console.error(failures.join('\n'))
process.exit(1)
}
console.log('Architecture guard passed.')
@@ -0,0 +1,73 @@
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
const root = fileURLToPath(new URL('..', import.meta.url))
function read(rel) {
return readFileSync(join(root, rel), 'utf8')
}
const failures = []
function assertAbsent(rel, pattern, message) {
const body = read(rel)
if (pattern.test(body)) failures.push(`${rel}: ${message}`)
}
function assertPresent(rel, pattern, message) {
const body = read(rel)
if (!pattern.test(body)) failures.push(`${rel}: ${message}`)
}
assertAbsent(
'src/utils/chat/artifacts.ts',
/\btoken\??:\s*string|searchParams\.set\(['"]token['"]|includeSessionKey\s*!==\s*false/,
'artifact URLs must not carry bearer tokens or default session keys in query params.',
)
assertPresent(
'src/utils/chat/artifacts.ts',
/searchParams\.delete\(['"]token['"]\)[\s\S]+searchParams\.delete\(['"]sessionKey['"]\)[\s\S]+searchParams\.delete\(['"]session_key['"]\)/,
'artifact URL sanitizer must strip sensitive same-origin query params.',
)
assertAbsent(
'src/composables/chat/useChatMarkdownExport.ts',
/\bsessionKey\b|\bauthToken\b|\btoken\b|artifactDownloadUrl/,
'Markdown export must not persist raw sessions, bearer tokens, or signed artifact URLs.',
)
assertAbsent(
'src/components/chat/ChatArtifactList.vue',
/artifactPreviewUrl\(|:href="artifactUrl\(|:src="artifactUrl\(/,
'artifact previews must not render credential-bearing artifact URLs directly into the DOM.',
)
assertPresent(
'src/components/chat/ChatArtifactList.vue',
/URL\.createObjectURL\(blob\)/,
'artifact previews must render fetched blob object URLs.',
)
// Assistant markdown is sanitized before it reaches the DOM: the renderer must
// not bypass DOMPurify, and must never let assistant text render arbitrary form
// controls. The only <input> markdown produces is a disabled task-list checkbox.
assertAbsent(
'src/composables/chat/useChatTextRendering.ts',
/forceKeepAttr/,
'markdown sanitization must not bypass DOMPurify via forceKeepAttr.',
)
assertPresent(
'src/composables/chat/useChatTextRendering.ts',
/addHook\(\s*['"]uponSanitizeElement['"][\s\S]*?removeChild/,
'markdown sanitizer must drop non-task-list <input> elements (uponSanitizeElement + removeChild).',
)
if (failures.length > 0) {
console.error(failures.join('\n'))
process.exit(1)
}
console.log('Chat security guard passed.')
@@ -0,0 +1,98 @@
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { hexToRgb, stripAllComments } from './lib/css-utils.mjs'
// Cross-surface brand-accent guard.
//
// The product ships several independently-authored surfaces (the Vue console,
// the Electron splash, the Electron onboarding window). Issue #403 found the
// brand accent had fragmented into six different oranges across them. The Vue
// console is already token-guarded by check-webui-colors.mjs; this guard keeps
// the DESKTOP launch sequence (splash + onboarding) locked to the one canonical
// "strike" accent so it can't drift off again.
//
// Scope: only a SATURATED ORANGE (a brand-accent candidate) is checked — a hue
// in the orange band with real saturation. Danger-reds, greens, warm neutrals
// and paper whites are ignored, so the guard is about brand identity, not every
// colour. Backgrounds are intentionally out of scope (too many valid neutral
// shades to allowlist without false positives).
//
// The legacy gateway frontend (src/opensquilla/gateway/static/css) is NOT scanned
// here — its status (resync vs. freeze) is a separate, still-open decision.
const repoRoot = fileURLToPath(new URL('../../', import.meta.url))
// The canonical "strike" family — the Instrument accent and its documented
// hover / deep / secondary / light-theme siblings. Stored as normalized
// "r,g,b" so hex and rgb()/rgba() forms compare equal.
const CANONICAL = new Set([
'242,106,27', // #F26A1B accent (dark)
'255,122,46', // #FF7A2E accent-hover (dark)
'217,90,17', // #D95A11 accent-deep (dark) / onboarding hover
'255,138,76', // #FF8A4C accent-secondary
'186,77,15', // #BA4D0F accent (light)
'165,68,12', // #A5440C accent-hover (light)
'142,58,10', // #8E3A0A accent-deep (light)
'182,80,28', // #B6501C accent-secondary (light)
])
// Files that make up the desktop launch sequence.
const targets = [
'desktop/electron/src/boot.html',
'desktop/electron/src/main.ts',
]
// Is this rgb a saturated orange — i.e. a brand-accent candidate?
function isBrandOrange([r, g, b]) {
const rn = r / 255, gn = g / 255, bn = b / 255
const max = Math.max(rn, gn, bn), min = Math.min(rn, gn, bn)
const l = (max + min) / 2
const d = max - min
if (d === 0) return false
const s = d / (1 - Math.abs(2 * l - 1))
let hue
if (max === rn) hue = ((gn - bn) / d) % 6
else if (max === gn) hue = (bn - rn) / d + 2
else hue = (rn - gn) / d + 4
hue = ((hue * 60) + 360) % 360
// Orange band, well saturated, mid lightness — excludes red danger (<16),
// yellow (>46), and low-saturation warm taupes/papers.
return hue >= 16 && hue <= 46 && s >= 0.4 && l >= 0.18 && l <= 0.72
}
const hexRe = /#[0-9a-fA-F]{6}\b|#[0-9a-fA-F]{3}\b/g
const rgbRe = /rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})/g
const failures = []
for (const rel of targets) {
let text
try {
text = readFileSync(repoRoot + rel, 'utf8')
} catch {
console.warn(`[cross-surface] skipped (not found): ${rel}`)
continue
}
// Comments are stripped so hexes named in them don't trip the guard.
const lines = stripAllComments(text).split('\n')
lines.forEach((line, i) => {
const found = []
for (const m of line.matchAll(hexRe)) found.push({ raw: m[0], rgb: hexToRgb(m[0]) })
for (const m of line.matchAll(rgbRe)) found.push({ raw: m[0], rgb: [+m[1], +m[2], +m[3]] })
for (const { raw, rgb } of found) {
if (!isBrandOrange(rgb)) continue
if (CANONICAL.has(rgb.join(','))) continue
failures.push(
`${rel}:${i + 1}: off-canonical brand orange ${raw} (rgb ${rgb.join(',')}); use the strike accent (#F26A1B family).`,
)
}
})
}
if (failures.length > 0) {
console.error(
`Cross-surface accent guard: ${failures.length} off-canonical brand orange(s) — the desktop launch sequence must use the strike accent:\n` +
failures.join('\n'),
)
process.exit(1)
}
console.log('Cross-surface accent guard passed.')
+68
View File
@@ -0,0 +1,68 @@
// i18n key-parity + English-leakage guard. Wired into `check:architecture` so a
// non-en locale can never silently drift from the en key set (vue-i18n would
// fall back to en and hide the gap) or ship untranslated English strings.
//
// en.json is the authoritative key superset. For every other locale we fail on:
// - missing keys (present in en, absent here)
// - extra keys (present here, absent in en)
// - untranslated values (string identical to en and containing Latin letters)
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { dirname, resolve } from 'node:path'
const here = dirname(fileURLToPath(import.meta.url))
const localesDir = resolve(here, '..', 'src', 'locales')
const OTHER_LOCALES = ['zh-Hans', 'ja', 'fr', 'de', 'es']
// English-leakage (value identical to en) is only a reliable "untranslated"
// signal for non-Latin scripts. fr/de/es legitimately share many words with
// English, so we enforce only KEY PARITY there and check leakage for zh-Hans.
const LEAKAGE_LOCALES = new Set(['zh-Hans'])
function load(name) {
return JSON.parse(readFileSync(resolve(localesDir, `${name}.json`), 'utf8'))
}
function flatten(obj, prefix = '', out = {}) {
for (const [k, v] of Object.entries(obj)) {
const key = prefix ? `${prefix}.${k}` : k
if (v && typeof v === 'object' && !Array.isArray(v)) flatten(v, key, out)
else out[key] = v
}
return out
}
const enFlat = flatten(load('en'))
const enKeys = Object.keys(enFlat)
let failed = false
for (const loc of OTHER_LOCALES) {
const flat = flatten(load(loc))
const keys = Object.keys(flat)
const missing = enKeys.filter((k) => !(k in flat))
const extra = keys.filter((k) => !(k in enFlat))
const untranslated = LEAKAGE_LOCALES.has(loc)
? enKeys.filter(
(k) => k in flat && typeof flat[k] === 'string' && flat[k] === enFlat[k] && /[A-Za-z]/.test(flat[k]),
)
: []
if (missing.length) {
failed = true
console.error(`[check-i18n] ${loc}: ${missing.length} missing key(s):\n ${missing.join('\n ')}`)
}
if (extra.length) {
failed = true
console.error(`[check-i18n] ${loc}: ${extra.length} extra key(s):\n ${extra.join('\n ')}`)
}
if (untranslated.length) {
failed = true
console.error(`[check-i18n] ${loc}: ${untranslated.length} untranslated (==en) value(s):\n ${untranslated.join('\n ')}`)
}
}
if (failed) {
console.error('[check-i18n] FAILED')
process.exit(1)
}
console.log(`[check-i18n] OK — ${OTHER_LOCALES.join(', ')} at full key parity with en, no English leakage`)
@@ -0,0 +1,149 @@
// README locale-parity guard — the docs analog of check-i18n.mjs.
//
// check-i18n.mjs keeps the webui translation CATALOGS in lockstep with the
// language list. This keeps the root README translations in lockstep with the
// SAME list, so the README's language coverage can never silently drift from
// the app's.
//
// The canonical language list (SUPPORTED_LOCALES) and the human endonyms
// (LOCALE_LABELS) are read from the real webui source — never duplicated here —
// so adding/removing a webui locale immediately changes what this check
// requires. For each locale it fails on:
// - a missing README file (README.md for the default locale, README.<code>.md otherwise)
// - a stale/extra translated README whose locale is not supported
// - a wrong/missing language-switcher entry in any README (link, href, endonym, or active marker)
// - a missing localized link in the docs/README.md footer
import { readFileSync, readdirSync, existsSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { dirname, resolve } from 'node:path'
const here = dirname(fileURLToPath(import.meta.url))
const webuiSrc = resolve(here, '..', 'src')
const repoRoot = resolve(here, '..', '..')
// README.<x>.md files that are content variants, NOT language translations.
const NON_LOCALE_README = new Set(['product', 'release'])
const failures = []
const fail = (msg) => failures.push(msg)
// --- read canonical list + labels from the webui source (single source of truth) ---
function readSupportedLocales() {
const src = readFileSync(resolve(webuiSrc, 'i18n', 'index.ts'), 'utf8')
const m = src.match(/SUPPORTED_LOCALES\s*=\s*\[([^\]]+)\]\s*as const/)
if (!m) throw new Error('could not parse SUPPORTED_LOCALES from src/i18n/index.ts')
const codes = [...m[1].matchAll(/['"]([^'"]+)['"]/g)].map((x) => x[1])
if (codes.length === 0) throw new Error('SUPPORTED_LOCALES parsed empty')
const dm = src.match(/DEFAULT_LOCALE\s*=\s*'([^']+)'/)
return { codes, defaultLocale: dm ? dm[1] : 'en' }
}
function readLocaleLabels() {
const src = readFileSync(resolve(webuiSrc, 'components', 'LanguageSwitcher.vue'), 'utf8')
const m = src.match(/LOCALE_LABELS[^{]*\{([\s\S]*?)\}/)
if (!m) throw new Error('could not parse LOCALE_LABELS from src/components/LanguageSwitcher.vue')
const labels = {}
for (const mm of m[1].matchAll(/(?:'([^']+)'|"([^"]+)"|([A-Za-z][\w-]*))\s*:\s*(?:'([^']+)'|"([^"]+)")/g)) {
labels[mm[1] ?? mm[2] ?? mm[3]] = mm[4] ?? mm[5]
}
if (Object.keys(labels).length === 0) throw new Error('LOCALE_LABELS parsed empty')
return labels
}
const { codes, defaultLocale } = readSupportedLocales()
const labels = readLocaleLabels()
for (const code of codes) {
if (!(code in labels)) fail(`LOCALE_LABELS is missing an endonym for "${code}"`)
}
const readmeFor = (code) => (code === defaultLocale ? 'README.md' : `README.${code}.md`)
// --- 1. every supported locale has a README file ---
for (const code of codes) {
const file = readmeFor(code)
if (!existsSync(resolve(repoRoot, file))) fail(`missing ${file} for locale "${code}"`)
}
// --- 2. no stale/extra translated README ---
for (const f of readdirSync(repoRoot).filter((f) => /^README\.[\w-]+\.md$/.test(f))) {
const tag = f.slice('README.'.length, -'.md'.length)
if (NON_LOCALE_README.has(tag)) continue
if (!codes.includes(tag)) fail(`stale/extra translated README: ${f} (locale "${tag}" not in SUPPORTED_LOCALES)`)
}
// --- 3. switcher parity in every root README ---
function parseTokens(block) {
const tokens = []
const re = /<b>([^<]+)<\/b>|<a href="([^"]+)">([^<]+)<\/a>/g
let mm
while ((mm = re.exec(block))) {
if (mm[1] !== undefined) tokens.push({ active: true, label: mm[1].trim() })
else tokens.push({ active: false, href: mm[2].trim(), label: mm[3].trim() })
}
return tokens
}
function switcherTokens(text) {
// The switcher is the centered block linking READMEs with the MOST language
// tokens — picking by max tokens avoids being fooled by a decoy centered
// block that happens to contain a single README link elsewhere in the doc.
const candidates = [...text.matchAll(/<p align="center">([\s\S]*?)<\/p>/g)]
.map((m) => m[1])
.filter((b) => /href="README(?:\.[\w-]+)?\.md"/.test(b))
if (!candidates.length) return null
return candidates.map(parseTokens).reduce((best, t) => (t.length > best.length ? t : best), [])
}
for (const activeCode of codes) {
const file = readmeFor(activeCode)
const path = resolve(repoRoot, file)
if (!existsSync(path)) continue // already reported as missing
const tokens = switcherTokens(readFileSync(path, 'utf8'))
if (!tokens) {
fail(`${file}: no language-switcher block found`)
continue
}
const expected = codes.map((code) =>
code === activeCode
? { active: true, label: labels[code] }
: { active: false, href: readmeFor(code), label: labels[code] },
)
if (tokens.length !== expected.length) {
fail(`${file}: switcher has ${tokens.length} entries, expected ${expected.length} (${codes.join(', ')})`)
continue
}
expected.forEach((exp, i) => {
const got = tokens[i]
if (got.label !== exp.label)
fail(`${file}: switcher position ${i + 1} endonym "${got.label}" should be "${exp.label}"`)
if (got.active !== exp.active)
fail(`${file}: switcher position ${i + 1} (${exp.label}) active-marker mismatch (active should be ${exp.active})`)
if (!exp.active && got.href !== exp.href)
fail(`${file}: switcher link "${exp.label}" href "${got.href}" should be "${exp.href}"`)
})
}
// --- 4. docs/README.md footer links every non-default locale (with ../ prefix) ---
const docsReadmePath = resolve(repoRoot, 'docs', 'README.md')
if (existsSync(docsReadmePath)) {
const docs = readFileSync(docsReadmePath, 'utf8')
for (const code of codes) {
if (code === defaultLocale) continue
const needle = `[${labels[code]}](../README.${code}.md)`
if (!docs.includes(needle)) fail(`docs/README.md footer missing localized link: ${needle}`)
}
} else {
fail('docs/README.md not found')
}
// --- report ---
if (failures.length) {
console.error('[check-readme-locales] FAILED:')
for (const f of failures) console.error(` - ${f}`)
process.exit(1)
}
console.log(
`[check-readme-locales] OK — ${codes.length} locales (${codes.join(', ')}); ` +
'README files, switchers, and docs footer in sync with webui SUPPORTED_LOCALES',
)
@@ -0,0 +1,100 @@
import { readFileSync, readdirSync, existsSync } from 'node:fs'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
// Post-build invariant: an expressive skin must be fully lazy — a console user
// who never opens a skinned route downloads ZERO of its bytes. We prove it by
// asserting each skin's scope rules never land in an eagerly-loaded chunk (the
// entry JS/CSS referenced by index.html), while still being emitted as their own
// on-demand chunks. Run after `vite build`.
const root = fileURLToPath(new URL('..', import.meta.url))
const dist = join(root, '..', 'src', 'opensquilla', 'gateway', 'static', 'dist')
const themesDir = join(root, 'src', 'themes')
const indexHtml = join(dist, 'index.html')
if (!existsSync(indexHtml)) {
console.error('check-theme-bundle: no built index.html — run `vite build` first.')
process.exit(1)
}
// Expressive skin ids = theme folders that ship a skin.css.
const skinIds = existsSync(themesDir)
? readdirSync(themesDir, { withFileTypes: true })
.filter((d) => d.isDirectory() && existsSync(join(themesDir, d.name, 'skin.css')))
.map((d) => d.name)
: []
if (skinIds.length === 0) {
console.log('Theme bundle guard: no expressive skins to check.')
process.exit(0)
}
const html = readFileSync(indexHtml, 'utf8')
// Assets the browser fetches on first paint (module scripts, modulepreloads,
// stylesheets in <head>).
const eager = new Set([...html.matchAll(/(?:src|href)="\.?\/?(assets\/[^"]+)"/g)].map((m) => m[1]))
const read = (relPath) => {
const p = join(dist, relPath)
return existsSync(p) ? readFileSync(p, 'utf8') : ''
}
const eagerText = [...eager].map(read).join('\n')
// Minifiers drop quotes in attribute selectors, so match both forms.
const hasScope = (text, id) =>
text.includes(`[data-skin="${id}"]`) || text.includes(`[data-skin=${id}]`)
const failures = []
const report = {}
for (const id of skinIds) {
// 1. the skin's scoped rules must NOT be in any eager chunk
if (hasScope(eagerText, id)) {
failures.push(`skin "${id}" leaked into an eagerly-loaded chunk (found in entry).`)
}
// 2. it MUST exist as its own lazy chunk somewhere in dist/assets
const assets = readdirSync(join(dist, 'assets'))
const lazyCss = assets.filter(
(f) => f.endsWith('.css') && !eager.has(`assets/${f}`) && hasScope(read(`assets/${f}`), id),
)
if (lazyCss.length === 0) {
failures.push(`skin "${id}" has no lazy stylesheet chunk (expected an on-demand CSS asset scoped to it).`)
}
report[id] = { lazyCssChunks: lazyCss.length }
}
// Worlds: a value theme's global "world" layer (world.css) must also stay lazy.
// Its DESCENDANT rules ("[data-theme=<id>] <sel>") live in world.css; the eager
// tokens.css only emits the palette root block ("[data-theme=<id>]{...}"), so a
// trailing space after the attribute distinguishes world rules from the palette.
const worldIds = existsSync(themesDir)
? readdirSync(themesDir, { withFileTypes: true })
.filter((d) => d.isDirectory() && existsSync(join(themesDir, d.name, 'world.css')))
.map((d) => d.name)
: []
const hasWorld = (text, id) =>
text.includes(`[data-theme="${id}"] `) || text.includes(`[data-theme=${id}] `)
for (const id of worldIds) {
if (hasWorld(eagerText, id)) {
failures.push(`world "${id}" leaked structural rules into an eager chunk (world.css must be lazy).`)
}
const assets = readdirSync(join(dist, 'assets'))
const lazy = assets.filter(
(f) => f.endsWith('.css') && !eager.has(`assets/${f}`) && hasWorld(read(`assets/${f}`), id),
)
if (lazy.length === 0) failures.push(`world "${id}" has no lazy stylesheet chunk.`)
}
// No font woff2 may be inlined into the eager entry (they are the skins' weight).
for (const rel of eager) {
if (/\.js$/.test(rel) && read(rel).includes('data:font/woff2')) {
failures.push(`eager chunk ${rel} inlines a woff2 font — skin fonts must stay separate.`)
}
}
if (failures.length) {
console.error('Theme bundle guard failed:\n' + failures.map((f) => ' ' + f).join('\n'))
process.exit(1)
}
console.log(
`Theme bundle guard passed — ${skinIds.length} skin(s) + ${worldIds.length} world(s) fully lazy (0 bytes in the entry). ` +
`skins: ${skinIds.join(', ') || 'none'}; worlds: ${worldIds.join(', ') || 'none'}.`,
)
@@ -0,0 +1,61 @@
import { readFileSync, readdirSync, existsSync } from 'node:fs'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { parseTokenDefinitions } from './lib/css-utils.mjs'
// Contract completeness: every value theme (a folder under src/themes/ that
// ships a tokens.css) must define every required L1 role from contract.json.
// Expressive skins (no tokens.css, or a sparse one) are exempt — they inherit
// the ground's tokens. This is what lets a new/stranger theme drop in without
// silently leaving a role undefined (which renders as an invisible element).
const root = fileURLToPath(new URL('..', import.meta.url))
const themesDir = join(root, 'src', 'themes')
const contract = JSON.parse(readFileSync(join(themesDir, 'contract.json'), 'utf8'))
const required = contract.required ?? []
const failures = []
let checked = 0
for (const entry of readdirSync(themesDir, { withFileTypes: true })) {
if (!entry.isDirectory()) continue
const tokensPath = join(themesDir, entry.name, 'tokens.css')
if (!existsSync(tokensPath)) {
// Only expressive skins may omit tokens.css. Cross-check the manifest: a
// kind:'value' folder without one would still register, appear in the
// picker, and silently render the :root fallback when selected — the
// "phantom theme" this guard exists to catch (e.g. a token.css typo).
const manifestPath = join(themesDir, entry.name, 'manifest.ts')
if (existsSync(manifestPath)) {
const manifest = readFileSync(manifestPath, 'utf8')
if (/kind\s*:\s*['"]value['"]/.test(manifest)) {
failures.push(
`theme "${entry.name}" declares kind:'value' in its manifest but has no tokens.css — it would ship as a selectable theme whose palette never applies`,
)
}
}
continue // expressive skin — inherits the ground's tokens, exempt
}
const css = readFileSync(tokensPath, 'utf8')
const defined = new Set(parseTokenDefinitions(css).keys())
const missing = required.filter((role) => !defined.has(role))
if (missing.length) {
failures.push(
`theme "${entry.name}" (src/themes/${entry.name}/tokens.css) is missing required L1 role(s): ${missing.join(', ')}`,
)
}
checked++
}
if (checked === 0) {
failures.push('no value-theme token files found (src/themes/*/tokens.css)')
}
if (failures.length) {
console.error('Theme contract check failed:\n' + failures.join('\n'))
process.exit(1)
}
console.log(
`Theme contract check passed (${checked} value theme(s) satisfy ${required.length} required roles).`,
)
@@ -0,0 +1,156 @@
import { readFileSync, readdirSync, existsSync } from 'node:fs'
import { join, relative } from 'node:path'
import { fileURLToPath } from 'node:url'
import { walkFiles } from './lib/fs-walk.mjs'
import { stripCssComments } from './lib/css-utils.mjs'
// Scope-leak / boundary guard for the Axis-B expressive-skin layer.
// 1. Every selector in a theme's skin.css must descend from that theme's
// `[data-skin="<id>"]` scope — a skin can never restyle the base app.
// 2. A skin must not reach operational L2 surfaces even when scoped.
// 3. L2 (components / views / base.css) must not read a skin-private
// `--x-*` token — those belong only inside the skin.
const root = fileURLToPath(new URL('..', import.meta.url))
const srcDir = join(root, 'src')
const themesDir = join(srcDir, 'themes')
const rel = (p) => relative(root, p).replace(/\\/g, '/')
// Operational L2 surfaces a skin must never target (belt-and-suspenders: the
// skin is already scoped to its route's content area, but this blocks a skin
// from ever reaching console chrome or data surfaces).
const FORBIDDEN = [
'.control-stage', '.control-stat', '.control-panel', '.control-card',
'.data-table', '.approval', '.chat-', '.composer', '.sidebar', '.topbar',
'.mobile-tab', '[role="alert"]',
]
// Split a selector header into its comma-separated arms, respecting commas
// nested inside (), [] and quotes (`:is(a, b)`, `[attr="x,y"]`). Each arm must
// be validated INDEPENDENTLY — otherwise `[data-skin="x"] .a, body .b {}` passes
// as one blob because the scoped-prefix substring is present, while `body .b`
// silently restyles the whole app (the exact leak this guard exists to stop).
function splitSelectorList(sel) {
const out = []
let depth = 0
let quote = null
let buf = ''
for (const ch of sel) {
if (quote) {
buf += ch
if (ch === quote) quote = null
continue
}
if (ch === '"' || ch === "'") { quote = ch; buf += ch; continue }
if (ch === '(' || ch === '[') { depth++; buf += ch; continue }
if (ch === ')' || ch === ']') { depth = Math.max(0, depth - 1); buf += ch; continue }
if (ch === ',' && depth === 0) { out.push(buf); buf = ''; continue }
buf += ch
}
if (buf.trim()) out.push(buf)
return out
}
// Brace-aware walk: invoke cb(selectorText, depth) for each rule header.
function eachSelector(css, cb) {
let depth = 0
let buf = ''
for (const ch of css) {
if (ch === '{') {
const sel = buf.trim()
if (sel) cb(sel, depth)
buf = ''
depth++
} else if (ch === '}') {
buf = ''
depth = Math.max(0, depth - 1)
} else if (ch === ';' && depth === 0) {
buf = '' // top-level statement (@import/@charset) — not a selector
} else {
buf += ch
}
}
}
const failures = []
// --- (1)+(2) skin.css scoping -----------------------------------------------
if (existsSync(themesDir)) {
for (const entry of readdirSync(themesDir, { withFileTypes: true })) {
if (!entry.isDirectory()) continue
const skinPath = join(themesDir, entry.name, 'skin.css')
if (!existsSync(skinPath)) continue
const id = entry.name
const scoped = (sel) =>
sel.includes(`[data-skin="${id}"]`) || sel.includes(`[data-skin=${id}]`)
const css = stripCssComments(readFileSync(skinPath, 'utf8'))
eachSelector(css, (sel) => {
if (sel.startsWith('@')) return // @media/@supports/@keyframes/@font-face
if (/^(from|to|\d+%)(\s*,\s*(from|to|\d+%))*$/.test(sel)) return // keyframe steps
for (const raw of splitSelectorList(sel)) {
const arm = raw.trim()
if (!arm) continue
if (!scoped(arm)) {
failures.push(`${rel(skinPath)}: selector not scoped to [data-skin="${id}"]: "${arm}"${arm === sel ? '' : ` (arm of "${sel}")`}`)
continue
}
for (const f of FORBIDDEN) {
if (arm.includes(f)) {
failures.push(`${rel(skinPath)}: skin reaches operational surface "${f}": "${arm}"`)
break
}
}
}
})
}
// world.css (a value theme's global "world" layer) may restyle the whole app
// (operational L2 included) -- that is the point -- but every selector must
// still be scoped to [data-theme="<id>"] so it can't leak to other themes.
for (const entry of readdirSync(themesDir, { withFileTypes: true })) {
if (!entry.isDirectory()) continue
const worldPath = join(themesDir, entry.name, 'world.css')
if (!existsSync(worldPath)) continue
const id = entry.name
const scoped = (sel) =>
sel.includes(`[data-theme="${id}"]`) || sel.includes(`[data-theme=${id}]`)
const css = stripCssComments(readFileSync(worldPath, 'utf8'))
eachSelector(css, (sel) => {
if (sel.startsWith('@')) return
if (/^(from|to|\d+%)(\s*,\s*(from|to|\d+%))*$/.test(sel)) return
for (const raw of splitSelectorList(sel)) {
const arm = raw.trim()
if (!arm) continue
if (!scoped(arm)) {
failures.push(`${rel(worldPath)}: world selector not scoped to [data-theme="${id}"]: "${arm}"${arm === sel ? '' : ` (arm of "${sel}")`}`)
}
}
})
}
}
// --- (3) L2 must not read skin-private --x-* tokens --------------------------
const styleFiles = /\.(vue|css)$/
const l2Files = [
...walkFiles(join(srcDir, 'components'), styleFiles),
...walkFiles(join(srcDir, 'views'), styleFiles),
// Global stylesheets (control-visual-system, chat-*, route-fx, …) are L2
// surfaces too — imported app-wide from main.ts.
...walkFiles(join(srcDir, 'styles'), styleFiles),
join(srcDir, 'assets', 'base.css'),
join(srcDir, 'assets', 'foundation.css'),
]
const xLeak = /var\(\s*--x-[\w-]+/
for (const f of l2Files) {
if (!existsSync(f)) continue
readFileSync(f, 'utf8').split('\n').forEach((line, i) => {
if (xLeak.test(line)) {
failures.push(`${rel(f)}:${i + 1}: L2 must not read skin-private --x-* token: ${line.trim()}`)
}
})
}
if (failures.length) {
console.error('Theme scope guard failed:\n' + failures.join('\n'))
process.exit(1)
}
console.log('Theme scope guard passed.')
@@ -0,0 +1,302 @@
import { readdirSync, readFileSync, existsSync } from 'node:fs'
import { join, relative } from 'node:path'
import { fileURLToPath } from 'node:url'
import { walkFiles } from './lib/fs-walk.mjs'
import { hexToRgb, parseTokenDefinitions } from './lib/css-utils.mjs'
// Every control-UI surface must use the semantic color tokens from
// src/assets/base.css so both themes render correctly. Raw hex / rgb() / hsl()
// color literals anywhere under src/ are violations — use a token, or
// color-mix(in srgb, var(--token) …).
//
// base.css is the token source, so it is scanned under a narrower rule: raw
// color literals are allowed only inside custom-property definitions
// (`--token: …`), comments, and neutral black/white rgba (the primitive the
// shadow/border tokens are themselves built from). An off-palette literal in a
// real base.css rule is still a violation — that is how theme-parity drift
// (e.g. a hardcoded near-black border that vanishes on dark) sneaks in.
const root = fileURLToPath(new URL('..', import.meta.url))
const srcDir = join(root, 'src')
// Token-definition sources. Raw color literals are allowed only inside
// custom-property definitions in these files: the base.css component file plus
// the layered token layer (foundation L0 + each theme's L1 tokens.css). Anywhere
// else a literal is a theme-parity violation.
function isTokenSource(f) {
const r = f.replace(/\\/g, '/')
return (
r.endsWith('/src/assets/base.css') ||
r.endsWith('/src/assets/foundation.css') ||
/\/src\/themes\/[^/]+\/tokens\.css$/.test(r) ||
// a skin's scoped stylesheet defines its own token pack; structural rules in
// it still may not use raw literals (only custom-prop lines are exempt).
/\/src\/themes\/[^/]+\/skin\.css$/.test(r)
)
}
// Negative lookbehind keeps HTML entities like &#8593; out of the hex match.
const colorLiteral = /(?<!&)#[0-9a-fA-F]{3,8}\b|\brgba?\(|\bhsla?\(/
// SVG fragment references such as url(#cg2) are ids, not colors.
const urlRef = /url\(#[\w-]+\)/g
// Neutral black/white — the primitive shadows/borders are built from; allowed
// in base.css rules. Matches rgba(0,0,0,…), rgba(255,255,255,…), #000(0), #fff(f).
const neutralColor =
/\brgba?\(\s*(?:0\s*,\s*0\s*,\s*0|255\s*,\s*255\s*,\s*255)\b[^)]*\)|#(?:000000|ffffff|000|fff)\b/gi
const customProp = /^\s*--[\w-]+\s*:/
const files = walkFiles(srcDir, /\.(vue|css)$/)
// ---------------------------------------------------------------------------
// Pass 1 — raw color literals
// ---------------------------------------------------------------------------
const failures = []
for (const file of files) {
const rel = relative(root, file).replace(/\\/g, '/')
const isBase = isTokenSource(file)
const lines = readFileSync(file, 'utf8').split('\n')
let inBlockComment = false
lines.forEach((line, index) => {
let code = line
// Strip block comments (base.css is scanned, so its banner hex must not
// trip the guard); track multi-line state.
if (inBlockComment) {
const end = code.indexOf('*/')
if (end === -1) return
code = code.slice(end + 2)
inBlockComment = false
}
code = code.replace(/\/\*.*?\*\//g, '')
const open = code.indexOf('/*')
if (open !== -1) {
inBlockComment = true
code = code.slice(0, open)
}
code = code.replace(urlRef, '')
if (isBase) {
if (customProp.test(code)) return // a token definition — allowed
code = code.replace(neutralColor, '') // neutral shadow/border primitive
}
if (colorLiteral.test(code)) {
failures.push(
`${rel}:${index + 1}: raw color literal; use a base.css token or color-mix(in srgb, var(--token) …). ${line.trim()}`,
)
}
})
}
// ---------------------------------------------------------------------------
// Pass 2 — references to undefined tokens
// ---------------------------------------------------------------------------
// A bare `var(--token)` with no fallback that is never defined anywhere renders
// to nothing (e.g. a colorless status message). Fallback forms `var(--x, …)`
// are intentional and exempt — that is how runtime/host-set vars are consumed.
// Scoped to the semantic color/surface namespace: layout vars like
// `--router-left`/`--i`/`--composer-h` are legitimately set at runtime via
// :style bindings and are out of scope for a color guard.
const semanticToken = /^--(bg|surface|text|color|accent|ok|warn|danger|info|success|border|hairline|card|shadow|scrim|sidebar|syntax)/
const defined = new Set()
const bareUsages = [] // { token, rel, line }
const defRe = /(?:^|[\s;{])(--[\w-]+)\s*:/g
const bareVarRe = /var\(\s*(--[\w-]+)\s*\)/g
for (const file of files) {
const rel = relative(root, file).replace(/\\/g, '/')
const text = readFileSync(file, 'utf8')
for (const m of text.matchAll(defRe)) defined.add(m[1])
text.split('\n').forEach((line, index) => {
for (const m of line.matchAll(bareVarRe)) {
bareUsages.push({ token: m[1], rel, line: index + 1, text: line.trim() })
}
})
}
const undefinedTokens = bareUsages.filter(
(u) => semanticToken.test(u.token) && !defined.has(u.token),
)
// ---------------------------------------------------------------------------
// Pass 3 — per-theme contrast floors (WCAG 2.x). Each value theme's reading
// pairs must clear a floor so a new theme cannot ship illegible text. Values are
// resolved through var()/color-mix(in srgb, …) within each theme's tokens + the
// foundation layer.
//
// The floors distinguish INK (colour used as `color:` text) from FILL (dots,
// bars, badge bodies — never text):
// • --text / --text-muted are body reading text → AA normal 4.5:1.
// • The colour-carrying INK — --accent, --accent-secondary and the status
// spectrum (ok/warn/danger/info/queued) — is used as `color:` text in 100+
// sites (links, nav, inline status, error copy). It MUST clear AA 4.5:1 on
// the two grounds it actually sits on: the base (--bg) and the card/surface
// (--bg-surface). On --bg-elevated (modals/popovers — a minority ground for
// coloured text) it is held to the 3:1 UI-component floor. This is the fix
// for the old bug where these were floored at 3.0 against --bg only (or, for
// accent, not checked at all), which green-lit sub-AA coloured text.
// • The matching --*-fill tokens are marks, never text → 3:1 on --bg.
const foundationFile = join(srcDir, 'assets', 'foundation.css')
const themesRoot = join(srcDir, 'themes')
const valueThemeFiles = existsSync(themesRoot)
? readdirSync(themesRoot, { withFileTypes: true })
.filter((d) => d.isDirectory() && existsSync(join(themesRoot, d.name, 'tokens.css')))
.map((d) => [d.name, join(themesRoot, d.name, 'tokens.css')])
: []
function tokenMapFor(themeFile) {
const map = new Map()
for (const p of [foundationFile, themeFile]) {
if (!existsSync(p)) continue
parseTokenDefinitions(readFileSync(p, 'utf8'), map)
}
return map
}
function splitTop(s) {
const out = []
let depth = 0
let buf = ''
for (const ch of s) {
if (ch === '(') { depth++; buf += ch }
else if (ch === ')') { depth--; buf += ch }
else if (ch === ',' && depth === 0) { out.push(buf); buf = '' }
else buf += ch
}
if (buf.trim()) out.push(buf)
return out
}
function toRgb(value, map, seen = new Set()) {
if (value == null) return null
const v = value.trim()
const varM = v.match(/^var\(\s*(--[\w-]+)\s*(?:,\s*([\s\S]+))?\)$/)
if (varM) {
const name = varM[1].slice(2)
if (seen.has(name)) return null
const next = new Set(seen).add(name)
if (map.has(name)) return toRgb(map.get(name), map, next)
return varM[2] ? toRgb(varM[2], map, next) : null
}
let m = v.match(/^#([0-9a-fA-F]{3,8})$/)
if (m) return hexToRgb(v)
m = v.match(/^rgba?\(\s*([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)/)
if (m) return [Number(m[1]), Number(m[2]), Number(m[3])]
m = v.match(/^color-mix\(in srgb,\s*([\s\S]+)\)$/)
if (m) {
const parts = splitTop(m[1]).map((p) => {
const pm = p.trim().match(/^([\s\S]*?)\s+([\d.]+)%$/)
return pm
? { rgb: toRgb(pm[1], map, new Set(seen)), pct: Number(pm[2]) }
: { rgb: toRgb(p, map, new Set(seen)), pct: null }
})
if (parts.length < 2 || !parts[0].rgb || !parts[1].rgb) return null
let [a, b] = parts
if (a.pct == null && b.pct == null) { a.pct = 50; b.pct = 50 }
else if (a.pct == null) a.pct = 100 - b.pct
else if (b.pct == null) b.pct = 100 - a.pct
const t = a.pct / (a.pct + b.pct)
return [0, 1, 2].map((i) => Math.round(a.rgb[i] * t + b.rgb[i] * (1 - t)))
}
return null
}
const chanLin = (c) => { const s = c / 255; return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4 }
const relLum = (rgb) => 0.2126 * chanLin(rgb[0]) + 0.7152 * chanLin(rgb[1]) + 0.0722 * chanLin(rgb[2])
function contrast(a, b) {
const la = relLum(a)
const lb = relLum(b)
const [hi, lo] = la > lb ? [la, lb] : [lb, la]
return (hi + 0.05) / (lo + 0.05)
}
// Colour-carrying ink used as text (see the note above). AA on --bg + --bg-surface;
// 3:1 UI floor on --bg-elevated. --accent-hover is included because it paints
// link/CTA hover text (a:hover, OverviewView). --accent-deep is intentionally
// absent — it is only ever a fill/border/gradient stop, never `color:` text.
const INK_TEXT = ['accent', 'accent-hover', 'accent-secondary', 'ok', 'warn', 'danger', 'info', 'queued']
// Fill tokens: marks/dots/badge bodies, never text → 3:1 on --bg.
const FILL_MARKS = ['ok-fill', 'warn-fill', 'danger-fill', 'info-fill', 'queued-fill']
const CONTRAST_CHECKS = [
{ fg: 'text', bg: 'bg', min: 4.5 },
{ fg: 'text', bg: 'bg-surface', min: 4.5 },
{ fg: 'text-muted', bg: 'bg', min: 4.5 },
{ fg: 'text-dim', bg: 'bg', min: 3.0 },
{ fg: 'accent-foreground', bg: 'accent', min: 3.0 },
...INK_TEXT.flatMap((fg) => [
{ fg, bg: 'bg', min: 4.5 },
{ fg, bg: 'bg-surface', min: 4.5 },
{ fg, bg: 'bg-elevated', min: 3.0 },
]),
...FILL_MARKS.map((fg) => ({ fg, bg: 'bg', min: 3.0 })),
]
const contrastFailures = []
const unresolvable = []
for (const [theme, file] of valueThemeFiles) {
const map = tokenMapFor(file)
const reported = new Set()
for (const c of CONTRAST_CHECKS) {
const fg = toRgb(`var(--${c.fg})`, map)
const bg = toRgb(`var(--${c.bg})`, map)
if (!fg || !bg) {
// A required role toRgb() can't parse (hsl()/oklch()/named colour/…)
// must FAIL, not silently skip — otherwise a theme authored in an
// unsupported syntax ships with zero contrast coverage while the guard
// reports it as checked.
for (const [role, rgb] of [[c.fg, fg], [c.bg, bg]]) {
const key = `${theme}:${role}`
if (!rgb && !reported.has(key)) {
reported.add(key)
unresolvable.push(
` [${theme}] --${role}: ${map.get(role) ?? '(undefined)'} — not parseable as a colour (supported: hex, rgb()/rgba(), var() chains, color-mix(in srgb, …))`,
)
}
}
continue
}
const r = contrast(fg, bg)
if (r < c.min) {
contrastFailures.push(` [${theme}] --${c.fg} on --${c.bg}: ${r.toFixed(2)}:1 (floor ${c.min}:1)`)
}
}
}
// ---------------------------------------------------------------------------
// Pass 4 — ink/fill discipline (harvested from the Out-of-Register work). A
// `*-fill` token is chroma-tuned for small marks (dots, bars, badge bodies); it
// is NOT a text colour. Using one as `color:` is a legibility bug — use the
// matching ink token. (background-color / border-color etc. are fine.)
const fillAsColor = /(?<![-\w])color\s*:\s*var\(\s*--[\w-]*-fill\b/
const fillMisuse = []
for (const file of files) {
const relf = relative(root, file).replace(/\\/g, '/')
readFileSync(file, 'utf8').split('\n').forEach((line, i) => {
if (fillAsColor.test(line)) {
fillMisuse.push(`${relf}:${i + 1}: a *-fill token is for fills, not text — use its ink token. ${line.trim()}`)
}
})
}
// ---------------------------------------------------------------------------
let failed = false
if (failures.length > 0) {
failed = true
console.error('Raw color literals found — use base.css design tokens:\n' + failures.join('\n'))
}
if (undefinedTokens.length > 0) {
failed = true
console.error(
'\nReferences to undefined CSS tokens (use a defined token or add a fallback `var(--x, …)`):\n' +
undefinedTokens.map((u) => `${u.rel}:${u.line}: ${u.token} is never defined. ${u.text}`).join('\n'),
)
}
if (contrastFailures.length > 0) {
failed = true
console.error('\nTheme contrast below floor (WCAG):\n' + contrastFailures.join('\n'))
}
if (unresolvable.length > 0) {
failed = true
console.error(
'\nTheme contrast UNVERIFIABLE — required colour roles the checker cannot parse (author them in a supported syntax so the WCAG floors actually apply):\n' +
unresolvable.join('\n'),
)
}
if (fillMisuse.length > 0) {
failed = true
console.error('\nInk/fill discipline — a fill token used as text colour:\n' + fillMisuse.join('\n'))
}
if (failed) process.exit(1)
console.log(
`WebUI color guard passed (${valueThemeFiles.length} theme(s) contrast-checked).`,
)
@@ -0,0 +1,106 @@
import { readFileSync } from 'node:fs'
import { join, relative } from 'node:path'
import { fileURLToPath } from 'node:url'
import { walkFiles } from './lib/fs-walk.mjs'
// Every control-UI surface must move with the shared motion vocabulary defined in
// src/assets/base.css: durations --dur-fast/base/enter/pulse and easings
// --ease-standard/out/in/spring. Raw millisecond/second literals and the bare
// `ease`/`ease-in`/`ease-out`/`ease-in-out` keywords inside transition/animation
// declarations are violations — route them through the tokens so timing and feel
// stay consistent (and so "exits one tier faster" can be enforced in one place).
//
// Allowed: var(--token), cubic-bezier(...)/steps(...) custom curves, `linear`
// and `infinite` continuous loops (spinners/pulses keep their own cadence), the
// zero value (0s/0ms), and the token definitions in base.css. Any genuinely
// intentional one-off can opt out with a trailing `/* motion-allow: why */`.
const root = fileURLToPath(new URL('..', import.meta.url))
const srcDir = join(root, 'src')
const customProp = /^\s*--[\w-]+\s*:/
const easingKeyword = /\b(?:ease-in-out|ease-in|ease-out|ease)\b/
// A time value; we exclude the literal zero (0s / 0ms / 0.0s) separately.
const durationLiteral = /\b\d*\.?\d+m?s\b/g
const isZero = (v) => /^0(?:\.0+)?m?s$/.test(v)
const files = walkFiles(srcDir, /\.(vue|css)$/)
const failures = []
// In .vue files only <style> blocks carry CSS; script/template lines (timer
// durations, "1s tick" comments) must not be scanned as motion.
function styleLineSet(text, isVue) {
if (!isVue) return null
const inStyle = new Set()
let active = false
text.split('\n').forEach((line, i) => {
if (!active && /<style[\s>]/.test(line)) { active = true; return }
if (active && /<\/style>/.test(line)) { active = false; return }
if (active) inStyle.add(i)
})
return inStyle
}
for (const file of files) {
const rel = relative(root, file).replace(/\\/g, '/')
const text = readFileSync(file, 'utf8')
const lines = text.split('\n')
const styleLines = styleLineSet(text, file.endsWith('.vue'))
let inBlockComment = false
lines.forEach((line, index) => {
if (styleLines && !styleLines.has(index)) return
// Explicit, reviewed opt-out (e.g. a spinner whose cadence is intentional).
if (/motion-allow/.test(line)) return
let code = line
if (inBlockComment) {
const end = code.indexOf('*/')
if (end === -1) return
code = code.slice(end + 2)
inBlockComment = false
}
code = code.replace(/\/\*.*?\*\//g, '')
const open = code.indexOf('/*')
if (open !== -1) {
inBlockComment = true
code = code.slice(0, open)
}
// Token definitions are the source of the vocabulary — allowed to hold raw
// values and bare curves.
if (customProp.test(code)) return
// Stagger delays are layout math (per-item offsets), not the motion-quality
// vocabulary — `animation-delay`/`transition-delay` are out of scope.
if (/(?:animation|transition)-delay\s*:/.test(code)) return
// Strip the allowed forms so only raw literals can remain to be flagged.
code = code
.replace(/var\([^()]*\)/g, '')
.replace(/cubic-bezier\([^()]*\)/g, '')
.replace(/steps\([^()]*\)/g, '')
// Continuous loops (linear/infinite spinners, breathes, shimmers) keep their
// own cadence and symmetric curve, which sit outside the UI-transition
// vocabulary — exempt the whole line (both duration and easing).
if (/\b(?:infinite|linear)\b/.test(code)) return
const durations = [...code.matchAll(durationLiteral)]
.map((m) => m[0])
.filter((v) => !isZero(v))
if (durations.length > 0) {
failures.push(
`${rel}:${index + 1}: raw duration ${durations.join(', ')}; use var(--dur-fast|base|enter|pulse). ${line.trim()}`,
)
}
if (easingKeyword.test(code)) {
failures.push(
`${rel}:${index + 1}: bare easing keyword; use var(--ease-standard|out|in|spring). ${line.trim()}`,
)
}
})
}
if (failures.length > 0) {
console.error(
`WebUI motion guard: ${failures.length} raw motion literal(s) — route through base.css motion tokens:\n` +
failures.join('\n'),
)
process.exit(1)
}
console.log('WebUI motion guard passed.')
@@ -0,0 +1,105 @@
import { readFileSync } from 'node:fs'
import { join, relative } from 'node:path'
import { fileURLToPath } from 'node:url'
import { walkFiles } from './lib/fs-walk.mjs'
// Every control-UI surface must round its corners with the radius token ladder
// defined in src/assets/base.css: the primitives --radius-none/xs/sm/md/lg/xl/
// 2xl/full and the semantic aliases --radius-control/card/panel/modal/pill. A
// raw length (6px, 0.375rem, 14px, …) inside a border-radius / border-*-radius
// declaration is a violation — route it through a token so the "Instrument"
// direction's corner tiers stay consistent and can be tuned in one place.
//
// Allowed raw values: 0, 0px, 50% (true circles — dots/avatars), inherit, and
// the fully-round pill literals 999px / 9999px, plus calc(...) expressions built
// from var(--radius-*). base.css is the token source, so its --radius-* token
// definitions (custom-property lines) are exempt. Any genuinely geometric one-off
// (hairline caret, progress/trace bar, scrollbar thumb) can opt out with a
// trailing `/* radius-allow: why */`.
const root = fileURLToPath(new URL('..', import.meta.url))
const srcDir = join(root, 'src')
const customProp = /^\s*--[\w-]+\s*:/
// A border-radius / border-<corner>-radius declaration, capturing just its VALUE
// (up to the next ; or }). Scanning only the value avoids false positives from
// other lengths on the same line (e.g. a `padding: 0.25rem` shorthand sitting
// beside a tokenized `border-radius: var(--radius-sm)`).
const radiusValue = /border(?:-[a-z]+)*-radius\s*:\s*([^;}]+)/gi
// A raw CSS length: number + px/rem/em unit.
const lengthLiteral = /\b\d*\.?\d+(?:px|rem|em)\b/g
// Raw length literals that are explicitly allowed even inside a radius decl.
const allowedLength = /^(?:0px|999px|9999px)$/
const files = walkFiles(srcDir, /\.(vue|css)$/)
// In .vue files only <style> blocks carry CSS; template class strings can
// contain the word "radius" and must not be scanned.
function styleLineSet(text, isVue) {
if (!isVue) return null
const inStyle = new Set()
let active = false
text.split('\n').forEach((line, i) => {
if (!active && /<style[\s>]/.test(line)) { active = true; return }
if (active && /<\/style>/.test(line)) { active = false; return }
if (active) inStyle.add(i)
})
return inStyle
}
const failures = []
for (const file of files) {
const rel = relative(root, file).replace(/\\/g, '/')
const text = readFileSync(file, 'utf8')
const lines = text.split('\n')
const styleLines = styleLineSet(text, file.endsWith('.vue'))
let inBlockComment = false
lines.forEach((line, index) => {
if (styleLines && !styleLines.has(index)) return
// Explicit, reviewed opt-out (e.g. a hairline caret / progress bar geometry).
if (/radius-allow/.test(line)) return
let code = line
// Strip block comments (base.css is scanned, so its banner text must not
// trip the guard); track multi-line state.
if (inBlockComment) {
const end = code.indexOf('*/')
if (end === -1) return
code = code.slice(end + 2)
inBlockComment = false
}
code = code.replace(/\/\*.*?\*\//g, '')
const open = code.indexOf('/*')
if (open !== -1) {
inBlockComment = true
code = code.slice(0, open)
}
// The --radius-* token definitions in base.css are the source of the ladder —
// allowed to hold raw lengths.
if (customProp.test(code)) return
// Check ONLY the value of each border-radius declaration on the line.
let offending = false
for (const decl of code.matchAll(radiusValue)) {
// Strip calc(...) so var(--radius-*)-based expressions don't trip on the
// literals inside them.
const value = decl[1].replace(/calc\([^()]*\)/g, '')
const literals = [...value.matchAll(lengthLiteral)]
.map((m) => m[0])
.filter((v) => !allowedLength.test(v))
if (literals.length > 0) offending = true
}
if (offending) {
failures.push(
`${rel}:${index + 1}: hardcoded border-radius; use a --radius-* token. ${line.trim()}`,
)
}
})
}
if (failures.length > 0) {
console.error(
`WebUI radius guard: ${failures.length} hardcoded border-radius value(s) — use a base.css --radius-* token:\n` +
failures.join('\n'),
)
process.exit(1)
}
console.log('WebUI radius guard passed.')
@@ -0,0 +1,34 @@
// Shared CSS/color parsing helpers for the guard scripts. Each of these used
// to exist as a per-script copy (with per-script gaps — e.g. one hex parser
// missing 4/8-digit forms, one token regex missing a block-final declaration
// without a trailing semicolon). Fix bugs here, once.
/** Strip CSS block comments. */
export function stripCssComments(css) {
return css.replace(/\/\*[\s\S]*?\*\//g, '')
}
/** Strip HTML, CSS and JS line comments from a mixed html/ts/css source.
* Line comments keep the char before `//` and never eat `://` (URLs). */
export function stripAllComments(text) {
return stripCssComments(
text.replace(/<!--[\s\S]*?-->/g, ''),
).replace(/(^|[^:])\/\/[^\n]*/g, '$1')
}
/** #rgb / #rgba / #rrggbb / #rrggbbaa → [r, g, b] (alpha ignored). */
export function hexToRgb(hex) {
let h = hex.replace('#', '')
if (h.length === 3 || h.length === 4) h = h.split('').map((c) => c + c).join('')
return [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16))
}
/** Every custom-property definition in a css source, as Map(name → value).
* Names are bare (no leading `--`). Handles a block-final declaration with no
* trailing semicolon (values stop at `;` or `}`). Later definitions win, so
* feeding foundation.css then a theme's tokens.css yields the theme's view. */
export function parseTokenDefinitions(css, map = new Map()) {
const re = /(?:^|[\s;{])--([\w-]+)\s*:\s*([^;{}]+)/g
for (const m of stripCssComments(css).matchAll(re)) map.set(m[1], m[2].trim())
return map
}
+35
View File
@@ -0,0 +1,35 @@
import { readdirSync, statSync, existsSync } from 'node:fs'
import { join } from 'node:path'
// Shared recursive file walker for the guard scripts. Before this existed each
// guard carried its own copy with its own exclusion rules, and they drifted —
// use this one and pass what differs.
/**
* Recursively collect files under `root` whose BASENAME matches `match`.
* A missing root returns [] (some guards scan optional directories).
*
* @param {string} root
* @param {RegExp} match tested against the file's basename
* @param {object} [opts]
* @param {string[]} [opts.excludeDirs] directory names pruned wholesale
* @param {(name: string) => boolean} [opts.skipFile] drop individual files by basename
* @returns {string[]} absolute paths
*/
export function walkFiles(root, match, { excludeDirs = ['node_modules', 'dist'], skipFile } = {}) {
const out = []
if (!existsSync(root)) return out
const visit = (path) => {
if (statSync(path).isDirectory()) {
for (const entry of readdirSync(path)) {
if (excludeDirs.includes(entry)) continue
visit(join(path, entry))
}
return
}
const name = path.slice(path.lastIndexOf('/') + 1)
if (match.test(name) && !(skipFile && skipFile(name))) out.push(path)
}
visit(root)
return out
}
@@ -0,0 +1,62 @@
import { readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'
import { resolve } from 'node:path'
const distDir = resolve(import.meta.dirname, '../../src/opensquilla/gateway/static/dist')
const textFilePattern = /\.(css|html|js|map)$/
function normalizeNewlines(value) {
return value.replace(/\r\n/g, '\n').replace(/\r/g, '')
}
function normalizeSourceMap(content) {
try {
const parsed = JSON.parse(normalizeNewlines(content))
return JSON.stringify(replaceWindowsNewlines(parsed))
} catch {
return normalizeNewlines(content)
}
}
function replaceWindowsNewlines(value) {
if (Array.isArray(value)) {
return value.map(replaceWindowsNewlines)
}
if (value && typeof value === 'object') {
return Object.fromEntries(
Object.entries(value).map(([key, entry]) => [key, replaceWindowsNewlines(entry)]),
)
}
if (typeof value === 'string') {
return normalizeNewlines(value)
}
return value
}
function normalizeFile(path) {
const before = readFileSync(path, 'utf8')
let after = path.endsWith('.map') ? normalizeSourceMap(before) : normalizeNewlines(before)
if (path.endsWith('.css')) {
after = after.replace(/\n+$/g, '')
}
if (after !== before) {
writeFileSync(path, after, 'utf8')
}
}
function walk(dir) {
for (const entry of readdirSync(dir)) {
const path = resolve(dir, entry)
const stat = statSync(path)
if (stat.isDirectory()) {
walk(path)
continue
}
if (textFilePattern.test(entry)) {
normalizeFile(path)
}
}
}
walk(distDir)