Ctrl K
ComunidadGuías…

scripts/theme-check.mjs

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
import { readdir, readFile } from "node:fs/promises";
import path from "node:path";

const root = process.cwd();
const webSource = path.join(root, "apps", "web", "src");
const failures = [];

async function sourceFiles(directory) {
  const entries = await readdir(directory, { withFileTypes: true });
  const files = [];
  for (const entry of entries) {
    const fullPath = path.join(directory, entry.name);
    if (entry.isDirectory()) {
      files.push(...await sourceFiles(fullPath));
      continue;
    }
    if (/\.(?:css|ts|tsx)$/.test(entry.name)) files.push(fullPath);
  }
  return files;
}

function relative(file) {
  return path.relative(root, file).replaceAll(path.sep, "/");
}

function report(file, lineNumber, rule, line) {
  failures.push(`${relative(file)}:${lineNumber} [${rule}] ${line.trim()}`);
}

for (const file of await sourceFiles(webSource)) {
  const rel = relative(file);
  const themeOwned = rel === "apps/web/src/theme.css" || rel === "apps/web/src/theme.tsx";
  const cssThemeOwner = rel === "apps/web/src/theme.css";
  const lines = (await readFile(file, "utf8")).replaceAll("\r\n", "\n").split("\n");

  lines.forEach((line, index) => {
    const lineNumber = index + 1;

    if (/window\.(?:alert|confirm|prompt)\s*\(/.test(line)) {
      report(file, lineNumber, "native-dialog", line);
    }
    if (/(^|[;{]\s*)filter:\s*blur\s*\(/.test(line)) {
      report(file, lineNumber, "filter-blur", line);
    }
    if (/mix-blend-mode\s*:/.test(line)) {
      report(file, lineNumber, "mix-blend-mode", line);
    }
    if (/will-change\s*:/.test(line)) {
      report(file, lineNumber, "will-change", line);
    }
    if (/transition\s*:\s*all(?:\s|;|$)/.test(line)) {
      report(file, lineNumber, "transition-all", line);
    }
    if (/backdrop-filter\s*:/.test(line) && !cssThemeOwner) {
      report(file, lineNumber, "backdrop-owner", line);
    }
    if (!themeOwned && /#[0-9a-fA-F]{3,8}\b|rgba?\s*\(/.test(line)) {
      report(file, lineNumber, "hard-coded-color", line);
    }
  });
}

const themeCssPath = path.join(webSource, "theme.css");
const themeTsPath = path.join(webSource, "theme.tsx");
const themeCss = await readFile(themeCssPath, "utf8");
const themeTs = await readFile(themeTsPath, "utf8");
const terminalViewPath = path.join(webSource, "TerminalView.tsx");
const terminalKeyBarPath = path.join(webSource, "TerminalKeyBar.tsx");
const terminalImeInputPath = path.join(webSource, "terminal-ime-input.ts");
const terminalKeyInputPath = path.join(webSource, "terminal-key-input.ts");
const terminalPreferencesPath = path.join(webSource, "terminal-preferences.ts");
const visualViewportPath = path.join(webSource, "visual-viewport.ts");
const viewportDebugPath = path.join(webSource, "ViewportDebug.tsx");
const mainPath = path.join(webSource, "main.tsx");
const indexHtmlPath = path.join(root, "apps", "web", "index.html");
const stylesPath = path.join(webSource, "styles.css");
const workspaceWorkbenchPath = path.join(webSource, "WorkspaceWorkbench.tsx");
const workspaceDialogPath = path.join(webSource, "WorkspaceDialog.tsx");
const gitPanePath = path.join(webSource, "GitPane.tsx");
const filesPanePath = path.join(webSource, "FilesPane.tsx");
const terminalView = await readFile(terminalViewPath, "utf8");
const terminalKeyBar = await readFile(terminalKeyBarPath, "utf8");
const mobilePrimaryInputs = await readFile(path.join(webSource, "MobilePrimaryInputActions.tsx"), "utf8");
const terminalImeInput = await readFile(terminalImeInputPath, "utf8");
const terminalKeyInput = await readFile(terminalKeyInputPath, "utf8");
const terminalPreferences = await readFile(terminalPreferencesPath, "utf8");
const visualViewport = await readFile(visualViewportPath, "utf8");
const viewportDebug = await readFile(viewportDebugPath, "utf8");
const mainSource = await readFile(mainPath, "utf8");
const indexHtml = await readFile(indexHtmlPath, "utf8");
const styles = (await readFile(stylesPath, "utf8")).replaceAll("\r\n", "\n");

const glassModalBlock = themeCss.match(/\.glass-modal\s*\{([^}]*)\}/)?.[1] ?? "";
if (/\bposition\s*:/.test(glassModalBlock) ||
    !styles.includes("dialog.glass-modal:modal {") ||
    !styles.includes("dialog.confirm-dialog:modal {")) {
  failures.push("apps/web [modal-viewport] dialogs must own their viewport positioning");
}
const workspaceWorkbench = await readFile(workspaceWorkbenchPath, "utf8");
const workspaceDialog = await readFile(workspaceDialogPath, "utf8");
const gitPane = await readFile(gitPanePath, "utf8");
const filesPane = await readFile(filesPanePath, "utf8");

for (const marker of [
  '<TerminalView',
  'active={pane === "terminal"}',
  'workbench-pane',
  'pane === "git"',
  'pane === "files"',
  'pane === "artifacts"',
  '<ArtifactsPane'
]) {
  if (!workspaceWorkbench.includes(marker)) {
    failures.push(`apps/web/src/WorkspaceWorkbench.tsx [workbench-lifecycle-contract] missing ${marker}`);
  }
}

for (const forbidden of [
  'pane === "terminal" ? <TerminalView'
]) {
  if (workspaceWorkbench.includes(forbidden)) {
    failures.push(`apps/web/src/WorkspaceWorkbench.tsx [workbench-lifecycle-contract] forbidden ${forbidden}`);
  }
}

for (const marker of [
  'className="terminal-frame terminal-surface"',
  'className="terminal-mount"',
  '"--terminal-background"',
  'terminalThemeRef.current',
  'terminal.open(mount)',
  'mount.addEventListener("click", focusTerminal);',
  'mount.removeEventListener("click", focusTerminal);',
  '}, [sessionId]);'
]) {
  if (!terminalView.includes(marker)) {
    failures.push(`apps/web/src/TerminalView.tsx [terminal-surface-contract] missing ${marker}`);
  }
}

for (const marker of [
  "<TerminalKeyBar",
  "new Ime229InputTransaction()",
  "recoverIme229ControlKey(event)",
  "terminal.attachCustomKeyEventHandler",
  "terminal.paste(data)",
  "applicationCursorKeysMode:"
]) {
  if (!terminalView.includes(marker)) {
    failures.push(`apps/web/src/TerminalView.tsx [terminal-keybar-contract] missing ${marker}`);
  }
}

for (const marker of [
  'keyButton("Enter", "enter"',
  'shortcutButton("Ctrl+J"',
  'keyButton("Shift+Tab", "backTab")',
  'shortcutButton("Ctrl+D"',
  'shortcutButton("/", "/")',
  'aria-pressed={ctrl}',
  'aria-pressed={alt}',
  'aria-expanded={moreOpen}'
]) {
  if (!terminalKeyBar.includes(marker)) {
    failures.push(`apps/web/src/TerminalKeyBar.tsx [terminal-keybar-contract] missing ${marker}`);
  }
}

for (const marker of [
  'enter: "\\r"',
  'backTab: `\${ESC}[Z`',
  "APPLICATION_CURSOR_SEQUENCES",
  "modifiedNavigationSequence(",
  "applyTerminalModifiers("
]) {
  if (!terminalKeyInput.includes(marker)) {
    failures.push(`apps/web/src/terminal-key-input.ts [terminal-keybar-contract] missing ${marker}`);
  }
}

for (const marker of [
  "class Ime229InputTransaction",
  "textareaInputDelta(",
  "recoverIme229ControlKey("
]) {
  if (!terminalImeInput.includes(marker)) {
    failures.push(`apps/web/src/terminal-ime-input.ts [terminal-ime-contract] missing ${marker}`);
  }
}

for (const marker of [
  "MIN_TERMINAL_FONT_SIZE",
  "MAX_TERMINAL_FONT_SIZE",
  "readTerminalFontSize(",
  "writeTerminalFontSize("
]) {
  if (!terminalPreferences.includes(marker)) {
    failures.push(`apps/web/src/terminal-preferences.ts [terminal-density-contract] missing ${marker}`);
  }
}

for (const [selector, marker] of [
  [".terminal-frame", "padding: 7px 5px 8px;"],
  [".terminal-mount .xterm-screen", "touch-action: none;"],
  [".workbench-page", "overscroll-behavior: none;"]
]) {
  const start = styles.indexOf(`${selector} {`);
  const end = start === -1 ? -1 : styles.indexOf("\n}", start);
  const block = start === -1 || end === -1 ? "" : styles.slice(start, end + 2);
  if (!block.includes(marker)) {
    failures.push(
      `apps/web/src/styles.css [mobile-terminal-scroll-contract] ${selector} missing ${marker}`
    );
  }
}

// The scrollbar option, not CSS, must own both the visible indicator
// and FitAddon's reserved column width. The second CSS gutter caused a
// extra reserved space in browsers with non-overlay gutters.
if (
  !terminalView.includes('scrollbar: {') ||
  !terminalView.includes('width: terminalScrollbarWidth(window.matchMedia("(pointer: coarse)").matches)') ||
  /\.terminal-mount \.xterm-viewport\s*\{[^}]*scrollbar-gutter\s*:/s.test(styles)
) {
  failures.push("apps/web [terminal-scrollbar-geometry] xterm and FitAddon must own the only scrollbar gutter");
}

for (const forbidden of [
  'terminal-frame glass-content',
  'terminal-touch-scroll',
  'attachTerminalTouchScroll',
  'terminal.scrollLines(',
  'touchstart',
  'touchmove',
  'window.visualViewport',
  '}, [sessionId, t]);'
]) {
  if (terminalView.includes(forbidden)) {
    failures.push(`apps/web/src/TerminalView.tsx [terminal-lifecycle-contract] forbidden ${forbidden}`);
  }
}

for (const marker of [
  'ref={workbenchRef}',
  'window.visualViewport',
  'visualViewport.addEventListener("resize", scheduleVisualViewportSync);',
  'visualViewport.addEventListener("scroll", scheduleVisualViewportSync);',
  'workbenchVisualViewportFrame(visualViewport)'
]) {
  if (!workspaceWorkbench.includes(marker)) {
    failures.push(
      `apps/web/src/WorkspaceWorkbench.tsx [mobile-viewport-contract] missing ${marker}`
    );
  }
}

if (
  visualViewport.includes("ZOOM_EPSILON") ||
  visualViewport.includes("Math.abs(scale - 1)") ||
  visualViewport.includes("scale !== 1")
) {
  failures.push(
    "apps/web/src/visual-viewport.ts [mobile-viewport-contract] zoom level must not disable visible-viewport framing"
  );
}

if (!terminalKeyBar.includes('className="keybar-pinned"') ||
    !terminalKeyBar.includes('onClick={onToggleMore}') ||
    !terminalKeyBar.includes('onLongText={onLongInput}') ||
    !terminalKeyBar.includes('<MobilePrimaryInputActions') ||
    !mobilePrimaryInputs.includes('onClick={onKeyboard}') ||
    !mobilePrimaryInputs.includes('onClick={onLongText}') ||
    mobilePrimaryInputs.indexOf('onClick={onKeyboard}') > mobilePrimaryInputs.indexOf('onClick={onLongText}') ||
    !terminalKeyBar.includes('id="terminal-keybar-more"') ||
    terminalKeyBar.includes('dockMode') ||
    !workspaceWorkbench.includes('mobile-workbench-tools') ||
    !workspaceWorkbench.includes('is-terminal')) {
  failures.push("apps/web [terminal-mobile-dock] direct More or primary Long Text missing");
}

if (!visualViewport.includes("return null;")) {
  failures.push(
    "apps/web/src/visual-viewport.ts [mobile-viewport-contract] invalid geometry must still fail closed"
  );
}

for (const marker of [
  'get("viewportDebug") === "1"',
  'window.visualViewport',
  'document.documentElement.clientWidth',
  'viewport.scale',
  'document.activeElement'
]) {
  if (!viewportDebug.includes(marker)) {
    failures.push(
      `apps/web/src/ViewportDebug.tsx [mobile-viewport-debug] missing ${marker}`
    );
  }
}

if (!mainSource.includes("<ViewportDebug />")) {
  failures.push(
    "apps/web/src/main.tsx [mobile-viewport-debug] viewport diagnostics must stay globally available behind the query flag"
  );
}

if (!indexHtml.includes("interactive-widget=resizes-content")) {
  failures.push(
    "apps/web/index.html [mobile-viewport-contract] viewport meta must opt into resizes-content when supported"
  );
}

const terminalMountStart = styles.indexOf(".terminal-mount {");
const terminalMountEnd = terminalMountStart === -1 ? -1 : styles.indexOf("\n}", terminalMountStart);
const terminalMountBlock = terminalMountStart === -1 || terminalMountEnd === -1
  ? ""
  : styles.slice(terminalMountStart, terminalMountEnd + 2);
for (const forbidden of ["padding:", "border:", "overflow: hidden"]) {
  if (terminalMountBlock.includes(forbidden)) {
    failures.push(
      `apps/web/src/styles.css [terminal-fit-contract] .terminal-mount must stay geometry-only; found ${forbidden}`
    );
  }
}

if (
  !styles.includes(".terminal-mount .xterm-helper-textarea {\n  font-size: 16px;\n}") ||
  !styles.includes("@media (max-width: 760px), (hover: none) and (pointer: coarse) {") ||
  !styles.includes(".appearance-controls.compact .glass-select,\n  .language-select {\n    font-size: 16px;") ||
  !styles.includes(".workbench-page.has-visual-viewport-frame {") ||
  !styles.includes("height: var(--workbench-visual-height);")
) {
  failures.push(
    "apps/web/src/styles.css [mobile-viewport-contract] workbench frame and mobile-safe 16px editable controls must remain explicit"
  );
}

if (
  !styles.includes(".terminal-mount .xterm:not(.allow-transparency) .xterm-viewport,") ||
  !styles.includes("background-color: var(--terminal-background);")
) {
  failures.push(
    "apps/web/src/styles.css [terminal-surface-contract] xterm viewport remainder must inherit --terminal-background"
  );
}

const gitSidebarStart = styles.lastIndexOf("\n.git-sidebar {");
const gitSidebarEnd = gitSidebarStart === -1 ? -1 : styles.indexOf("\n}", gitSidebarStart);
const gitSidebarBlock = gitSidebarStart === -1 || gitSidebarEnd === -1
  ? ""
  : styles.slice(gitSidebarStart, gitSidebarEnd + 2);
for (const marker of [
  "overflow-x: hidden;",
  "overflow-y: auto;",
  "overscroll-behavior-y: contain;"
]) {
  if (!gitSidebarBlock.includes(marker)) {
    failures.push(
      `apps/web/src/styles.css [git-scroll-contract] .git-sidebar missing ${marker}`
    );
  }
}

if (
  styles.includes(".file-list,\n.git-groups,\n.git-change-list {") ||
  !styles.includes(".git-groups,\n.git-change-list {\n  overflow: visible;\n}") ||
  !styles.includes(".git-groups {\n  display: block;\n  flex: 0 0 auto;\n}")
) {
  failures.push(
    "apps/web/src/styles.css [git-scroll-contract] Git groups/change lists must not be nested scroll owners"
  );
}

for (const marker of [
  'type GitView = "changes" | "history"',
  'className="git-view-tabs"',
  'workspaceGitHistory(workspaceId',
  'workspaceGitCommit(workspaceId',
  'workspaceGitCommitDiff(',
  'repositoryFileHistoryPath('
]) {
  if (!gitPane.includes(marker)) {
    failures.push(`apps/web/src/GitPane.tsx [mobile-git-history-contract] missing ${marker}`);
  }
}

for (const marker of [
  'readWorkspaceFileContent(',
  'copySelectedFile',
  'shareSelectedFile',
  'onShowHistory',
  'file-preview-actions'
]) {
  if (!filesPane.includes(marker)) {
    failures.push(`apps/web/src/FilesPane.tsx [mobile-file-export-contract] missing ${marker}`);
  }
}

if (
  !styles.includes(".git-view-tabs {") ||
  !styles.includes(".git-history-list {") ||
  !styles.includes(".file-preview-actions {")
) {
  failures.push(
    "apps/web/src/styles.css [mobile-workbench-contract] Git history and file actions must retain explicit themed mobile layout"
  );
}

for (const marker of [
  'className="workspace-dialog glass-modal"',
  'className="workspace-form-body"',
  'workspace-dialog-footer'
]) {
  if (!workspaceDialog.includes(marker)) {
    failures.push(`apps/web/src/WorkspaceDialog.tsx [workspace-modal-contract] missing ${marker}`);
  }
}

for (const marker of [
  'html[data-theme="spectrum"]',
  'html[data-theme="obsidian"]',
  'html[data-theme="frosted"]',
  'html[data-performance="performance"]',
  '.glass-shell',
  '.glass-modal',
  '.glass-panel',
  '.terminal-surface',
  '.glass-content',
  '.glass-control',
  '.glass-card'
]) {
  if (!themeCss.includes(marker)) {
    failures.push(`apps/web/src/theme.css [required-marker] missing ${marker}`);
  }
}

for (const marker of [
  '"spectrum"',
  '"obsidian"',
  '"frosted"',
  '"quality"',
  '"performance"',
  'palmtty.theme',
  'palmtty.visual-performance'
]) {
  if (!themeTs.includes(marker)) {
    failures.push(`apps/web/src/theme.tsx [required-marker] missing ${marker}`);
  }
}


if (failures.length) {
  console.error("Theme contract check failed:");
  for (const failure of failures) console.error(`- ${failure}`);
  process.exit(1);
}

console.log("Theme contract check passed.");