@sudajs/cli 0.18.11 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -6,9 +6,7 @@ import { createRequire } from 'module';
6
6
  import path2 from 'path';
7
7
  import { createInterface } from 'readline/promises';
8
8
  import { fileURLToPath, pathToFileURL } from 'url';
9
- import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
10
- import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
11
- import { themeDesignSystemSchema, createThemeDesignCssVariables, createThemeAgentManifest, createSudaPageAgentRuntime, checkThemeModule, formatThemeCheckResult, agentValidationResultSchema, agentPageSchemaOutputSchema, agentComponentOutputSchema } from '@sudajs/theme-engine';
9
+ import { themeDesignSystemSchema, createThemeDesignFontStylesheetUrl, findThemeDesignSystemFieldName, resolveThemeDesignTokens, createThemeDesignCssVariables, createThemeAgentManifest, createSudaPageAgentRuntime, checkThemeModule, formatThemeCheckResult, agentValidationResultSchema } from '@sudajs/theme-engine';
12
10
  import { themeManifestSchema, runWithThemePreviewLocale } from '@sudajs/theme-engine/server';
13
11
  import { Command } from 'commander';
14
12
  import { build } from 'esbuild';
@@ -904,10 +902,13 @@ export const {
904
902
  SUDA_COLOR_TOKEN_KEYS,
905
903
  createThemeDesignCssVariables,
906
904
  createThemeDesignDefault,
905
+ createThemeDesignFontStylesheetUrl,
907
906
  resolveThemeDesignTokens,
908
907
  SUDA_CUSTOM_DESIGN_PRESET_ID,
909
908
  SUDA_DESIGN_COLOR_KEYS,
910
909
  SUDA_DESIGN_RADIUS_KEYS,
910
+ SUDA_DESIGN_TYPOGRAPHY_KEYS,
911
+ SUDA_THEME_FONT_FAMILIES,
911
912
  compileSudaBlockSlots,
912
913
  isSudaLocalBlockComponent,
913
914
  SUDA_LOCAL_BLOCK_CATEGORY,
@@ -1185,17 +1186,18 @@ async function validateThemeCssTokenContract(root) {
1185
1186
  }
1186
1187
  }
1187
1188
  }
1188
- const sudaAliases = /* @__PURE__ */ new Set();
1189
+ const platformTokenPattern = /var\(\s*--(?:background|foreground|primary(?:-foreground)?|secondary(?:-foreground)?|accent(?:-foreground)?|muted(?:-foreground)?|radius(?:-(?:card|button|input))?)(?:\s*[,)])/;
1190
+ const platformAliases = /* @__PURE__ */ new Set();
1189
1191
  let changed = true;
1190
1192
  while (changed) {
1191
1193
  changed = false;
1192
1194
  for (const [name, value] of declarations) {
1193
- const dependsOnSuda = /var\(\s*--suda-/.test(value);
1194
- const dependsOnAlias = [...sudaAliases].some(
1195
+ const dependsOnPlatformToken = platformTokenPattern.test(value);
1196
+ const dependsOnAlias = [...platformAliases].some(
1195
1197
  (alias) => new RegExp(`var\\(\\s*${escapeRegExp(alias)}(?:\\s*[,\\)])`).test(value)
1196
1198
  );
1197
- if ((dependsOnSuda || dependsOnAlias) && !sudaAliases.has(name)) {
1198
- sudaAliases.add(name);
1199
+ if ((dependsOnPlatformToken || dependsOnAlias) && !platformAliases.has(name)) {
1200
+ platformAliases.add(name);
1199
1201
  changed = true;
1200
1202
  }
1201
1203
  }
@@ -1211,7 +1213,7 @@ async function validateThemeCssTokenContract(root) {
1211
1213
  }
1212
1214
  }
1213
1215
  const searchable = handwrittenStyles.join("");
1214
- for (const alias of sudaAliases) {
1216
+ for (const alias of platformAliases) {
1215
1217
  const reference = new RegExp(`var\\(\\s*${escapeRegExp(alias)}(?:\\s*[,\\)])`, "g");
1216
1218
  for (const match of searchable.matchAll(reference)) {
1217
1219
  const line = searchable.slice(0, match.index ?? 0).split("\n").length;
@@ -1224,7 +1226,7 @@ async function validateThemeCssTokenContract(root) {
1224
1226
  [
1225
1227
  "Hand-written theme CSS must not consume Suda-backed Tailwind aliases from `@theme inline`.",
1226
1228
  ...issues.map((issue) => ` - ${issue}`),
1227
- "Use `var(--suda-*)` directly, or define and consume a theme-prefixed variable on the theme root. Tailwind utilities such as `bg-primary` remain supported."
1229
+ "Use an unprefixed platform token such as `var(--primary)` directly, or define and consume a theme-prefixed variable on the theme root. Tailwind utilities such as `bg-primary` remain supported."
1228
1230
  ].join("\n")
1229
1231
  );
1230
1232
  }
@@ -1724,8 +1726,14 @@ function runFooterContractCheck(theme, renderer) {
1724
1726
  return issues;
1725
1727
  }
1726
1728
  var __testUtils = {
1729
+ activateThemeOutputSchema,
1730
+ contactFormOutputSchema,
1731
+ createPageDraftOutputSchema,
1732
+ createProjectOutputSchema,
1727
1733
  createThemeProjectRequire,
1734
+ createVisualAuditPage,
1728
1735
  createRemotePageDraft,
1736
+ destructivePageOperationOutputSchema,
1729
1737
  findAnchorTagByHref,
1730
1738
  formatMissingSharpWarning,
1731
1739
  loadThemeScopedRenderer,
@@ -1736,13 +1744,19 @@ var __testUtils = {
1736
1744
  performConfirmedPageOperation,
1737
1745
  performUpdateContactForm,
1738
1746
  performUpdateThemeSettings,
1747
+ getRemoteContactForm,
1739
1748
  getRemoteThemeSettings,
1749
+ listProjectsOutputSchema,
1740
1750
  renderDevStarterPageHtml,
1741
1751
  renderDevEditorPageHtml,
1752
+ readDevThemeLocaleMessages,
1742
1753
  writeVitePreviewEntries,
1743
1754
  resolveDevEditorSlug,
1744
1755
  resolveDevStarterSlug,
1756
+ resolveDevVisualAuditComponent,
1757
+ getThemeVisualAuditComponentTypes,
1745
1758
  resolveScreenshotOptions,
1759
+ resolveVisualCheckOptions,
1746
1760
  slugifyThemeName,
1747
1761
  validateThemeKey,
1748
1762
  validateThemeName,
@@ -1753,7 +1767,11 @@ var __testUtils = {
1753
1767
  resolveThemePreviewLocale,
1754
1768
  withSerializedVitePreviewLocaleLoad,
1755
1769
  toViteModuleUrl,
1756
- updateRemotePageDraft
1770
+ themeSettingsOutputSchema,
1771
+ updateContactFormOutputSchema,
1772
+ updatePageDraftOutputSchema,
1773
+ updateRemotePageDraft,
1774
+ updateThemeSettingsOutputSchema
1757
1775
  };
1758
1776
  async function runThemeCheck(theme) {
1759
1777
  const result = checkThemeModule(theme.module);
@@ -1809,7 +1827,7 @@ async function watchTheme(root, port) {
1809
1827
  console.log(`previewing Vite theme at ${style.url(handle.url)}`);
1810
1828
  await new Promise(() => void 0);
1811
1829
  }
1812
- async function startViteDevPreviewServer(root, port) {
1830
+ async function startViteDevPreviewServer(root, port, options = {}) {
1813
1831
  const viteConfig = await findViteConfig(root);
1814
1832
  const vite = await loadThemeVite(root);
1815
1833
  await writeVitePreviewEntries(root);
@@ -1824,13 +1842,14 @@ async function startViteDevPreviewServer(root, port) {
1824
1842
  entries: [".suda-build/preview-client.ts", ".suda-build/editor-client.ts"]
1825
1843
  },
1826
1844
  server: { host, port },
1827
- plugins: [createSudaPreviewVitePlugin(root)]
1845
+ plugins: [createSudaPreviewVitePlugin(root, options)]
1828
1846
  });
1829
1847
  await server.listen(port);
1830
1848
  const url = server.resolvedUrls?.local.find((candidate) => candidate.includes("127.0.0.1")) ?? server.resolvedUrls?.local[0] ?? `http://${host}:${port}/`;
1831
1849
  return {
1832
1850
  url: url.replace(/\/$/, ""),
1833
1851
  port,
1852
+ server,
1834
1853
  close: () => server.close()
1835
1854
  };
1836
1855
  }
@@ -1840,8 +1859,8 @@ async function writeVitePreviewEntries(root) {
1840
1859
  const buildDir = path2.join(root, ".suda-build");
1841
1860
  await mkdir(buildDir, { recursive: true });
1842
1861
  await writeFile(
1843
- path2.join(buildDir, "runtime.dev.ts"),
1844
- createClientRuntimeSource(clientHooksImport, false),
1862
+ path2.join(buildDir, "runtime.dev.js"),
1863
+ createClientRuntimeSource(clientHooksImport, false, false),
1845
1864
  "utf8"
1846
1865
  );
1847
1866
  await writeFile(
@@ -1853,7 +1872,7 @@ async function writeVitePreviewEntries(root) {
1853
1872
  'if(!payloadElement){throw new Error("Missing Suda theme hydration payload.")}',
1854
1873
  "const payload=JSON.parse(payloadElement.textContent ?? '{}');",
1855
1874
  "if(payload.previewLocaleMessages){installThemePreviewLocale(payload.previewLocaleMessages)}",
1856
- 'const runtime=(await loadThemeRuntime("/.suda-build/runtime.dev.ts")).default;',
1875
+ 'const runtime=(await loadThemeRuntime("/.suda-build/runtime.dev.js")).default;',
1857
1876
  'const metadata={contactForm:payload.contactForm,resolveAssetUrl:(value: string | undefined)=>resolveAssetPath(value,{legacyResolve:(key)=>key.startsWith("themes/")?`/api/themes/${key.slice("themes/".length)}`:undefined})};',
1858
1877
  'await hydrateThemePage({container:document.querySelector("[data-theme-hydration-root]")!,runtime,pageData:payload.pageData,layoutData:payload.layoutData,metadata,onRecoverableError:(error)=>console.error("[theme-dev] theme hydration recovered from an error",error)});',
1859
1878
  ""
@@ -1864,26 +1883,50 @@ async function writeVitePreviewEntries(root) {
1864
1883
  path2.join(buildDir, "editor-client.ts"),
1865
1884
  [
1866
1885
  'import "@puckeditor/core/puck.css";',
1867
- 'import { composeUnifiedThemeEditorData, createUnifiedThemeEditorConfig, installThemePreviewLocale, resolveAssetPath, ThemeEditor } from "@sudajs/theme-engine";',
1886
+ 'import { composeUnifiedThemeEditorData, createThemeDesignFontStylesheetUrl, createUnifiedThemeEditorConfig, installThemeLocale, installThemePreviewLocale, resolveAssetPath, resolveThemeDesignTokens, ThemeEditor } from "@sudajs/theme-engine";',
1868
1887
  'import { createElement } from "react";',
1869
1888
  'import { createRoot } from "react-dom/client";',
1870
- 'import runtime from "./runtime.dev";',
1871
1889
  'const payloadElement=document.querySelector("#suda-theme-editor-data");',
1872
1890
  'if(!payloadElement){throw new Error("Missing Suda theme editor payload.")}',
1873
1891
  "const payload=JSON.parse(payloadElement.textContent ?? '{}');",
1892
+ "if(payload.themeLocaleMessages){installThemeLocale(payload.themeLocaleMessages)}",
1874
1893
  "if(payload.previewLocaleMessages){installThemePreviewLocale(payload.previewLocaleMessages)}",
1894
+ 'const runtime=(await import("./runtime.dev")).default;',
1875
1895
  'const resolveAssetUrl=(value: string | undefined)=>resolveAssetPath(value,{legacyResolve:(key)=>key.startsWith("themes/")?`/api/themes/${key.slice("themes/".length)}`:undefined});',
1876
1896
  'const config=createUnifiedThemeEditorConfig(runtime.theme,undefined,{labels:{pageContent:"Page content",themeSettings:"Theme settings"}});',
1877
1897
  "const data=composeUnifiedThemeEditorData(payload.layoutData,payload.pageData,{layoutConfig:runtime.theme.layoutConfig,pageConfig:runtime.theme.pageConfig});",
1898
+ 'function applyDesignFont(nextData){const fieldName=payload.designSystemFieldName||"designSystem";const value=nextData?.root?.props?.[fieldName];const tokens=resolveThemeDesignTokens(payload.designSystem,value);const href=createThemeDesignFontStylesheetUrl(tokens);let link=document.querySelector("#suda-theme-fonts");if(!href){link?.remove();return}if(!link){link=document.createElement("link");link.id="suda-theme-fonts";link.rel="stylesheet";document.head.append(link)}link.href=href}',
1899
+ "applyDesignFont(data);",
1878
1900
  'const editorContext={assets:[],resolveAssetUrl,currentPageId:payload.currentPageId,backHref:payload.backHref,editorPages:payload.editorPages,editorLocale:payload.locale,chromeMessages:{backToPages:"Back to preview",pageMenuLabel:"Switch starter page",themeSettings:"Theme settings",outline:"Outline",blocks:"Blocks",switchViewport:"Switch viewport"}};',
1879
1901
  "const metadata={contactForm:payload.contactForm,isEditor:true,resolveAssetUrl};",
1880
- 'createRoot(document.querySelector("#suda-theme-dev-editor-root")!).render(createElement(ThemeEditor,{config,data,editorContext,metadata,iframe:false,height:"100%",publishActionVisible:false}));',
1902
+ 'createRoot(document.querySelector("#suda-theme-dev-editor-root")!).render(createElement(ThemeEditor,{config,data,editorContext,metadata,iframe:false,height:"100%",previewStylesheetUrls:["/src/styles.css"],publishActionVisible:false,onChange:applyDesignFont}));',
1881
1903
  ""
1882
1904
  ].join("\n"),
1883
1905
  "utf8"
1884
1906
  );
1885
1907
  }
1886
- function createClientRuntimeSource(clientHooksImport, useClientDirective) {
1908
+ function createClientRuntimeSource(clientHooksImport, useClientDirective, typescript = true) {
1909
+ if (!typescript) {
1910
+ return [
1911
+ ...useClientDirective ? ['"use client";'] : [],
1912
+ 'import * as themeConfig from "../src/config.js";',
1913
+ clientHooksImport,
1914
+ "const theme = {",
1915
+ " pageConfig: themeConfig.pageConfig,",
1916
+ " layoutConfig: themeConfig.layoutConfig,",
1917
+ "};",
1918
+ 'if ("rendering" in themeConfig && themeConfig.rendering !== undefined) {',
1919
+ " theme.rendering = themeConfig.rendering;",
1920
+ "}",
1921
+ "const hooks = clientHooks;",
1922
+ "const runtime = { theme };",
1923
+ "if (hooks.initialize !== undefined) {",
1924
+ " runtime.initialize = hooks.initialize;",
1925
+ "}",
1926
+ "export default runtime;",
1927
+ ""
1928
+ ].join("\n");
1929
+ }
1887
1930
  return [
1888
1931
  ...useClientDirective ? ['"use client";'] : [],
1889
1932
  'import type { ThemeClientHooks, ThemeClientRuntime, ThemeRenderModule } from "@sudajs/theme-engine/runtime";',
@@ -1905,22 +1948,49 @@ function createClientRuntimeSource(clientHooksImport, useClientDirective) {
1905
1948
  ""
1906
1949
  ].join("\n");
1907
1950
  }
1908
- function createSudaPreviewVitePlugin(root) {
1951
+ function createSudaPreviewVitePlugin(root, options = {}) {
1909
1952
  return {
1910
1953
  name: "suda-theme-preview",
1954
+ resolveId(source, importer) {
1955
+ if (importer === void 0 || !source.endsWith(".js") || !source.startsWith(".")) {
1956
+ return void 0;
1957
+ }
1958
+ const requestedPath = path2.resolve(path2.dirname(importer), source);
1959
+ if (!requestedPath.startsWith(`${root}${path2.sep}`)) {
1960
+ return void 0;
1961
+ }
1962
+ for (const extension of [".ts", ".tsx"]) {
1963
+ const candidate = `${requestedPath.slice(0, -3)}${extension}`;
1964
+ if (existsSync(candidate)) {
1965
+ return candidate;
1966
+ }
1967
+ }
1968
+ return void 0;
1969
+ },
1911
1970
  configureServer(server) {
1912
1971
  server.middlewares.use((request, response, next) => {
1913
1972
  void (async () => {
1914
1973
  const url = new URL(request.url ?? "/", "http://localhost");
1974
+ if (url.pathname.startsWith("/src/") && url.pathname.endsWith(".js")) {
1975
+ const sourcePath = path2.join(root, url.pathname.slice(1));
1976
+ for (const extension of [".ts", ".tsx"]) {
1977
+ const candidate = `${sourcePath.slice(0, -3)}${extension}`;
1978
+ if (existsSync(candidate)) {
1979
+ request.url = `${url.pathname.slice(0, -3)}${extension}${url.search}`;
1980
+ break;
1981
+ }
1982
+ }
1983
+ }
1915
1984
  const assetPrefixMatch = url.pathname.match(/^\/api\/themes\/[^/]+\/[^/]+\/assets\/(.+)$/);
1916
1985
  if (assetPrefixMatch?.[1]) {
1917
1986
  request.url = `/assets/${assetPrefixMatch[1]}${url.search}`;
1918
1987
  next();
1919
1988
  return;
1920
1989
  }
1990
+ const visualAuditComponent = resolveDevVisualAuditComponent(url.pathname);
1921
1991
  const editorSlug = resolveDevEditorSlug(url.pathname);
1922
1992
  const previewSlug = resolveDevStarterSlug(url.pathname);
1923
- if (editorSlug === void 0 && previewSlug === void 0) {
1993
+ if (visualAuditComponent === void 0 && editorSlug === void 0 && previewSlug === void 0) {
1924
1994
  next();
1925
1995
  return;
1926
1996
  }
@@ -1938,11 +2008,11 @@ function createSudaPreviewVitePlugin(root) {
1938
2008
  );
1939
2009
  const renderer = await loadThemeScopedRenderer(root);
1940
2010
  const starterSlug = slug ?? pickStarterSlug(localized.theme);
1941
- const starter = starterSlug ? findStarterPage(localized.theme, starterSlug) : null;
2011
+ const starter = visualAuditComponent ? createVisualAuditPage(localized.theme, visualAuditComponent) : starterSlug ? findStarterPage(localized.theme, starterSlug) : null;
1942
2012
  if (!starter) {
1943
2013
  response.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
1944
2014
  response.end(
1945
- starterSlug ? `Unknown starter page: ${starterSlug}` : "No starter pages declared by this theme."
2015
+ starterSlug ? `Unknown starter page: ${starterSlug}` : visualAuditComponent ? `Unknown theme page component: ${visualAuditComponent}` : "No starter pages declared by this theme."
1946
2016
  );
1947
2017
  return;
1948
2018
  }
@@ -1952,7 +2022,8 @@ function createSudaPreviewVitePlugin(root) {
1952
2022
  renderer,
1953
2023
  localized.locale,
1954
2024
  url.searchParams,
1955
- localized.previewLocaleMessages
2025
+ localized.previewLocaleMessages,
2026
+ localized.themeLocaleMessages
1956
2027
  ) : renderDevStarterPageHtml(
1957
2028
  localized.theme,
1958
2029
  starter,
@@ -1960,7 +2031,8 @@ function createSudaPreviewVitePlugin(root) {
1960
2031
  localized.locale,
1961
2032
  canonicalLocale(explicitLang) !== null,
1962
2033
  localized.previewLocaleMessages,
1963
- url.searchParams
2034
+ url.searchParams,
2035
+ { showDevTools: options.showDevTools !== false }
1964
2036
  );
1965
2037
  const html = await server.transformIndexHtml(url.pathname, htmlSource);
1966
2038
  response.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
@@ -1985,8 +2057,9 @@ async function loadLocalizedViteDevTheme(root, server, explicitLang, acceptLangu
1985
2057
  localeParams.acceptLanguage = acceptLanguage;
1986
2058
  }
1987
2059
  const locale = resolveThemePreviewLocale(localeParams);
2060
+ const themeLocaleMessages = await readDevThemeLocaleMessages(root, locale);
1988
2061
  if (!baseTheme.module.manifest.previewLocales) {
1989
- return { theme: baseTheme, locale };
2062
+ return { theme: baseTheme, locale, themeLocaleMessages };
1990
2063
  }
1991
2064
  const previewLocaleMessages = await readDevPreviewLocaleMessages(root, locale);
1992
2065
  server.moduleGraph.invalidateAll();
@@ -1994,7 +2067,7 @@ async function loadLocalizedViteDevTheme(root, server, explicitLang, acceptLangu
1994
2067
  previewLocaleMessages,
1995
2068
  () => loadViteDevTheme(root, server)
1996
2069
  );
1997
- return { theme, locale, previewLocaleMessages };
2070
+ return { theme, locale, themeLocaleMessages, previewLocaleMessages };
1998
2071
  });
1999
2072
  }
2000
2073
  var vitePreviewLocaleLoadTails = /* @__PURE__ */ new WeakMap();
@@ -2043,6 +2116,42 @@ function findStarterPage(theme, slug) {
2043
2116
  return theme.module.starterPages.find((page) => page.slug === slug) ?? null;
2044
2117
  }
2045
2118
  var DEV_EDITOR_PATH = "/__suda/editor";
2119
+ var DEV_VISUAL_AUDIT_PATH = "/__suda/visual-audit";
2120
+ function resolveDevVisualAuditComponent(pathname) {
2121
+ if (!pathname.startsWith(`${DEV_VISUAL_AUDIT_PATH}/`)) {
2122
+ return void 0;
2123
+ }
2124
+ const encodedComponent = pathname.slice(DEV_VISUAL_AUDIT_PATH.length + 1);
2125
+ if (!encodedComponent || encodedComponent.includes("/")) {
2126
+ return void 0;
2127
+ }
2128
+ try {
2129
+ return decodeURIComponent(encodedComponent);
2130
+ } catch {
2131
+ return void 0;
2132
+ }
2133
+ }
2134
+ function getThemeVisualAuditComponentTypes(theme) {
2135
+ return Object.keys(theme.module.pageConfig.components);
2136
+ }
2137
+ function createVisualAuditPage(theme, type) {
2138
+ const themeComponent = theme.module.pageConfig.components[type];
2139
+ if (!themeComponent) {
2140
+ return null;
2141
+ }
2142
+ const props = {
2143
+ ...themeComponent.defaultProps ?? {},
2144
+ id: `suda-visual-audit-${safeObjectKeyPart(type)}`
2145
+ };
2146
+ return {
2147
+ slug: `visual-audit-${safeObjectKeyPart(type).toLowerCase()}`,
2148
+ title: `Visual audit: ${type}`,
2149
+ data: {
2150
+ root: { props: {} },
2151
+ content: [{ type, props }]
2152
+ }
2153
+ };
2154
+ }
2046
2155
  function resolveDevEditorSlug(pathname) {
2047
2156
  if (pathname === DEV_EDITOR_PATH || pathname === `${DEV_EDITOR_PATH}/`) {
2048
2157
  return null;
@@ -2136,6 +2245,16 @@ async function readDevPreviewLocaleMessages(root, locale) {
2136
2245
  }
2137
2246
  return messages;
2138
2247
  }
2248
+ async function readDevThemeLocaleMessages(root, locale) {
2249
+ const localesDir = path2.join(root, "src", "locales");
2250
+ const localizedPath = path2.join(localesDir, `${locale}.json`);
2251
+ const filePath = await pathExists(localizedPath) ? localizedPath : path2.join(localesDir, "en.json");
2252
+ const messages = await readLocaleJson(filePath);
2253
+ if (!isRecord(messages)) {
2254
+ throw new Error(`Theme locale ${locale} must be a JSON object.`);
2255
+ }
2256
+ return messages;
2257
+ }
2139
2258
  function escapeHtml(value) {
2140
2259
  return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
2141
2260
  }
@@ -2169,8 +2288,10 @@ async function loadThemeScopedRenderer(themeRoot) {
2169
2288
  renderToString: reactDomServerModule.renderToString
2170
2289
  };
2171
2290
  }
2172
- function renderDevStarterPageHtml(theme, page, renderer, locale = "en", preserveLang = false, previewLocaleMessages, searchParams = new URLSearchParams()) {
2291
+ function renderDevStarterPageHtml(theme, page, renderer, locale = "en", preserveLang = false, previewLocaleMessages, searchParams = new URLSearchParams(), options = {}) {
2173
2292
  const layoutData = createDevPreviewLayout(theme, searchParams);
2293
+ const designTokens = resolveDevDesignTokens(theme, layoutData);
2294
+ const fontStylesheetUrl = createThemeDesignFontStylesheetUrl(designTokens);
2174
2295
  const chrome = renderer.extractLayoutChrome(layoutData);
2175
2296
  const metadata = {
2176
2297
  resolveAssetUrl: createPreviewAssetResolver(renderer.resolveAssetPath),
@@ -2187,10 +2308,11 @@ function renderDevStarterPageHtml(theme, page, renderer, locale = "en", preserve
2187
2308
  const cssVarStyle = Object.entries(chrome.cssVariables).map(([key, value]) => `${key}: ${value};`).join(" ");
2188
2309
  const customHead = chrome.customHeadCode ?? "";
2189
2310
  const customBody = chrome.customBodyCode ?? "";
2190
- const designPalette = renderDevDesignPalette(
2311
+ const showDevTools = options.showDevTools !== false;
2312
+ const designPalette = showDevTools ? renderDevDesignPalette(
2191
2313
  theme.module.manifest.designSystem,
2192
2314
  createDevEditorHref(page.slug, searchParams)
2193
- );
2315
+ ) : null;
2194
2316
  const hydrationPayload = serializeInlineScriptValue({
2195
2317
  pageData: page.data,
2196
2318
  layoutData,
@@ -2209,12 +2331,13 @@ function renderDevStarterPageHtml(theme, page, renderer, locale = "en", preserve
2209
2331
  '<meta name="viewport" content="width=device-width, initial-scale=1" />',
2210
2332
  `<title>${escapeHtml(`${theme.module.manifest.name} \u2014 ${page.title}`)}</title>`,
2211
2333
  '<link rel="stylesheet" href="/src/styles.css" />',
2212
- designPalette.style,
2334
+ ...fontStylesheetUrl ? [`<link id="suda-theme-fonts" rel="stylesheet" href="${escapeHtml(fontStylesheetUrl)}" />`] : [],
2213
2335
  customHead,
2336
+ designPalette?.style ?? "",
2214
2337
  "</head>",
2215
2338
  `<body${cssVarStyle ? ` style="${cssVarStyle}"` : ""}>`,
2216
2339
  `<div data-theme-hydration-root="" style="display:contents">${body}</div>`,
2217
- designPalette.html,
2340
+ designPalette?.html ?? "",
2218
2341
  customBody,
2219
2342
  hydrationScript,
2220
2343
  ...preserveLang ? [
@@ -2251,10 +2374,7 @@ function createDevPreviewLayout(theme, searchParams) {
2251
2374
  if (!requestedPreset) {
2252
2375
  return theme.module.defaultLayout;
2253
2376
  }
2254
- const rootFields = theme.module.layoutConfig.root?.fields ?? {};
2255
- const designFieldName = Object.entries(rootFields).find(
2256
- ([, field]) => field.metadata?.sudaField === "designSystem"
2257
- )?.[0];
2377
+ const designFieldName = findThemeDesignSystemFieldName(theme.module.layoutConfig.root?.fields);
2258
2378
  if (!designFieldName) {
2259
2379
  return theme.module.defaultLayout;
2260
2380
  }
@@ -2268,8 +2388,16 @@ function createDevPreviewLayout(theme, searchParams) {
2268
2388
  };
2269
2389
  return layoutData;
2270
2390
  }
2271
- function renderDevEditorPageHtml(theme, page, renderer, locale = "en", searchParams = new URLSearchParams(), previewLocaleMessages) {
2391
+ function resolveDevDesignTokens(theme, layoutData) {
2392
+ const designFieldName = findThemeDesignSystemFieldName(theme.module.layoutConfig.root?.fields);
2393
+ const rootProps = layoutData.root?.props;
2394
+ const designValue = designFieldName ? rootProps?.[designFieldName] : void 0;
2395
+ return resolveThemeDesignTokens(theme.module.manifest.designSystem, designValue);
2396
+ }
2397
+ function renderDevEditorPageHtml(theme, page, renderer, locale = "en", searchParams = new URLSearchParams(), previewLocaleMessages, themeLocaleMessages) {
2272
2398
  const layoutData = createDevPreviewLayout(theme, searchParams);
2399
+ const designTokens = resolveDevDesignTokens(theme, layoutData);
2400
+ const fontStylesheetUrl = createThemeDesignFontStylesheetUrl(designTokens);
2273
2401
  const chrome = renderer.extractLayoutChrome(layoutData);
2274
2402
  const cssVariables = { ...chrome.cssVariables };
2275
2403
  const cssVarStyle = Object.entries(cssVariables).map(([key, value]) => `${key}: ${value};`).join(" ");
@@ -2285,6 +2413,9 @@ function renderDevEditorPageHtml(theme, page, renderer, locale = "en", searchPar
2285
2413
  layoutData,
2286
2414
  contactForm: PREVIEW_CONTACT_FORM,
2287
2415
  previewLocaleMessages,
2416
+ designSystem: theme.module.manifest.designSystem,
2417
+ designSystemFieldName: findThemeDesignSystemFieldName(theme.module.layoutConfig.root?.fields) ?? "designSystem",
2418
+ themeLocaleMessages,
2288
2419
  locale,
2289
2420
  currentPageId: page.slug,
2290
2421
  backHref: createDevPreviewHref(page, searchParams),
@@ -2298,6 +2429,7 @@ function renderDevEditorPageHtml(theme, page, renderer, locale = "en", searchPar
2298
2429
  '<meta name="viewport" content="width=device-width, initial-scale=1" />',
2299
2430
  `<title>${escapeHtml(`Edit ${theme.module.manifest.name} \u2014 ${page.title}`)}</title>`,
2300
2431
  '<link rel="stylesheet" href="/src/styles.css" />',
2432
+ ...fontStylesheetUrl ? [`<link id="suda-theme-fonts" rel="stylesheet" href="${escapeHtml(fontStylesheetUrl)}" />`] : [],
2301
2433
  "<style>html,body,#suda-theme-dev-editor-root{height:100%;margin:0}body{overflow:hidden}</style>",
2302
2434
  "</head>",
2303
2435
  `<body${cssVarStyle ? ` style="${cssVarStyle}"` : ""}>`,
@@ -2312,10 +2444,14 @@ function serializeInlineScriptValue(value) {
2312
2444
  return JSON.stringify(value).replaceAll("<", "\\u003c").replaceAll("\u2028", "\\u2028").replaceAll("\u2029", "\\u2029");
2313
2445
  }
2314
2446
  function renderDevDesignPalette(designSystem, editorHref) {
2315
- const hasPresetSwitcher = designSystem.presets.length >= 2;
2447
+ const primaryColors = new Set(
2448
+ designSystem.presets.map((preset) => preset.tokens.colors.primary)
2449
+ );
2450
+ const hasPresetSwitcher = primaryColors.size >= 2;
2316
2451
  const presets = designSystem.presets.map((preset) => ({
2317
2452
  id: preset.id,
2318
- variables: createThemeDesignCssVariables(preset.tokens)
2453
+ variables: createThemeDesignCssVariables(preset.tokens),
2454
+ fontStylesheetUrl: createThemeDesignFontStylesheetUrl(preset.tokens)
2319
2455
  }));
2320
2456
  const selectedIcon = renderToStaticMarkup(
2321
2457
  createElement(CircleCheck, {
@@ -2347,7 +2483,7 @@ function renderDevDesignPalette(designSystem, editorHref) {
2347
2483
  });
2348
2484
  return {
2349
2485
  style: '<style data-suda-dev-palette-style>\n#suda-dev-palette{all:initial;position:fixed;z-index:2147483000;top:50%;right:16px;width:64px;max-height:calc(100svh - 32px);padding:8px 9px;overflow-x:hidden;overflow-y:auto;box-sizing:border-box;background:#fff;border:1px solid #d1d5db;border-radius:999px;box-shadow:0 8px 24px rgb(15 23 42 / 12%);transform:translateY(-50%);color-scheme:light;scrollbar-width:none;-ms-overflow-style:none}\n#suda-dev-palette::-webkit-scrollbar{display:none}\n#suda-dev-palette button{all:initial;position:relative;display:grid;width:44px;height:44px;box-sizing:border-box;cursor:pointer;border-radius:999px;place-items:center}\n#suda-dev-palette button span{display:block;width:32px;height:32px;border-radius:999px;transition:transform 120ms ease}\n#suda-dev-palette button:hover span{transform:scale(1.08)}\n#suda-dev-palette button[aria-pressed="true"] span{visibility:hidden}\n#suda-dev-palette .suda-dev-preset-selected-icon{position:absolute;display:block;width:32px;height:32px;color:var(--suda-dev-preset-primary);visibility:hidden;opacity:0;transform:scale(.85);transition:opacity 120ms ease,transform 120ms ease}\n#suda-dev-palette button[aria-pressed="true"] .suda-dev-preset-selected-icon{visibility:visible;opacity:1;transform:scale(1)}\n#suda-dev-palette button:focus-visible{outline:2px solid #94a3b8;outline-offset:2px}\n#suda-dev-palette .suda-dev-edit-divider{display:block;width:28px;height:1px;margin:4px auto;background:#e2e8f0}\n#suda-dev-palette .suda-dev-edit-link{all:initial;display:grid;width:44px;height:44px;box-sizing:border-box;color:#334155;cursor:pointer;border-radius:999px;place-items:center;transition:background-color 120ms ease,color 120ms ease}\n#suda-dev-palette .suda-dev-edit-link:hover{color:#0f172a;background:#f1f5f9}\n#suda-dev-palette .suda-dev-edit-link:focus-visible{outline:2px solid #94a3b8;outline-offset:2px}\n@media(max-width:640px){#suda-dev-palette{right:8px;max-height:calc(100svh - 16px)}}\n@media(prefers-reduced-motion:reduce){#suda-dev-palette button span,#suda-dev-palette .suda-dev-preset-selected-icon,#suda-dev-palette .suda-dev-edit-link{transition:none}}\n</style>',
2350
- html: `<aside id="suda-dev-palette" aria-label="Theme development tools">${buttons}${hasPresetSwitcher ? '<span class="suda-dev-edit-divider" aria-hidden="true"></span>' : ""}<a class="suda-dev-edit-link" data-suda-dev-editor-link="" href="${escapeHtml(editorHref)}" title="Edit starter page" aria-label="Edit starter page">${editIcon}</a></aside>` + (hasPresetSwitcher ? `<script data-suda-dev-palette-script>(function(){const config=${config};const palette=document.getElementById("suda-dev-palette");if(!palette)return;const byId=new Map(config.presets.map((preset)=>[preset.id,preset]));const params=new URL(location.href).searchParams;const requested=params.get("designPreset");const selected=byId.has(requested)?requested:config.defaultPresetId;function apply(id,updateUrl){const preset=byId.get(id);if(!preset)return;const properties=Object.keys(preset.variables);const targets=[document.documentElement,document.body,...document.querySelectorAll("[style]")].filter((element,index,items)=>items.indexOf(element)===index&&(element===document.documentElement||element===document.body||properties.some((property)=>element.style.getPropertyValue(property)!=="")));for(const target of targets){for(const [property,value] of Object.entries(preset.variables))target.style.setProperty(property,value)}for(const button of palette.querySelectorAll("[data-suda-dev-preset]")){const active=button.dataset.sudaDevPreset===id;button.setAttribute("aria-pressed",String(active));button.style.setProperty("--suda-dev-preset-primary",byId.get(button.dataset.sudaDevPreset).variables["--suda-color-primary"])}const editorLink=palette.querySelector("[data-suda-dev-editor-link]");if(editorLink){const editorUrl=new URL(editorLink.href,location.href);editorUrl.searchParams.set("designPreset",id);editorLink.href=editorUrl.pathname+editorUrl.search+editorUrl.hash}if(updateUrl){const url=new URL(location.href);url.searchParams.set("designPreset",id);history.replaceState(history.state,"",url)}}if(requested&&byId.has(requested))apply(selected,false);else for(const button of palette.querySelectorAll("[data-suda-dev-preset]")){button.setAttribute("aria-pressed",String(button.dataset.sudaDevPreset===selected));button.style.setProperty("--suda-dev-preset-primary",byId.get(button.dataset.sudaDevPreset).variables["--suda-color-primary"])}palette.addEventListener("click",(event)=>{const button=event.target.closest("[data-suda-dev-preset]");if(button)apply(button.dataset.sudaDevPreset,true)})})();</script>` : "")
2486
+ html: `<aside id="suda-dev-palette" aria-label="Theme development tools">${buttons}${hasPresetSwitcher ? '<span class="suda-dev-edit-divider" aria-hidden="true"></span>' : ""}<a class="suda-dev-edit-link" data-suda-dev-editor-link="" href="${escapeHtml(editorHref)}" title="Edit starter page" aria-label="Edit starter page">${editIcon}</a></aside>` + (hasPresetSwitcher ? `<script data-suda-dev-palette-script>(function(){const config=${config};const palette=document.getElementById("suda-dev-palette");if(!palette)return;const byId=new Map(config.presets.map((preset)=>[preset.id,preset]));const params=new URL(location.href).searchParams;const requested=params.get("designPreset");const selected=byId.has(requested)?requested:config.defaultPresetId;function apply(id,updateUrl){const preset=byId.get(id);if(!preset)return;const properties=Object.keys(preset.variables);const targets=[document.documentElement,document.body,...document.querySelectorAll("[style]")].filter((element,index,items)=>items.indexOf(element)===index&&(element===document.documentElement||element===document.body||properties.some((property)=>element.style.getPropertyValue(property)!=="")));for(const target of targets){for(const [property,value] of Object.entries(preset.variables))target.style.setProperty(property,value)}let fontLink=document.querySelector("#suda-theme-fonts");if(preset.fontStylesheetUrl){if(!fontLink){fontLink=document.createElement("link");fontLink.id="suda-theme-fonts";fontLink.rel="stylesheet";document.head.append(fontLink)}fontLink.href=preset.fontStylesheetUrl}else fontLink?.remove();for(const button of palette.querySelectorAll("[data-suda-dev-preset]")){const active=button.dataset.sudaDevPreset===id;button.setAttribute("aria-pressed",String(active));button.style.setProperty("--suda-dev-preset-primary",byId.get(button.dataset.sudaDevPreset).variables["--primary"])}const editorLink=palette.querySelector("[data-suda-dev-editor-link]");if(editorLink){const editorUrl=new URL(editorLink.href,location.href);editorUrl.searchParams.set("designPreset",id);editorLink.href=editorUrl.pathname+editorUrl.search+editorUrl.hash}if(updateUrl){const url=new URL(location.href);url.searchParams.set("designPreset",id);history.replaceState(history.state,"",url)}}if(requested&&byId.has(requested))apply(selected,false);else for(const button of palette.querySelectorAll("[data-suda-dev-preset]")){button.setAttribute("aria-pressed",String(button.dataset.sudaDevPreset===selected));button.style.setProperty("--suda-dev-preset-primary",byId.get(button.dataset.sudaDevPreset).variables["--primary"])}palette.addEventListener("click",(event)=>{const button=event.target.closest("[data-suda-dev-preset]");if(button)apply(button.dataset.sudaDevPreset,true)})})();</script>` : "")
2351
2487
  };
2352
2488
  }
2353
2489
  var SCREENSHOT_DEVICES = ["desktop", "tablet", "mobile"];
@@ -2422,6 +2558,33 @@ function resolveScreenshotOptions(root, options) {
2422
2558
  fullPage: options.fullPage === true
2423
2559
  };
2424
2560
  }
2561
+ function parseVisualCheckPresetIds(theme, value) {
2562
+ const available = theme.module.manifest.designSystem.presets.map((preset) => preset.id);
2563
+ if (value === void 0) {
2564
+ return available;
2565
+ }
2566
+ const requested = (Array.isArray(value) ? value : [value]).flatMap((item) => item.split(",")).map((item) => item.trim()).filter(Boolean);
2567
+ if (requested.length === 0) {
2568
+ return available;
2569
+ }
2570
+ const unknown = requested.filter((preset) => !available.includes(preset));
2571
+ if (unknown.length > 0) {
2572
+ throw new Error(
2573
+ `Unknown design preset ${unknown.map((preset) => `"${preset}"`).join(", ")}. Available presets: ${available.join(", ")}.`
2574
+ );
2575
+ }
2576
+ return Array.from(new Set(requested));
2577
+ }
2578
+ function resolveVisualCheckOptions(root, theme, options) {
2579
+ const outputDir = path2.resolve(root, options.output ?? path2.join(".suda-build", "visual-check"));
2580
+ return {
2581
+ devices: parseScreenshotDevices(options.device),
2582
+ fullPage: options.fullPage === true,
2583
+ outputDir,
2584
+ port: parsePositiveInt(options.port, 4179),
2585
+ presetIds: parseVisualCheckPresetIds(theme, options.preset)
2586
+ };
2587
+ }
2425
2588
  function createThemeProjectRequire(themeRoot) {
2426
2589
  return createRequire(path2.join(themeRoot, "package.json"));
2427
2590
  }
@@ -2516,7 +2679,7 @@ async function optimizePngIfAvailable(themeRoot, filePath) {
2516
2679
  }
2517
2680
  async function captureScreenshot(root, options) {
2518
2681
  const playwright = await loadPlaywright(root);
2519
- const handle = await startViteDevPreviewServer(root, options.port);
2682
+ const handle = await startViteDevPreviewServer(root, options.port, { showDevTools: false });
2520
2683
  try {
2521
2684
  const browser = await playwright.chromium.launch({ headless: true });
2522
2685
  try {
@@ -2549,6 +2712,278 @@ async function screenshotTheme(root, options) {
2549
2712
  const resolved = resolveScreenshotOptions(root, options);
2550
2713
  await captureScreenshot(root, resolved);
2551
2714
  }
2715
+ function visualAuditUrl(baseUrl, component, preset) {
2716
+ const url = new URL(`${DEV_VISUAL_AUDIT_PATH}/${encodeURIComponent(component)}`, `${baseUrl}/`);
2717
+ url.searchParams.set("designPreset", preset);
2718
+ return url.href;
2719
+ }
2720
+ async function runThemeVisualCheck(root, options) {
2721
+ const playwright = await loadPlaywright(root);
2722
+ const handle = await startViteDevPreviewServer(root, parsePositiveInt(options.port, 4179));
2723
+ let theme;
2724
+ try {
2725
+ theme = await loadViteDevTheme(root, handle.server);
2726
+ } catch (error2) {
2727
+ await handle.close();
2728
+ throw error2;
2729
+ }
2730
+ const resolved = resolveVisualCheckOptions(root, theme, options);
2731
+ const components = getThemeVisualAuditComponentTypes(theme);
2732
+ const report = {
2733
+ components,
2734
+ issues: [],
2735
+ outputDir: resolved.outputDir,
2736
+ presets: resolved.presetIds
2737
+ };
2738
+ try {
2739
+ const browser = await playwright.chromium.launch({ headless: true });
2740
+ try {
2741
+ for (const preset of resolved.presetIds) {
2742
+ for (const device of resolved.devices) {
2743
+ const viewport = SCREENSHOT_DEVICE_CONFIG[device].viewport;
2744
+ const context = await browser.newContext({ viewport, deviceScaleFactor: 1 });
2745
+ try {
2746
+ for (const component of components) {
2747
+ const page = await context.newPage();
2748
+ const pageErrors = [];
2749
+ page.on("pageerror", (error2) => pageErrors.push(error2.message));
2750
+ try {
2751
+ await page.goto(visualAuditUrl(handle.url, component, preset), {
2752
+ waitUntil: "networkidle"
2753
+ });
2754
+ const audit = await page.evaluate(() => {
2755
+ const issues = [];
2756
+ const section = document.querySelector("[data-section]");
2757
+ if (!section) {
2758
+ return {
2759
+ issues: [
2760
+ {
2761
+ code: "section-missing",
2762
+ message: "The visual audit page did not render the theme component.",
2763
+ severity: "error"
2764
+ }
2765
+ ]
2766
+ };
2767
+ }
2768
+ const colorForToken = (token) => {
2769
+ const probe = document.createElement("span");
2770
+ probe.style.color = `var(${token})`;
2771
+ probe.style.display = "none";
2772
+ section.append(probe);
2773
+ const color = getComputedStyle(probe).color;
2774
+ probe.remove();
2775
+ return color;
2776
+ };
2777
+ const contrastRatio = (first, second) => {
2778
+ const channels = (value) => {
2779
+ const match = value.match(/^rgba?\\(([^)]+)\\)$/);
2780
+ if (!match?.[1]) return null;
2781
+ const parsed = match[1].split(",").slice(0, 3).map((channel) => Number.parseFloat(channel.trim()));
2782
+ if (parsed.length !== 3 || !parsed.every(Number.isFinite)) return null;
2783
+ const [red, green, blue] = parsed;
2784
+ if (red === void 0 || green === void 0 || blue === void 0) return null;
2785
+ return [red, green, blue];
2786
+ };
2787
+ const firstRgb = channels(first);
2788
+ const secondRgb = channels(second);
2789
+ if (!firstRgb || !secondRgb) return null;
2790
+ const luminance = (values) => {
2791
+ const [red, green, blue] = values.map((channel) => {
2792
+ const normalized = channel / 255;
2793
+ return normalized <= 0.04045 ? normalized / 12.92 : ((normalized + 0.055) / 1.055) ** 2.4;
2794
+ });
2795
+ return 0.2126 * red + 0.7152 * green + 0.0722 * blue;
2796
+ };
2797
+ const firstLuminance = luminance(firstRgb);
2798
+ const secondLuminance = luminance(secondRgb);
2799
+ return (Math.max(firstLuminance, secondLuminance) + 0.05) / (Math.min(firstLuminance, secondLuminance) + 0.05);
2800
+ };
2801
+ const tokenPairs = [
2802
+ ["--background", "--foreground"],
2803
+ ["--primary", "--primary-foreground"],
2804
+ ["--secondary", "--secondary-foreground"],
2805
+ ["--accent", "--accent-foreground"],
2806
+ ["--muted", "--muted-foreground"]
2807
+ ];
2808
+ for (const [surfaceToken, foregroundToken] of tokenPairs) {
2809
+ const ratio = contrastRatio(colorForToken(surfaceToken), colorForToken(foregroundToken));
2810
+ if (ratio === null) {
2811
+ issues.push({
2812
+ code: "token-color-unresolved",
2813
+ message: `${surfaceToken} or ${foregroundToken} did not resolve to a CSS color.`,
2814
+ severity: "error"
2815
+ });
2816
+ } else if (ratio < 4.5) {
2817
+ issues.push({
2818
+ code: "token-contrast",
2819
+ message: `${surfaceToken} and ${foregroundToken} have ${ratio.toFixed(2)}:1 contrast; 4.5:1 is required.`,
2820
+ severity: "error"
2821
+ });
2822
+ }
2823
+ }
2824
+ const semanticTokenPairs = [
2825
+ ["--background", "--foreground"],
2826
+ ["--primary", "--primary-foreground"],
2827
+ ["--secondary", "--secondary-foreground"],
2828
+ ["--accent", "--accent-foreground"],
2829
+ ["--muted", "--muted-foreground"]
2830
+ ];
2831
+ const semanticSurfaces = semanticTokenPairs.map(([surface, foreground]) => ({
2832
+ foreground,
2833
+ foregroundColor: colorForToken(foreground),
2834
+ surface,
2835
+ surfaceColor: colorForToken(surface)
2836
+ }));
2837
+ const semanticSurfacesByElement = /* @__PURE__ */ new Map();
2838
+ for (const element of [section, ...section.querySelectorAll("*")]) {
2839
+ const backgroundColor = getComputedStyle(element).backgroundColor;
2840
+ const surface = semanticSurfaces.find(
2841
+ (candidate) => candidate.surfaceColor === backgroundColor
2842
+ );
2843
+ if (surface) semanticSurfacesByElement.set(element, surface);
2844
+ }
2845
+ const visible = (element) => {
2846
+ const style2 = getComputedStyle(element);
2847
+ const rect = element.getBoundingClientRect();
2848
+ return style2.display !== "none" && style2.visibility !== "hidden" && Number.parseFloat(style2.opacity) > 0 && rect.width > 0 && rect.height > 0;
2849
+ };
2850
+ const closestSurface = (element) => {
2851
+ let current = element;
2852
+ while (current && section.contains(current)) {
2853
+ const match = semanticSurfacesByElement.get(current);
2854
+ if (match) return match;
2855
+ current = current.parentElement;
2856
+ }
2857
+ return null;
2858
+ };
2859
+ const reportedTextChecks = /* @__PURE__ */ new Set();
2860
+ const textWalker = document.createTreeWalker(section, NodeFilter.SHOW_TEXT);
2861
+ let textNode = textWalker.nextNode();
2862
+ while (textNode) {
2863
+ if (!textNode.textContent?.trim()) {
2864
+ textNode = textWalker.nextNode();
2865
+ continue;
2866
+ }
2867
+ const parent = textNode.parentElement;
2868
+ if (!parent || !visible(parent)) {
2869
+ textNode = textWalker.nextNode();
2870
+ continue;
2871
+ }
2872
+ const surface = closestSurface(parent);
2873
+ if (surface) {
2874
+ const actualForeground = getComputedStyle(parent).color;
2875
+ const ratio = contrastRatio(surface.surfaceColor, actualForeground);
2876
+ const key = `${surface.surface}/${actualForeground}`;
2877
+ if (ratio !== null && ratio < 4.5 && !reportedTextChecks.has(`contrast/${key}`)) {
2878
+ issues.push({
2879
+ code: "surface-text-contrast",
2880
+ message: `Text on ${surface.surface} has ${ratio.toFixed(2)}:1 contrast; 4.5:1 is required.`,
2881
+ severity: "error"
2882
+ });
2883
+ reportedTextChecks.add(`contrast/${key}`);
2884
+ }
2885
+ if (actualForeground !== surface.foregroundColor && !reportedTextChecks.has(`foreground/${key}`)) {
2886
+ issues.push({
2887
+ code: "surface-foreground-token-mismatch",
2888
+ message: `Text on ${surface.surface} uses ${actualForeground} instead of ${surface.foreground}; verify the custom foreground remains readable.`,
2889
+ severity: "warning"
2890
+ });
2891
+ reportedTextChecks.add(`foreground/${key}`);
2892
+ }
2893
+ }
2894
+ textNode = textWalker.nextNode();
2895
+ }
2896
+ const sectionRect = section.getBoundingClientRect();
2897
+ if (document.documentElement.scrollWidth > window.innerWidth + 1 || sectionRect.left < -1 || sectionRect.right > window.innerWidth + 1) {
2898
+ issues.push({
2899
+ code: "horizontal-overflow",
2900
+ message: "The page or audited section overflows the viewport horizontally.",
2901
+ severity: "error"
2902
+ });
2903
+ }
2904
+ const firstText = section.querySelector("h1, h2, h3, p");
2905
+ if (firstText) {
2906
+ const textRect = firstText.getBoundingClientRect();
2907
+ const overlays = Array.from(document.querySelectorAll("body *")).filter(
2908
+ (element) => {
2909
+ if (element === section || section.contains(element)) return false;
2910
+ const style2 = getComputedStyle(element);
2911
+ if (style2.position !== "fixed" || style2.visibility === "hidden") return false;
2912
+ const rect = element.getBoundingClientRect();
2913
+ return rect.bottom > textRect.top && rect.top < textRect.bottom && rect.right > textRect.left && rect.left < textRect.right;
2914
+ }
2915
+ );
2916
+ if (overlays.length > 0) {
2917
+ issues.push({
2918
+ code: "layout-overlay",
2919
+ message: "A fixed layout element overlaps the first section text. Add spacing in the theme layout if this is unintended.",
2920
+ severity: "warning"
2921
+ });
2922
+ }
2923
+ }
2924
+ return { issues };
2925
+ });
2926
+ for (const issue of audit.issues) {
2927
+ report.issues.push({ ...issue, component, device, preset });
2928
+ }
2929
+ for (const message of pageErrors) {
2930
+ report.issues.push({
2931
+ component,
2932
+ code: "page-error",
2933
+ device,
2934
+ message,
2935
+ preset,
2936
+ severity: "error"
2937
+ });
2938
+ }
2939
+ const outputPath = path2.join(
2940
+ resolved.outputDir,
2941
+ safeObjectKeyPart(preset),
2942
+ device,
2943
+ `${safeObjectKeyPart(component)}.png`
2944
+ );
2945
+ await mkdir(path2.dirname(outputPath), { recursive: true });
2946
+ await page.screenshot({ path: outputPath, fullPage: resolved.fullPage });
2947
+ await optimizePngIfAvailable(root, outputPath);
2948
+ } finally {
2949
+ await page.close();
2950
+ }
2951
+ }
2952
+ } finally {
2953
+ await context.close();
2954
+ }
2955
+ }
2956
+ }
2957
+ } finally {
2958
+ await browser.close();
2959
+ }
2960
+ } finally {
2961
+ await handle.close();
2962
+ }
2963
+ await mkdir(resolved.outputDir, { recursive: true });
2964
+ await writeFile(
2965
+ path2.join(resolved.outputDir, "report.json"),
2966
+ `${JSON.stringify(report, null, 2)}
2967
+ `
2968
+ );
2969
+ const errors = report.issues.filter((issue) => issue.severity === "error");
2970
+ const warnings = report.issues.filter((issue) => issue.severity === "warning");
2971
+ if (errors.length === 0) {
2972
+ console.log(success(`visual check passed; screenshots: ${style.path(report.outputDir)}`));
2973
+ } else {
2974
+ console.error(error(`visual check failed with ${errors.length} error(s).`));
2975
+ }
2976
+ for (const issue of report.issues) {
2977
+ const formatter = issue.severity === "error" ? error : warning;
2978
+ console.log(
2979
+ ` ${formatter(issue.severity)} ${issue.preset}/${issue.device}/${issue.component}: ${issue.message}`
2980
+ );
2981
+ }
2982
+ if (warnings.length > 0) {
2983
+ console.log(warning(`visual check completed with ${warnings.length} warning(s).`));
2984
+ }
2985
+ return errors.length === 0;
2986
+ }
2552
2987
  async function loadLocalAgentManifest(root) {
2553
2988
  const built = await readJsonIfExists(
2554
2989
  path2.join(root, "dist", "agent-manifest.json")
@@ -2910,422 +3345,6 @@ async function createAgentProject(options) {
2910
3345
  })
2911
3346
  });
2912
3347
  }
2913
- function mcpStructured(summary, value) {
2914
- return {
2915
- content: [
2916
- {
2917
- type: "text",
2918
- text: summary
2919
- }
2920
- ],
2921
- structuredContent: value
2922
- };
2923
- }
2924
- async function startMcpServer() {
2925
- const server = new McpServer({
2926
- name: "suda",
2927
- version: "0.1.0"
2928
- });
2929
- server.registerTool(
2930
- "list_projects",
2931
- {
2932
- description: "List Suda projects the current CLI user can access. ALWAYS call this first when the user asks to generate or edit a website. Flow: (1) If `projects` is empty, do NOT call this tool again; instead skip directly to gathering inputs and calling create_project (see its description). (2) If `projects` has one or more entries, you MUST ask the user which project to use before any further action \u2014 even if there is only a single project, present it and ask the user to confirm or pick a different one. Never auto-pick a project on the user's behalf.",
2933
- inputSchema: {},
2934
- outputSchema: listProjectsOutputSchema.shape
2935
- },
2936
- async () => {
2937
- const structuredContent = listProjectsOutputSchema.parse({
2938
- projects: await listAgentProjects()
2939
- });
2940
- return mcpStructured("Suda projects accessible to the current user.", structuredContent);
2941
- }
2942
- );
2943
- server.registerTool(
2944
- "create_project",
2945
- {
2946
- description: "Create a new Suda project for the current user. Only call this after list_projects returned an empty list. Conversation flow before calling: (1) Ask the user for the website / brand name in one turn and capture it as `name`. (2) Ask the user for a business introduction; the user is allowed (and expected) to answer over multiple turns \u2014 keep asking follow-up questions and accumulating their answers until they confirm they are done, then concatenate the accumulated text into a single `siteDescription` (trimmed). Do not invent details the user did not provide. Do not call this tool until both fields are confirmed by the user. The project is created with the default theme, starter pages, and a default project contact form. After creation, if the business context suggests better contact fields, call get_contact_form_settings, propose the contact form plan to the user, and only call update_contact_form_settings with confirm:true after they agree. No in-app AI site generation is triggered \u2014 drive page content afterwards via create_page_draft using the returned projectId.",
2947
- inputSchema: {
2948
- name: createProjectInputSchema.shape.name.describe(
2949
- "Website or brand name confirmed by the user in a dedicated turn. Used as the project name and site title."
2950
- ),
2951
- siteDescription: createProjectInputSchema.shape.siteDescription.describe(
2952
- "Business introduction the user provided, possibly across multiple turns, then concatenated and trimmed. Captures what the site is about and who it serves. Used to seed default site metadata."
2953
- )
2954
- },
2955
- outputSchema: createProjectOutputSchema.shape
2956
- },
2957
- async ({ name, siteDescription }) => {
2958
- const result = await createAgentProject({ name, siteDescription });
2959
- const structuredContent = createProjectOutputSchema.parse({
2960
- projectId: result.projectId,
2961
- name: result.name,
2962
- defaultDomain: result.defaultDomain,
2963
- dashboardUrl: result.dashboardUrl
2964
- });
2965
- return mcpStructured("Created Suda project.", structuredContent);
2966
- }
2967
- );
2968
- server.registerTool(
2969
- "list_themes",
2970
- {
2971
- description: "List themes visible to the current Suda CLI user.",
2972
- inputSchema: {
2973
- projectId: z.string().optional(),
2974
- themeRoot: z.string().optional()
2975
- },
2976
- outputSchema: {
2977
- themes: z.array(
2978
- z.object({
2979
- key: z.string(),
2980
- name: z.string(),
2981
- version: z.string(),
2982
- description: z.string().nullable().optional(),
2983
- categories: z.array(z.string()).optional(),
2984
- preview: z.string().optional(),
2985
- active: z.boolean().optional()
2986
- })
2987
- )
2988
- }
2989
- },
2990
- async ({ projectId, themeRoot }) => {
2991
- const structuredContent = { themes: await listAgentThemes({ projectId, themeRoot }) };
2992
- return mcpStructured("Available Suda themes.", structuredContent);
2993
- }
2994
- );
2995
- server.registerTool(
2996
- "describe_theme",
2997
- {
2998
- description: "Return the raw Suda agent manifest for a theme.",
2999
- inputSchema: {
3000
- theme: z.string(),
3001
- version: z.string().optional(),
3002
- projectId: z.string().optional(),
3003
- themeRoot: z.string().optional()
3004
- },
3005
- outputSchema: {
3006
- agentManifest: z.unknown()
3007
- }
3008
- },
3009
- async ({ theme, version, projectId, themeRoot }) => {
3010
- const structuredContent = {
3011
- agentManifest: await fetchAgentManifest(theme, { version, projectId, themeRoot })
3012
- };
3013
- return mcpStructured("Raw Suda theme agent manifest.", structuredContent);
3014
- }
3015
- );
3016
- server.registerTool(
3017
- "get_page_schema",
3018
- {
3019
- description: "Return the schema for configuring existing Suda theme sections as page content. Use only the returned section types, fields, defaultProps, and slot allowedComponents; never create theme source code or new component types. If the theme has a contact section, generate only the section placement/content props; public form fields and delivery integrations are project contact form settings exposed to themes as metadata.contactForm. Use get_contact_form_settings and update_contact_form_settings for user-approved contact form changes.",
3020
- inputSchema: {
3021
- theme: z.string(),
3022
- version: z.string().optional(),
3023
- projectId: z.string().optional(),
3024
- themeRoot: z.string().optional(),
3025
- includeExample: z.boolean().optional()
3026
- },
3027
- outputSchema: agentPageSchemaOutputSchema.shape
3028
- },
3029
- async ({ theme, version, projectId, themeRoot, includeExample }) => {
3030
- const manifest = await fetchAgentManifest(theme, { version, projectId, themeRoot });
3031
- const structuredContent = createSudaPageAgentRuntime(manifest).getPageSchemaOutput({
3032
- includeExample: includeExample === true
3033
- });
3034
- return mcpStructured("Suda page content configuration schema.", structuredContent);
3035
- }
3036
- );
3037
- server.registerTool(
3038
- "get_section_schema",
3039
- {
3040
- description: "Return the schema for configuring one existing section in a theme. Use the returned fields, defaultProps, and slot allowedComponents to set props; do not design or add theme components. For contact sections, use the schema for presentation props only and do not hardcode form field metadata, notification channels, webhook URLs, or recipients into page content. Use contact form tools for project-level contact settings.",
3041
- inputSchema: {
3042
- theme: z.string(),
3043
- section: z.string(),
3044
- version: z.string().optional(),
3045
- projectId: z.string().optional(),
3046
- themeRoot: z.string().optional()
3047
- },
3048
- outputSchema: agentComponentOutputSchema.shape
3049
- },
3050
- async ({ theme, section, version, projectId, themeRoot }) => {
3051
- const manifest = await fetchAgentManifest(theme, { version, projectId, themeRoot });
3052
- const structuredContent = createSectionSchema(manifest, section);
3053
- return mcpStructured("Suda section configuration schema.", structuredContent);
3054
- }
3055
- );
3056
- server.registerTool(
3057
- "validate_page_config",
3058
- {
3059
- description: "Validate Suda page content JSON against an existing theme schema.",
3060
- inputSchema: {
3061
- theme: z.string(),
3062
- data: z.unknown(),
3063
- version: z.string().optional(),
3064
- projectId: z.string().optional(),
3065
- themeRoot: z.string().optional()
3066
- },
3067
- outputSchema: agentValidationResultSchema.shape
3068
- },
3069
- async ({ theme, data, version, projectId, themeRoot }) => {
3070
- const manifest = await fetchAgentManifest(theme, { version, projectId, themeRoot });
3071
- const structuredContent = agentValidationResultSchema.parse(
3072
- createSudaPageAgentRuntime(manifest).validatePageContent(data)
3073
- );
3074
- return mcpStructured("Suda page content validation result.", structuredContent);
3075
- }
3076
- );
3077
- server.registerTool(
3078
- "get_contact_form_settings",
3079
- {
3080
- description: "Read project contact form settings. Use this before planning contact form changes. The result includes editable field metadata, masked notification/webhook URLs, and plan features/limits. Secrets are never returned.",
3081
- inputSchema: {
3082
- projectId: z.string()
3083
- },
3084
- outputSchema: contactFormOutputSchema.shape
3085
- },
3086
- async ({ projectId }) => {
3087
- const auth = await requireCliBaseUrl();
3088
- const structuredContent = contactFormOutputSchema.parse({
3089
- contactForm: await getRemoteContactForm(auth, projectId)
3090
- });
3091
- return mcpStructured("Suda contact form settings.", structuredContent);
3092
- }
3093
- );
3094
- server.registerTool(
3095
- "update_contact_form_settings",
3096
- {
3097
- description: "Plan or update project contact form settings. Use this when the user asks to change contact form fields, labels, validation, notification channels, or webhooks. Supported field types are text, textarea, checkbox, radio, and select. Conversation flow: first call get_contact_form_settings, then propose the exact draft to the user. Call this tool without confirm or with confirm:false to return the impact summary; only call again with confirm:true after the user explicitly agrees. Do not invent webhook URLs, notification recipients, or external delivery secrets. When updating an existing webhook, omit url to keep the stored secret; new webhooks need a user-provided url.",
3098
- inputSchema: updateContactFormInputSchema.shape,
3099
- outputSchema: {
3100
- status: z.enum(["needs_confirmation", "updated"]),
3101
- projectId: z.string(),
3102
- impact: z.string().optional(),
3103
- draft: updateContactFormInputSchema.omit({ confirm: true }).optional(),
3104
- contactForm: contactFormViewSchema.optional()
3105
- }
3106
- },
3107
- async (input) => {
3108
- const parsed = updateContactFormInputSchema.parse(input);
3109
- const auth = await requireCliBaseUrl();
3110
- const structuredContent = updateContactFormOutputSchema.parse(
3111
- await performUpdateContactForm(auth, parsed)
3112
- );
3113
- return mcpStructured(
3114
- structuredContent.status === "needs_confirmation" ? "Updating this contact form requires explicit confirmation." : "Updated Suda contact form settings.",
3115
- structuredContent
3116
- );
3117
- }
3118
- );
3119
- server.registerTool(
3120
- "get_theme_settings",
3121
- {
3122
- description: 'Read project theme layout root settings. Use this before planning theme style changes. The result includes manifest.designSystem presets, current rootProps, and the layout root schema. To use a preset, set rootProps.designSystem to { presetId }. To fully customize colors/radius, set rootProps.designSystem to { presetId: "custom", tokens: { colors, radius } } using every token from designSystem.',
3123
- inputSchema: {
3124
- projectId: z.string()
3125
- },
3126
- outputSchema: themeSettingsOutputSchema.shape
3127
- },
3128
- async ({ projectId }) => {
3129
- const auth = await requireCliBaseUrl();
3130
- const structuredContent = themeSettingsOutputSchema.parse({
3131
- themeSettings: await getRemoteThemeSettings(auth, projectId)
3132
- });
3133
- return mcpStructured("Suda theme settings.", structuredContent);
3134
- }
3135
- );
3136
- server.registerTool(
3137
- "update_theme_settings",
3138
- {
3139
- description: "Plan or update project theme layout root settings. Use this for choosing a designSystem preset or saving custom design tokens for colors and radius. Conversation flow: first call get_theme_settings, then propose the exact rootProps changes to the user. Call this tool without confirm or with confirm:false to return the impact summary; only call again with confirm:true after the user explicitly agrees. Do not create theme source code or introduce a second token system.",
3140
- inputSchema: updateThemeSettingsInputSchema.shape,
3141
- outputSchema: {
3142
- status: z.enum(["needs_confirmation", "updated"]),
3143
- projectId: z.string(),
3144
- impact: z.string().optional(),
3145
- draft: updateThemeSettingsInputSchema.omit({ confirm: true }).optional(),
3146
- themeSettings: themeSettingsViewSchema.optional()
3147
- }
3148
- },
3149
- async (input) => {
3150
- const parsed = updateThemeSettingsInputSchema.parse(input);
3151
- const auth = await requireCliBaseUrl();
3152
- const structuredContent = updateThemeSettingsOutputSchema.parse(
3153
- await performUpdateThemeSettings(auth, parsed)
3154
- );
3155
- return mcpStructured(
3156
- structuredContent.status === "needs_confirmation" ? "Updating these theme settings requires explicit confirmation." : "Updated Suda theme settings.",
3157
- structuredContent
3158
- );
3159
- }
3160
- );
3161
- server.registerTool(
3162
- "create_page_draft",
3163
- {
3164
- description: "Create a new workspace page draft from Suda page content JSON that configures existing theme sections. When creating a complete website, mark exactly one primary landing page with isHome: true so it becomes the public homepage at `/`. After this tool succeeds, tell the user the page is only a draft, explain that they can review it in the SudaCloud workspace by navigating to Pages, opening the created page, previewing it, then clicking Publish, and ask whether they want you to publish it now. Do not call publish_page until the user explicitly confirms.",
3165
- inputSchema: {
3166
- projectId: z.string(),
3167
- title: z.string(),
3168
- slug: z.string(),
3169
- isHome: z.boolean().optional().describe("Set true for exactly one page that should serve as the public homepage at `/`."),
3170
- theme: z.string(),
3171
- data: z.unknown(),
3172
- version: z.string().optional()
3173
- },
3174
- outputSchema: createPageDraftOutputSchema
3175
- },
3176
- async ({ projectId, title, slug, isHome, theme, data, version }) => {
3177
- const manifest = await fetchAgentManifest(theme, { version, projectId });
3178
- const prepared = createSudaPageAgentRuntime(manifest).preparePageData(data);
3179
- if (!prepared.validation.valid) {
3180
- return mcpStructured(
3181
- "Suda page content validation failed. Fix issues before creating a draft.",
3182
- createPageDraftOutputSchema.parse({
3183
- status: "invalid",
3184
- valid: false,
3185
- issues: prepared.validation.issues
3186
- })
3187
- );
3188
- }
3189
- const auth = await requireCliBaseUrl();
3190
- const response = await createRemotePageDraft(auth, {
3191
- projectId,
3192
- title,
3193
- slug,
3194
- isHome,
3195
- themeKey: manifest.manifest.key,
3196
- themeVersion: manifest.manifest.version,
3197
- data
3198
- });
3199
- return mcpStructured(
3200
- "Created Suda page draft. Tell the user they can review it in the SudaCloud workspace under Pages by opening the created page, previewing it, then clicking Publish. Ask whether they want you to publish it now before calling publish_page.",
3201
- createPageDraftOutputSchema.parse({ status: "created", ...response })
3202
- );
3203
- }
3204
- );
3205
- server.registerTool(
3206
- "update_page_draft",
3207
- {
3208
- description: "Update an existing workspace page draft by slug from Suda page content JSON that configures existing theme sections.",
3209
- inputSchema: {
3210
- projectId: z.string(),
3211
- slug: z.string(),
3212
- theme: z.string(),
3213
- data: z.unknown(),
3214
- version: z.string().optional()
3215
- },
3216
- outputSchema: updatePageDraftOutputSchema
3217
- },
3218
- async ({ projectId, slug, theme, data, version }) => {
3219
- const manifest = await fetchAgentManifest(theme, { version, projectId });
3220
- const prepared = createSudaPageAgentRuntime(manifest).preparePageData(data);
3221
- if (!prepared.validation.valid) {
3222
- return mcpStructured(
3223
- "Suda page content validation failed. Fix issues before updating a draft.",
3224
- updatePageDraftOutputSchema.parse({
3225
- status: "invalid",
3226
- valid: false,
3227
- issues: prepared.validation.issues
3228
- })
3229
- );
3230
- }
3231
- const auth = await requireCliBaseUrl();
3232
- const response = await updateRemotePageDraft(auth, {
3233
- projectId,
3234
- slug,
3235
- themeKey: manifest.manifest.key,
3236
- themeVersion: manifest.manifest.version,
3237
- data
3238
- });
3239
- return mcpStructured(
3240
- "Updated Suda page draft.",
3241
- updatePageDraftOutputSchema.parse({ status: "updated", ...response })
3242
- );
3243
- }
3244
- );
3245
- server.registerTool(
3246
- "delete_page",
3247
- {
3248
- description: "Delete a Suda page by slug. This is destructive: first call without confirm to get the exact project, slug, and impact; only call again with confirm: true after the user explicitly agrees.",
3249
- inputSchema: {
3250
- projectId: z.string(),
3251
- slug: z.string(),
3252
- confirm: z.boolean().optional()
3253
- },
3254
- outputSchema: destructivePageOperationOutputSchema
3255
- },
3256
- async ({ projectId, slug, confirm }) => {
3257
- const auth = await requireCliBaseUrl();
3258
- const structuredContent = destructivePageOperationOutputSchema.parse(
3259
- await performConfirmedPageOperation(auth, {
3260
- operation: "delete",
3261
- projectId,
3262
- slug,
3263
- confirm
3264
- })
3265
- );
3266
- return mcpStructured(
3267
- structuredContent.status === "needs_confirmation" ? "Deleting this Suda page requires explicit confirmation." : "Deleted Suda page.",
3268
- structuredContent
3269
- );
3270
- }
3271
- );
3272
- server.registerTool(
3273
- "publish_page",
3274
- {
3275
- description: "Publish the current saved draft for a Suda page by slug. This affects the public site: first call without confirm to get the exact project, slug, and impact; only call again with confirm: true after the user explicitly agrees. If the user wants to review manually first, tell them to open the SudaCloud workspace, navigate to Pages, open the created page, preview it, then click Publish.",
3276
- inputSchema: {
3277
- projectId: z.string(),
3278
- slug: z.string(),
3279
- confirm: z.boolean().optional()
3280
- },
3281
- outputSchema: destructivePageOperationOutputSchema
3282
- },
3283
- async ({ projectId, slug, confirm }) => {
3284
- const auth = await requireCliBaseUrl();
3285
- const structuredContent = destructivePageOperationOutputSchema.parse(
3286
- await performConfirmedPageOperation(auth, {
3287
- operation: "publish",
3288
- projectId,
3289
- slug,
3290
- confirm
3291
- })
3292
- );
3293
- return mcpStructured(
3294
- structuredContent.status === "needs_confirmation" ? "Publishing this Suda page requires explicit confirmation." : "Published Suda page.",
3295
- structuredContent
3296
- );
3297
- }
3298
- );
3299
- server.registerTool(
3300
- "activate_theme",
3301
- {
3302
- description: "Activate a theme for a Suda project. This affects how public pages render and may seed starter content on first activation: first call without confirm to get the exact project, theme, and impact; only call again with confirm: true after the user explicitly agrees.",
3303
- inputSchema: {
3304
- projectId: z.string(),
3305
- themeKey: z.string(),
3306
- themeVersion: z.string().optional(),
3307
- confirm: z.boolean().optional()
3308
- },
3309
- outputSchema: activateThemeOutputSchema
3310
- },
3311
- async ({ projectId, themeKey, themeVersion, confirm }) => {
3312
- const auth = await requireCliBaseUrl();
3313
- const structuredContent = activateThemeOutputSchema.parse(
3314
- await performActivateTheme(auth, {
3315
- projectId,
3316
- themeKey,
3317
- themeVersion,
3318
- confirm
3319
- })
3320
- );
3321
- return mcpStructured(
3322
- structuredContent.status === "needs_confirmation" ? "Activating this Suda theme requires explicit confirmation." : "Activated Suda theme.",
3323
- structuredContent
3324
- );
3325
- }
3326
- );
3327
- await server.connect(new StdioServerTransport());
3328
- }
3329
3348
  async function writeThemeArtifacts(theme) {
3330
3349
  const dist = path2.join(theme.root, "dist");
3331
3350
  const agentManifest = createThemeAgentManifest(theme.module);
@@ -3816,6 +3835,24 @@ function buildProgram() {
3816
3835
  ).option("--output <path>", "Output PNG path relative to theme root. Requires exactly one device.").option("--width <px>", "Override viewport width in pixels.").option("--height <px>", "Override viewport height in pixels.").option("--full-page", "Capture the full page instead of the viewport.").option("--port <port>", "Preview server port used during capture.", "4178").action(async (options) => {
3817
3836
  await screenshotTheme(resolveThemeRoot(options), options);
3818
3837
  });
3838
+ theme.command("visual-check").description(
3839
+ "Render every theme page component across presets and viewports, then report token contrast, semantic surface colors, overflow, and layout overlays."
3840
+ ).option("--theme-root <path>", "Theme source/artifact root.").option(
3841
+ "--device <device>",
3842
+ "Device to audit: desktop, tablet, mobile. Repeat or use comma-separated values.",
3843
+ collectOption,
3844
+ []
3845
+ ).option(
3846
+ "--preset <id>",
3847
+ "Design preset to audit. Repeat or use comma-separated values; defaults to every theme preset.",
3848
+ collectOption,
3849
+ []
3850
+ ).option("--output <path>", "Output directory relative to theme root.").option("--full-page", "Capture full-page screenshots instead of viewport screenshots.").option("--port <port>", "Preview server port used during visual checking.", "4179").action(async (options) => {
3851
+ const ok = await runThemeVisualCheck(resolveThemeRoot(options), options);
3852
+ if (!ok) {
3853
+ process.exitCode = 1;
3854
+ }
3855
+ });
3819
3856
  theme.command("publish").description("Upload artifact to S3 and upsert ThemePackage/ThemeVersion.").option("--theme-root <path>", "Theme source/artifact root.").option("--skip-build", "Publish existing dist files without rebuilding.").option("--host <host>", "Publish to this authenticated SudaCloud workspace host.").option(
3820
3857
  "--force",
3821
3858
  "Development recovery only: clear the existing themes/<key>/<version>/ prefix and ThemeVersion row before republishing. Re-published clients pinned to this version are unavailable until the new publish completes."
@@ -3887,9 +3924,6 @@ function buildProgram() {
3887
3924
  const manifest = await fetchAgentManifest(options.theme, options);
3888
3925
  printJson(createSectionSchema(manifest, options.section));
3889
3926
  });
3890
- program.command("mcp").description("Run the Suda local MCP server over stdio.").action(async () => {
3891
- await startMcpServer();
3892
- });
3893
3927
  const hostCmd = program.command("host").description("Manage the current SudaCloud workspace host.");
3894
3928
  hostCmd.command("current").description("Show the current authenticated workspace host.").action(async () => {
3895
3929
  await showCurrentHost();