Files
wehub-resource-sync d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

147 lines
5.7 KiB
TypeScript

#!/usr/bin/env bun
/**
* Audits brand icons that render "bare" (without their colored tile) for
* theme-safety. The suggested-actions surface and other bare contexts draw a
* block's icon on a neutral page in both light and dark mode. An icon whose
* SVG hardcodes only near-white fills vanishes on a light page; one that
* hardcodes only near-black fills vanishes on a dark page. The fix is to draw
* monochrome marks with `fill='currentColor'` (which adapts via the
* theme-aware foreground) and reserve hardcoded fills for genuinely
* multi-color brand logos.
*
* Scope: blocks that contribute suggested-action prompt rows — i.e. blocks
* whose meta defines `templates`. New integrations land here, so this is where
* regressions are caught. Multi-color icons and `currentColor` icons pass.
*
* Each block's main `icon:` AND every template's own `icon:` is audited (a
* template may reuse another block's brand icon). Only icons imported from
* `@/components/icons` are checked — that is the only module this script can
* resolve; `@sim/emcn/icons` are design-system line icons drawn with
* `currentColor` and are safe by construction, so they are intentionally
* skipped.
*
* Limitation: this catches purely-monochrome icons (only near-white or only
* near-black fills). It cannot catch an icon whose large primary shape is
* white but which also has a small vivid accent (the accent clears the
* heuristic) — that needs a visual light+dark check. Always eyeball new icons
* on the suggested-actions surface in both themes.
*
* Run: `bun run scripts/check-bare-icons.ts`
*/
import { readdir, readFile } from 'node:fs/promises'
import path from 'node:path'
import { perceivedBrightness } from '../apps/sim/lib/colors'
const ROOT = path.resolve(import.meta.dir, '..')
const BLOCKS_DIR = path.join(ROOT, 'apps/sim/blocks/blocks')
const ICONS_FILE = path.join(ROOT, 'apps/sim/components/icons.tsx')
const isNearWhite = (c: string) => {
const b = perceivedBrightness(c)
return b !== null && b > 0.9
}
const isNearBlack = (c: string) => {
const b = perceivedBrightness(c)
return b !== null && b < 0.1
}
/** Extract each exported icon's source body, keyed by component name. */
function indexIconBodies(src: string): Map<string, string> {
const bodies = new Map<string, string>()
const starts: Array<{ name: string; index: number }> = []
for (const m of src.matchAll(/export (?:function|const) (\w+)\s*[=(]/g)) {
starts.push({ name: m[1], index: m.index })
}
for (let i = 0; i < starts.length; i++) {
const end = i + 1 < starts.length ? starts[i + 1].index : src.length
bodies.set(starts[i].name, src.slice(starts[i].index, end))
}
return bodies
}
interface Hazard {
block: string
icon: string
kind: 'light' | 'dark'
detail: string
}
function analyzeIcon(body: string): { hazard: 'light' | 'dark' | null; detail: string } {
if (/currentColor/.test(body)) return { hazard: null, detail: 'uses currentColor' }
if (/url\(#|<stop|inearGradient|adialGradient/.test(body))
return { hazard: null, detail: 'gradient fill' }
const colors: string[] = []
for (const m of body.matchAll(/(?:fill|stroke)=(?:'([^']*)'|"([^"]*)")/g)) {
const v = (m[1] ?? m[2] ?? '').trim()
if (v && v.toLowerCase() !== 'none') colors.push(v)
}
const literal = colors.filter((c) => c.toLowerCase() !== 'currentcolor')
if (literal.length === 0) return { hazard: null, detail: 'no literal fills' }
const vivid = literal.filter((c) => !isNearWhite(c) && !isNearBlack(c))
if (vivid.length > 0) return { hazard: null, detail: `multi-color (${vivid[0]})` }
if (literal.every(isNearWhite))
return { hazard: 'light', detail: `only near-white fills (${literal.join(', ')})` }
if (literal.every(isNearBlack))
return { hazard: 'dark', detail: `only near-black fills (${literal.join(', ')})` }
return { hazard: null, detail: 'mixed' }
}
async function main() {
const iconsSrc = await readFile(ICONS_FILE, 'utf8')
const iconBodies = indexIconBodies(iconsSrc)
const blockFiles = (await readdir(BLOCKS_DIR)).filter((f) => f.endsWith('.ts'))
const hazards: Hazard[] = []
const seen = new Set<string>()
for (const file of blockFiles) {
const src = await readFile(path.join(BLOCKS_DIR, file), 'utf8')
if (!/\btemplates:\s*\[/.test(src)) continue
const brandIcons = new Set<string>()
for (const m of src.matchAll(
/import\s*(?:type\s*)?{([^}]*)}\s*from\s*'@\/components\/icons'/g
)) {
for (const name of m[1].split(',')) {
const trimmed = name.trim()
if (trimmed) brandIcons.add(trimmed)
}
}
for (const m of src.matchAll(/\bicon:\s*(\w+)/g)) {
const iconName = m[1]
if (!brandIcons.has(iconName)) continue
const key = `${file}:${iconName}`
if (seen.has(key)) continue
seen.add(key)
const body = iconBodies.get(iconName)
if (!body) continue
const { hazard, detail } = analyzeIcon(body)
if (hazard) {
hazards.push({ block: file.replace('.ts', ''), icon: iconName, kind: hazard, detail })
}
}
}
if (hazards.length === 0) {
console.log('✓ All suggested-action brand icons render safely bare in light and dark mode.')
process.exit(0)
}
console.error(`\nFound ${hazards.length} bare-icon hazard(s):\n`)
for (const h of hazards) {
const mode = h.kind === 'light' ? 'invisible on LIGHT pages' : 'invisible on DARK pages'
console.error(` ${h.block} (${h.icon}) — ${mode}`)
console.error(` ${h.detail}`)
console.error(
` Fix: draw the monochrome shape with fill='currentColor' in components/icons.tsx`
)
console.error(
` so it adapts to the theme bare and to the tile foreground (getTileIconColorClass).\n`
)
}
process.exit(1)
}
main()