better-design 0.15.0 → 0.19.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
@@ -899,9 +899,119 @@ Default is create-design-system (above). For a named system: find-design-system(
899
899
  }
900
900
  });
901
901
 
902
+ // ../shared/src/mcp/session-guidance.ts
903
+ function topicFromDocId(docId) {
904
+ return docId.replace(/^(principle|foundational|doc)-/, "").trim();
905
+ }
906
+ function scopeToClient(calls, clientName) {
907
+ if (!clientName) return calls;
908
+ const sameClient = calls.filter((call) => call.clientName === clientName);
909
+ return sameClient.length > 0 ? sameClient : calls;
910
+ }
911
+ function summarizeSessionGuidance(calls, clientName) {
912
+ const topics = [];
913
+ const seen = /* @__PURE__ */ new Set();
914
+ let guidanceCalls = 0;
915
+ let reviewed = false;
916
+ for (const call of scopeToClient(calls, clientName)) {
917
+ if (REVIEW_TOOLS.has(call.tool)) {
918
+ reviewed = true;
919
+ continue;
920
+ }
921
+ if (!GUIDANCE_TOOLS.has(call.tool)) continue;
922
+ guidanceCalls += 1;
923
+ const raw = call.topic?.trim() || call.docIds.map(topicFromDocId).find(Boolean);
924
+ if (!raw) continue;
925
+ const topic = topicFromDocId(raw).toLowerCase();
926
+ if (!topic || seen.has(topic)) continue;
927
+ seen.add(topic);
928
+ topics.push(topic);
929
+ }
930
+ return { topics, reviewed, guidanceCalls };
931
+ }
932
+ function formatTopicList(topics) {
933
+ const named = topics.slice(0, MAX_NAMED_TOPICS);
934
+ if (named.length === 0) return "";
935
+ if (named.length === 1) return named[0];
936
+ return `${named.slice(0, -1).join(", ")} and ${named[named.length - 1]}`;
937
+ }
938
+ function reviewSectionForTopic(topic) {
939
+ const needle = topic.toLowerCase();
940
+ return REVIEW_SECTION_BY_TOPIC.find(({ match }) => match.test(needle))?.section ?? null;
941
+ }
942
+ function weightReviewRules(rules, topics) {
943
+ const sections = [...new Set(topics.map(reviewSectionForTopic).filter((s) => Boolean(s)))];
944
+ if (sections.length === 0) return { rules: [...rules], sections: [] };
945
+ const matches = (rule) => sections.some((section) => rule.id.toLowerCase().includes(section));
946
+ const first = rules.filter(matches);
947
+ if (first.length === 0 || first.length === rules.length) {
948
+ return { rules: [...rules], sections: [] };
949
+ }
950
+ return {
951
+ rules: [...first, ...rules.filter((rule) => !matches(rule))],
952
+ sections: sections.filter(
953
+ (section) => first.some((rule) => rule.id.toLowerCase().includes(section))
954
+ )
955
+ };
956
+ }
957
+ function guidancePreamble(topics, subject, orderedSections = []) {
958
+ if (topics.length === 0) return "";
959
+ const list = formatTopicList(topics);
960
+ const plural = topics.length > 1 ? "principles" : "principle";
961
+ const ordering = orderedSections.length ? ` The ${formatTopicList(orderedSections.map((s) => s.replace(/-/g, " ")))} rules are ordered first below.` : "";
962
+ return `Earlier this session you loaded the ${list} ${plural}. Check ${subject} against these first.${ordering}`;
963
+ }
964
+ function reviewNudge(session) {
965
+ if (session.reviewed || session.guidanceCalls === 0) return "";
966
+ return "You have loaded design guidance in this session and not reviewed the result yet. When the screen exists, call `get-review-rules` on the code, then `check-comprehension` on its copy, and fix every critical and serious finding before you present it.";
967
+ }
968
+ var MCP_SESSION_WINDOW_MS, GUIDANCE_TOOLS, REVIEW_TOOLS, SESSION_LOOP_TOOLS, NO_SESSION_GUIDANCE, MAX_NAMED_TOPICS, REVIEW_SECTION_BY_TOPIC;
969
+ var init_session_guidance = __esm({
970
+ "../shared/src/mcp/session-guidance.ts"() {
971
+ "use strict";
972
+ MCP_SESSION_WINDOW_MS = 30 * 6e4;
973
+ GUIDANCE_TOOLS = /* @__PURE__ */ new Set([
974
+ "get-ui-principle",
975
+ "get-ux-principle",
976
+ "get-react-native-guide",
977
+ "get-three-js-guide",
978
+ "get-motion-guide"
979
+ ]);
980
+ REVIEW_TOOLS = /* @__PURE__ */ new Set([
981
+ "get-review-rules",
982
+ "check-comprehension"
983
+ ]);
984
+ SESSION_LOOP_TOOLS = /* @__PURE__ */ new Set([
985
+ ...GUIDANCE_TOOLS,
986
+ ...REVIEW_TOOLS
987
+ ]);
988
+ NO_SESSION_GUIDANCE = {
989
+ topics: [],
990
+ reviewed: false,
991
+ guidanceCalls: 0
992
+ };
993
+ MAX_NAMED_TOPICS = 4;
994
+ REVIEW_SECTION_BY_TOPIC = [
995
+ { match: /accessib|a11y|contrast|keyboard|screen-reader/, section: "accessibility" },
996
+ { match: /animation|motion|transition|spring|scroll|reveal|particle|canvas|hover|pointer|gesture|interaction/, section: "animation" },
997
+ // `\bform` on purpose: bare `form` also matches "performance", which the
998
+ // visual-design row below claims, and "information".
999
+ { match: /microcopy|copy|content|marketing|onboarding|error|notification|dialog|\bform|search|navigation|psycholog|behavior/, section: "content" },
1000
+ { match: /hierarchy|spacing|layout|typograph|color|depth|surface|polish|image|grid|component|distill|bolder|quieter|performance/, section: "visual-design" }
1001
+ ];
1002
+ }
1003
+ });
1004
+
902
1005
  // ../shared/src/mcp/tools/shared.ts
903
1006
  import { createHash } from "crypto";
904
1007
  import { z } from "zod";
1008
+ function mcpClientName(server) {
1009
+ try {
1010
+ return server.server.getClientVersion?.()?.name || void 0;
1011
+ } catch {
1012
+ return void 0;
1013
+ }
1014
+ }
905
1015
  function codingHostName(server) {
906
1016
  try {
907
1017
  const name = server.server.getClientVersion?.()?.name;
@@ -932,6 +1042,24 @@ function buildKitOutputSchema(enableShopifyKit = true) {
932
1042
  themeFiles: z.array(z.object({ path: z.string().min(1), content: z.string().min(1) })).optional().describe("Shopify target only. Theme files to write verbatim at each path: the per-system CSS asset, the Liquid snippets, and the theme editor sections.")
933
1043
  });
934
1044
  }
1045
+ async function loadSessionGuidance(provider, userId, clientName, now = Date.now()) {
1046
+ if (!provider.getRecentGuidance) return NO_SESSION_GUIDANCE;
1047
+ let timer;
1048
+ try {
1049
+ const calls = await Promise.race([
1050
+ provider.getRecentGuidance(userId, now - MCP_SESSION_WINDOW_MS),
1051
+ new Promise((resolve) => {
1052
+ timer = setTimeout(() => resolve(null), SESSION_LOOKUP_TIMEOUT_MS);
1053
+ })
1054
+ ]);
1055
+ if (!Array.isArray(calls) || calls.length === 0) return NO_SESSION_GUIDANCE;
1056
+ return summarizeSessionGuidance(calls, clientName);
1057
+ } catch {
1058
+ return NO_SESSION_GUIDANCE;
1059
+ } finally {
1060
+ if (timer) clearTimeout(timer);
1061
+ }
1062
+ }
935
1063
  function extractOutputPreview(result) {
936
1064
  const content = result?.content;
937
1065
  if (!Array.isArray(content)) return void 0;
@@ -1037,10 +1165,11 @@ function truncateToTokens(text, maxTokens) {
1037
1165
 
1038
1166
  ...(truncated to ~${maxTokens} tokens)`;
1039
1167
  }
1040
- var MAX_OUTPUT_PREVIEW, ICONIFY_FETCH_TIMEOUT_MS, ICONIFY_API_BASE, REGISTRY_BASE_URL, APP_BASE_URL, WIDGET_RESOURCE_DOMAINS, WIDGET_FRAME_DOMAINS, WIDGET_APP_DOMAIN, REQUIREMENTS_MD, ANTI_MOCKUP_MD, BUILD_TARGET_RULE, CODING_HOST_RE, PREVIEW_OUTPUT_SCHEMA, BUILD_KIT_OUTPUT_SCHEMA, CONTEXT_ARG, SEARCH_TOOLS;
1168
+ var MAX_OUTPUT_PREVIEW, ICONIFY_FETCH_TIMEOUT_MS, ICONIFY_API_BASE, REGISTRY_BASE_URL, APP_BASE_URL, WIDGET_RESOURCE_DOMAINS, WIDGET_APP_DOMAIN, REQUIREMENTS_MD, ANTI_MOCKUP_MD, BUILD_TARGET_RULE, CODING_HOST_RE, PREVIEW_OUTPUT_SCHEMA, BUILD_KIT_OUTPUT_SCHEMA, CONTEXT_ARG, READ_ONLY_TOOL_ANNOTATIONS, SESSION_LOOKUP_TIMEOUT_MS, SEARCH_TOOLS;
1041
1169
  var init_shared = __esm({
1042
1170
  "../shared/src/mcp/tools/shared.ts"() {
1043
1171
  "use strict";
1172
+ init_session_guidance();
1044
1173
  MAX_OUTPUT_PREVIEW = 1e3;
1045
1174
  ICONIFY_FETCH_TIMEOUT_MS = 1e4;
1046
1175
  ICONIFY_API_BASE = "https://api.iconify.design";
@@ -1050,7 +1179,6 @@ var init_shared = __esm({
1050
1179
  "https://fonts.googleapis.com",
1051
1180
  "https://fonts.gstatic.com"
1052
1181
  ];
1053
- WIDGET_FRAME_DOMAINS = [APP_BASE_URL];
1054
1182
  WIDGET_APP_DOMAIN = `${createHash("sha256").update(`${APP_BASE_URL}/api/mcp`).digest("hex").slice(0, 32)}.claudemcpcontent.com`;
1055
1183
  REQUIREMENTS_MD = `> **Requirements:** these components target **Tailwind v4**. The installed \`globals.css\` uses the v4 entry point (\`@import "tailwindcss"\`), so the project needs \`tailwindcss@^4\` with \`@tailwindcss/postcss\` in its PostCSS config (or the \`@tailwindcss/vite\` plugin). On a Tailwind v3 project, migrate to v4 first, otherwise the build throws the "trying to use tailwindcss directly as a PostCSS plugin" error.
1056
1184
  `;
@@ -1074,8 +1202,8 @@ The UI copy inside the screen follows the same restraint: short headlines (about
1074
1202
  status: z.string().describe("completed | error | pending | generating | selected"),
1075
1203
  name: z.string().describe("Display name of the design system or project."),
1076
1204
  viewUrl: z.string().optional().describe("Owner-facing Better Design page (the human 'Open' link). May require login."),
1077
- previewUrl: z.string().optional().describe("Public, cookieless showcase URL to embed in an iframe (sandboxed hosts can't carry the user's session). Falls back to viewUrl."),
1078
- shotUrl: z.string().optional().describe("Real screenshot of the showcase page, for hosts that can't iframe it."),
1205
+ previewUrl: z.string().optional().describe("Public, cookieless showcase URL used to generate preview assets. Falls back to viewUrl."),
1206
+ shotUrl: z.string().optional().describe("Real screenshot of the showcase page for the self-contained preview widget."),
1079
1207
  installCommand: z.string().optional().describe("shadcn install command, when complete."),
1080
1208
  installLibrary: z.enum(["base-ui", "radix", "mixed"]).optional().describe("Actual component library flavor installed by installCommand."),
1081
1209
  installNote: z.string().optional().describe("Important install flavor or fallback note for clients that read only structuredContent."),
@@ -1086,6 +1214,12 @@ The UI copy inside the screen follows the same restraint: short headlines (about
1086
1214
  CONTEXT_ARG = z.string().max(280).optional().describe(
1087
1215
  "Optional. One sentence on WHY you are calling this and what the user asked for (e.g. 'adding nav icons for the fintech dashboard'). Used only for usage analytics; does not change the result."
1088
1216
  );
1217
+ READ_ONLY_TOOL_ANNOTATIONS = {
1218
+ readOnlyHint: true,
1219
+ destructiveHint: false,
1220
+ openWorldHint: false
1221
+ };
1222
+ SESSION_LOOKUP_TIMEOUT_MS = 2e3;
1089
1223
  SEARCH_TOOLS = /* @__PURE__ */ new Set([
1090
1224
  "find-design-system",
1091
1225
  "get-ui-principle",
@@ -1101,6 +1235,541 @@ The UI copy inside the screen follows the same restraint: short headlines (about
1101
1235
  }
1102
1236
  });
1103
1237
 
1238
+ // ../shared/src/mcp/scaffold/motion-scaffold.ts
1239
+ var MOTION_SCAFFOLD_FILES, MOTION_SCAFFOLD_STEPS;
1240
+ var init_motion_scaffold = __esm({
1241
+ "../shared/src/mcp/scaffold/motion-scaffold.ts"() {
1242
+ "use strict";
1243
+ MOTION_SCAFFOLD_FILES = [
1244
+ {
1245
+ path: "package.json",
1246
+ contents: `{
1247
+ "name": "motion",
1248
+ "private": true,
1249
+ "scripts": {
1250
+ "dev": "remotion studio",
1251
+ "music": "python3 scripts/make-music.py",
1252
+ "render": "remotion render Video out/video.mp4 --concurrency=6",
1253
+ "render:1080": "remotion render Video out/video-1080.mp4 --concurrency=6 --scale=1.5"
1254
+ },
1255
+ "dependencies": {
1256
+ "@remotion/cli": "4.0.499",
1257
+ "@remotion/google-fonts": "4.0.499",
1258
+ "@remotion/layout-utils": "4.0.499",
1259
+ "react": "19.0.0",
1260
+ "react-dom": "19.0.0",
1261
+ "remotion": "4.0.499"
1262
+ },
1263
+ "devDependencies": {
1264
+ "@types/react": "19.0.0",
1265
+ "typescript": "5.7.3"
1266
+ }
1267
+ }
1268
+ `
1269
+ },
1270
+ {
1271
+ path: "tsconfig.json",
1272
+ contents: `{
1273
+ "compilerOptions": {
1274
+ "target": "ES2022",
1275
+ "module": "ESNext",
1276
+ "moduleResolution": "bundler",
1277
+ "jsx": "react-jsx",
1278
+ "strict": true,
1279
+ "skipLibCheck": true,
1280
+ "esModuleInterop": true,
1281
+ "noEmit": true,
1282
+ "lib": [
1283
+ "ES2022",
1284
+ "DOM"
1285
+ ]
1286
+ },
1287
+ "include": [
1288
+ "src"
1289
+ ]
1290
+ }
1291
+ `
1292
+ },
1293
+ {
1294
+ path: "remotion.config.ts",
1295
+ contents: `import { Config } from "@remotion/cli/config";
1296
+
1297
+ Config.setVideoImageFormat("jpeg");
1298
+ Config.setOverwriteOutput(true);
1299
+ `
1300
+ },
1301
+ {
1302
+ path: ".gitignore",
1303
+ contents: `node_modules/\\nout/\\npublic/music/\\n`
1304
+ },
1305
+ {
1306
+ path: "src/index.ts",
1307
+ contents: `import { registerRoot } from "remotion";
1308
+ import { RemotionRoot } from "./Root";
1309
+
1310
+ registerRoot(RemotionRoot);
1311
+ `
1312
+ },
1313
+ {
1314
+ path: "src/Root.tsx",
1315
+ contents: `import React from "react";
1316
+ import { Composition } from "remotion";
1317
+ import { Video, TOTAL_FRAMES, FPS } from "./Video";
1318
+
1319
+ export const RemotionRoot: React.FC = () => (
1320
+ <Composition
1321
+ id="Video"
1322
+ component={Video}
1323
+ durationInFrames={TOTAL_FRAMES}
1324
+ fps={FPS}
1325
+ width={1280}
1326
+ height={720}
1327
+ />
1328
+ );
1329
+ `
1330
+ },
1331
+ {
1332
+ path: "src/Video.tsx",
1333
+ contents: `import React from "react";
1334
+ import { AbsoluteFill, Audio, Series, staticFile } from "remotion";
1335
+
1336
+ import { BeforeAfter } from "./scenes/BeforeAfter";
1337
+ import { DeviceFrame } from "./scenes/DeviceFrame";
1338
+ import { TitleCard } from "./scenes/TitleCard";
1339
+ import { color } from "./theme";
1340
+
1341
+ export const FPS = 60;
1342
+
1343
+ /**
1344
+ * The running order, and the only place scene lengths live.
1345
+ *
1346
+ * Read the titles on their own. If they do not tell the story in order, no
1347
+ * amount of animation will rescue it.
1348
+ *
1349
+ * At 120 BPM and 60fps a beat is 30 frames, so every length here is a multiple
1350
+ * of 30 and the cuts land on the beat. scripts/make-music.py reads these
1351
+ * numbers back out of this file, so changing one keeps the music in step.
1352
+ */
1353
+ export const SCENES = [
1354
+ {
1355
+ id: "hook",
1356
+ frames: 240,
1357
+ node: <TitleCard title="Your product, in sixty seconds." />,
1358
+ },
1359
+ {
1360
+ id: "problem",
1361
+ frames: 270,
1362
+ node: <TitleCard title="Here is the part people struggle with." muted />,
1363
+ },
1364
+ {
1365
+ id: "product",
1366
+ frames: 300,
1367
+ node: <DeviceFrame caption="So we built this." />,
1368
+ },
1369
+ {
1370
+ id: "difference",
1371
+ frames: 330,
1372
+ node: <BeforeAfter beforeLabel="Before" afterLabel="After" />,
1373
+ },
1374
+ {
1375
+ id: "cta",
1376
+ frames: 240,
1377
+ node: <TitleCard title="Try it today." />,
1378
+ },
1379
+ ];
1380
+
1381
+ export const TOTAL_FRAMES = SCENES.reduce((sum, scene) => sum + scene.frames, 0);
1382
+
1383
+ export const Video: React.FC = () => (
1384
+ <AbsoluteFill style={{ backgroundColor: color.background }}>
1385
+ {/* Delete this line until you have run: bun run music */}
1386
+ <Audio src={staticFile("music/bed.wav")} />
1387
+ <Series>
1388
+ {SCENES.map((scene) => (
1389
+ <Series.Sequence key={scene.id} durationInFrames={scene.frames}>
1390
+ {scene.node}
1391
+ </Series.Sequence>
1392
+ ))}
1393
+ </Series>
1394
+ </AbsoluteFill>
1395
+ );
1396
+ `
1397
+ },
1398
+ {
1399
+ path: "src/theme.ts",
1400
+ contents: `/**
1401
+ * Replace these with your own design system's tokens so the film and the app
1402
+ * are the same brand. If the project uses Better Design, copy the values
1403
+ * straight out of globals.css.
1404
+ */
1405
+ export const color = {
1406
+ background: "oklch(0.98 0.004 90)",
1407
+ foreground: "oklch(0.24 0.005 75)",
1408
+ card: "oklch(1 0 0)",
1409
+ muted: "oklch(0.94 0.006 86)",
1410
+ mutedFg: "oklch(0.52 0.008 80)",
1411
+ border: "oklch(0.9 0.008 85)",
1412
+ accent: "oklch(0.62 0.2 256)",
1413
+ accentFg: "oklch(1 0 0)",
1414
+ };
1415
+
1416
+ export const radius = { sm: 8, md: 16, lg: 28 };
1417
+
1418
+ export const shadow = {
1419
+ card: "0 18px 40px oklch(0 0 0 / 0.10), 0 2px 6px oklch(0 0 0 / 0.06)",
1420
+ };
1421
+
1422
+ /**
1423
+ * Load a webfont through @remotion/google-fonts rather than a CSS import, so
1424
+ * the render waits for it. A font that arrives late changes every text
1425
+ * measurement in the frame.
1426
+ */
1427
+ export const sans =
1428
+ 'ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif';
1429
+
1430
+ export const type = {
1431
+ title: { fontSize: 76, fontWeight: 600, letterSpacing: "-0.04em" },
1432
+ caption: { fontSize: 28, fontWeight: 500, letterSpacing: "-0.01em" },
1433
+ label: { fontSize: 20, fontWeight: 500, letterSpacing: "0" },
1434
+ };
1435
+ `
1436
+ },
1437
+ {
1438
+ path: "src/scenes/TitleCard.tsx",
1439
+ contents: `import React from "react";
1440
+ import { AbsoluteFill, interpolate, spring, useCurrentFrame, useVideoConfig } from "remotion";
1441
+
1442
+ import { color, sans, type } from "../theme";
1443
+
1444
+ /**
1445
+ * One sentence, held still. Note the two things every scene here does:
1446
+ * it starts visible enough to read on frame one, and it stops moving before
1447
+ * the scene ends.
1448
+ */
1449
+ export const TitleCard: React.FC<{ title: string; muted?: boolean }> = ({
1450
+ title,
1451
+ muted = false,
1452
+ }) => {
1453
+ const frame = useCurrentFrame();
1454
+ const { fps } = useVideoConfig();
1455
+
1456
+ const enter = spring({ frame, fps, config: { damping: 26 } });
1457
+ const rise = interpolate(enter, [0, 1], [18, 0]);
1458
+ const fade = interpolate(frame, [0, 12], [0, 1], {
1459
+ extrapolateLeft: "clamp",
1460
+ extrapolateRight: "clamp",
1461
+ });
1462
+
1463
+ return (
1464
+ <AbsoluteFill
1465
+ style={{
1466
+ alignItems: "center",
1467
+ justifyContent: "center",
1468
+ padding: 96,
1469
+ fontFamily: sans,
1470
+ }}
1471
+ >
1472
+ <h1
1473
+ style={{
1474
+ ...type.title,
1475
+ margin: 0,
1476
+ width: "100%",
1477
+ textAlign: "center",
1478
+ color: muted ? color.mutedFg : color.foreground,
1479
+ opacity: fade,
1480
+ transform: "translateY(" + rise + "px)",
1481
+ }}
1482
+ >
1483
+ {title}
1484
+ </h1>
1485
+ </AbsoluteFill>
1486
+ );
1487
+ };
1488
+ `
1489
+ },
1490
+ {
1491
+ path: "src/scenes/DeviceFrame.tsx",
1492
+ contents: `import React from "react";
1493
+ import { AbsoluteFill, interpolate, spring, useCurrentFrame, useVideoConfig } from "remotion";
1494
+
1495
+ import { color, radius, sans, shadow, type } from "../theme";
1496
+
1497
+ /**
1498
+ * A screenshot inside a frame. Put your own UI where the placeholder is, and
1499
+ * keep any cursor or callout INSIDE this transform, or it drifts off target
1500
+ * the moment the scene scales.
1501
+ */
1502
+ export const DeviceFrame: React.FC<{ caption: string }> = ({ caption }) => {
1503
+ const frame = useCurrentFrame();
1504
+ const { fps } = useVideoConfig();
1505
+
1506
+ const enter = spring({ frame, fps, config: { damping: 24 } });
1507
+ const scale = interpolate(enter, [0, 1], [0.94, 1]);
1508
+ const fade = interpolate(frame, [0, 10], [0, 1], {
1509
+ extrapolateLeft: "clamp",
1510
+ extrapolateRight: "clamp",
1511
+ });
1512
+
1513
+ return (
1514
+ <AbsoluteFill
1515
+ style={{
1516
+ alignItems: "center",
1517
+ justifyContent: "center",
1518
+ gap: 40,
1519
+ fontFamily: sans,
1520
+ opacity: fade,
1521
+ }}
1522
+ >
1523
+ <div
1524
+ style={{
1525
+ width: 880,
1526
+ height: 460,
1527
+ borderRadius: radius.lg,
1528
+ background: color.card,
1529
+ border: "1px solid " + color.border,
1530
+ boxShadow: shadow.card,
1531
+ transform: "scale(" + scale + ")",
1532
+ display: "grid",
1533
+ placeItems: "center",
1534
+ color: color.mutedFg,
1535
+ ...type.label,
1536
+ }}
1537
+ >
1538
+ Your screen here
1539
+ </div>
1540
+ <p style={{ ...type.caption, margin: 0, color: color.foreground }}>{caption}</p>
1541
+ </AbsoluteFill>
1542
+ );
1543
+ };
1544
+ `
1545
+ },
1546
+ {
1547
+ path: "src/scenes/BeforeAfter.tsx",
1548
+ contents: `import React from "react";
1549
+ import { AbsoluteFill, interpolate, useCurrentFrame } from "remotion";
1550
+
1551
+ import { color, radius, sans, shadow, type } from "../theme";
1552
+
1553
+ /**
1554
+ * The comparison scene. It only works if both sides are on screen together
1555
+ * long enough to read, so bring the second panel in early and hold both.
1556
+ */
1557
+ export const BeforeAfter: React.FC<{
1558
+ beforeLabel: string;
1559
+ afterLabel: string;
1560
+ }> = ({ beforeLabel, afterLabel }) => {
1561
+ const frame = useCurrentFrame();
1562
+
1563
+ const left = interpolate(frame, [0, 20], [0, 1], {
1564
+ extrapolateLeft: "clamp",
1565
+ extrapolateRight: "clamp",
1566
+ });
1567
+ const right = interpolate(frame, [30, 55], [0, 1], {
1568
+ extrapolateLeft: "clamp",
1569
+ extrapolateRight: "clamp",
1570
+ });
1571
+
1572
+ const panel = {
1573
+ width: 500,
1574
+ height: 380,
1575
+ borderRadius: radius.md,
1576
+ border: "1px solid " + color.border,
1577
+ background: color.card,
1578
+ boxShadow: shadow.card,
1579
+ display: "grid",
1580
+ placeItems: "center",
1581
+ color: color.mutedFg,
1582
+ ...type.label,
1583
+ } as const;
1584
+
1585
+ return (
1586
+ <AbsoluteFill
1587
+ style={{
1588
+ alignItems: "center",
1589
+ justifyContent: "center",
1590
+ gap: 48,
1591
+ flexDirection: "row",
1592
+ fontFamily: sans,
1593
+ }}
1594
+ >
1595
+ <div style={{ opacity: left, display: "grid", gap: 16, justifyItems: "center" }}>
1596
+ <div style={panel}>Your old screen</div>
1597
+ <span style={{ ...type.label, color: color.mutedFg }}>{beforeLabel}</span>
1598
+ </div>
1599
+ <div style={{ opacity: right, display: "grid", gap: 16, justifyItems: "center" }}>
1600
+ <div style={{ ...panel, borderColor: color.accent }}>Your new screen</div>
1601
+ <span style={{ ...type.label, color: color.foreground }}>{afterLabel}</span>
1602
+ </div>
1603
+ </AbsoluteFill>
1604
+ );
1605
+ };
1606
+ `
1607
+ },
1608
+ {
1609
+ path: "scripts/make-music.py",
1610
+ contents: `"""
1611
+ Synthesises the backing track, so nothing is licensed from anywhere.
1612
+
1613
+ 120 BPM at 60fps is a beat every 30 frames. Scene lengths are read straight
1614
+ out of src/Video.tsx, so the swells cannot drift from the edit: change a scene
1615
+ length there, run this again, and the accents move with it.
1616
+ """
1617
+
1618
+ import math
1619
+ import re
1620
+ import struct
1621
+ import wave
1622
+ from pathlib import Path
1623
+
1624
+ ROOT = Path(__file__).resolve().parent.parent
1625
+ SR = 44_100
1626
+ FPS = 60
1627
+ BPM = 120
1628
+ BEAT = 60 / BPM
1629
+
1630
+ SCENES = [
1631
+ int(n)
1632
+ for n in re.findall(r"frames: (\\d+)", (ROOT / "src" / "Video.tsx").read_text())
1633
+ ]
1634
+ if not SCENES:
1635
+ raise SystemExit("no \`frames: <n>\` entries found in src/Video.tsx")
1636
+
1637
+ TOTAL = sum(SCENES) / FPS
1638
+ CUTS = []
1639
+ acc = 0
1640
+ for length in SCENES[:-1]:
1641
+ acc += length
1642
+ CUTS.append(acc / FPS)
1643
+
1644
+ # A minor pentatonic pad. Swap these for any scale you like.
1645
+ CHORD = [220.00, 261.63, 329.63, 392.00]
1646
+
1647
+
1648
+ def envelope(t: float, attack: float, release: float, length: float) -> float:
1649
+ if t < 0 or t > length:
1650
+ return 0.0
1651
+ if t < attack:
1652
+ return t / attack
1653
+ if t > length - release:
1654
+ return max(0.0, (length - t) / release)
1655
+ return 1.0
1656
+
1657
+
1658
+ samples = []
1659
+ for i in range(int(SR * TOTAL)):
1660
+ t = i / SR
1661
+ value = 0.0
1662
+
1663
+ # Pad: quiet, always there, gives the film a floor.
1664
+ for n, freq in enumerate(CHORD):
1665
+ value += math.sin(2 * math.pi * freq * t) * (0.05 / (n + 1))
1666
+
1667
+ # A pluck on every beat, louder on the downbeat.
1668
+ beat_pos = t % BEAT
1669
+ beat_index = int(t / BEAT)
1670
+ accent = 1.0 if beat_index % 4 == 0 else 0.45
1671
+ value += (
1672
+ math.sin(2 * math.pi * CHORD[beat_index % len(CHORD)] * 2 * t)
1673
+ * envelope(beat_pos, 0.005, 0.18, BEAT)
1674
+ * 0.10
1675
+ * accent
1676
+ )
1677
+
1678
+ # A swell into every cut, so the edit and the music agree.
1679
+ for cut in CUTS:
1680
+ since = t - (cut - 0.6)
1681
+ if 0 <= since <= 0.6:
1682
+ value += math.sin(2 * math.pi * 110 * t) * (since / 0.6) * 0.12
1683
+
1684
+ samples.append(value)
1685
+
1686
+ # Normalise, or it renders far too quiet under the picture.
1687
+ peak = max(abs(s) for s in samples) or 1.0
1688
+ gain = 0.89 / peak
1689
+
1690
+ out = ROOT / "public" / "music"
1691
+ out.mkdir(parents=True, exist_ok=True)
1692
+ path = out / "bed.wav"
1693
+
1694
+ with wave.open(str(path), "w") as f:
1695
+ f.setnchannels(1)
1696
+ f.setsampwidth(2)
1697
+ f.setframerate(SR)
1698
+ f.writeframes(
1699
+ b"".join(struct.pack("<h", int(max(-1.0, min(1.0, s * gain)) * 32767)) for s in samples)
1700
+ )
1701
+
1702
+ print(f"wrote {path.name}: {TOTAL:.2f}s, {len(CUTS)} cuts")
1703
+ `
1704
+ },
1705
+ {
1706
+ path: "README.md",
1707
+ contents: `# motion
1708
+
1709
+ The video for this project. Every frame is React, so a scene is reviewed and
1710
+ changed like any other component.
1711
+
1712
+ ## Run it
1713
+
1714
+ \`\`\`bash
1715
+ bun install # or npm install. The first run pulls a headless Chrome
1716
+ bun run music # writes public/music/bed.wav
1717
+ bun run dev # opens the studio, scrub and edit with hot reload
1718
+ bun run render # writes out/video.mp4
1719
+ \`\`\`
1720
+
1721
+ Rendering happens on your machine. Nothing is uploaded.
1722
+
1723
+ ## Where to start
1724
+
1725
+ \`src/Video.tsx\` is the running order and the only place scene lengths live.
1726
+ Read the scene ids on their own: if they do not tell your story in order, fix
1727
+ that before touching any animation.
1728
+
1729
+ \`src/theme.ts\` holds the colours and type. Replace them with your own design
1730
+ system's tokens so the film and the app are the same brand.
1731
+
1732
+ \`src/scenes/\` has three patterns to copy: a title card, a screenshot in a
1733
+ frame, and a before and after.
1734
+
1735
+ ## Two rules worth keeping
1736
+
1737
+ Scene lengths are multiples of 30 frames, which at 60fps and 120 BPM puts every
1738
+ cut on a beat. \`scripts/make-music.py\` reads those lengths back out of
1739
+ \`src/Video.tsx\`, so the music follows the edit rather than the other way round.
1740
+
1741
+ A render that succeeds is not a render that is correct. Before you ship it,
1742
+ step through the frames:
1743
+
1744
+ \`\`\`bash
1745
+ ffmpeg -i out/video.mp4 -vf fps=3 frames/%04d.png
1746
+ \`\`\`
1747
+
1748
+ Sampling once a second is not enough. Check the first and last frame of every
1749
+ scene, which is where entry and exit animations fail.
1750
+
1751
+ ## Shipping it to the web
1752
+
1753
+ \`\`\`bash
1754
+ ffmpeg -i out/video-1080.mp4 -vf scale=1280:-2 -c:v libx264 -crf 26 \\
1755
+ -preset slow -movflags +faststart -c:a aac -b:a 96k web.mp4
1756
+ \`\`\`
1757
+
1758
+ \`+faststart\` matters: without it the player waits for the whole file before it
1759
+ can begin.
1760
+ `
1761
+ }
1762
+ ];
1763
+ MOTION_SCAFFOLD_STEPS = [
1764
+ "Write every file in MOTION_SCAFFOLD_FILES into a `motion/` directory at the repo root. Keep it out of the app's workspace: the first install pulls a headless Chrome and is around 600 MB.",
1765
+ "Replace the tokens in src/theme.ts with the project's own colours, radii and type, so the film and the app are the same brand.",
1766
+ "Rewrite the scene list in src/Video.tsx for this product. Keep every length a multiple of 30 frames so the cuts stay on the beat.",
1767
+ "Tell the user to run: bun install, then bun run music, then bun run render.",
1768
+ "Do not claim the video is finished until they have watched it. A render that succeeds is not a render that is correct."
1769
+ ].join("\n");
1770
+ }
1771
+ });
1772
+
1104
1773
  // ../shared/src/foundational-docs/topic-utils.ts
1105
1774
  function normalizePrincipleTopic(value) {
1106
1775
  return value.trim().toLowerCase().replace(/^principle-/, "").replace(/&/g, "and").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
@@ -1180,6 +1849,18 @@ function formatTopicNotFound(topic, principles, toolName, resourceLabel) {
1180
1849
  `Call \`${toolName}({})\` for the full index.`
1181
1850
  ].filter(Boolean).join("\n");
1182
1851
  }
1852
+ function appendReviewNudge(response, session) {
1853
+ const nudge = reviewNudge(session);
1854
+ if (!nudge) return response;
1855
+ const [first, ...rest] = response.content;
1856
+ if (!first) return response;
1857
+ return {
1858
+ ...response,
1859
+ content: [{ ...first, text: `${first.text}
1860
+
1861
+ ${nudge}` }, ...rest]
1862
+ };
1863
+ }
1183
1864
  async function runPrincipleTool(provider, area, label, toolName, args) {
1184
1865
  const { topic, query, maxTokens } = args;
1185
1866
  const resourceLabel = area === "react-native" || area === "three-js" || area === "motion" ? "Guides" : "Principles";
@@ -1257,14 +1938,22 @@ ${formatted}`;
1257
1938
  capture: { topResults: [] }
1258
1939
  };
1259
1940
  }
1260
- function registerPrincipleTools({ server, provider, track }, { enableReactNativeGuide = true, enableThreeJsGuide = true } = {}) {
1941
+ async function runGuidanceTool(provider, userId, clientName, area, label, toolName, args) {
1942
+ const [run, session] = await Promise.all([
1943
+ runPrincipleTool(provider, area, label, toolName, args),
1944
+ loadSessionGuidance(provider, userId, clientName)
1945
+ ]);
1946
+ return { ...run, response: appendReviewNudge(run.response, session) };
1947
+ }
1948
+ function registerPrincipleTools({ server, provider, track, userId }, { enableReactNativeGuide = true, enableThreeJsGuide = true } = {}) {
1949
+ const clientName = () => mcpClientName(server);
1261
1950
  server.registerTool(
1262
1951
  "get-ui-principle",
1263
1952
  {
1264
1953
  title: "Get UI Design Principle",
1265
1954
  description: `Load visual UI design principles into context on-demand.
1266
1955
 
1267
- Use this for visual surface decisions: hierarchy and emphasis, spacing/layout and grouping, typography, color, depth, images, accessibility, polish, and animation. For behavior, flows, forms, navigation, notifications, errors, search, onboarding, microcopy, rewards, behavior change, design process, or psychology/cognitive laws, use get-ux-principle instead.
1956
+ Use this for visual surface decisions: hierarchy and emphasis, spacing/layout and grouping, typography, color, depth, images, accessibility, polish, and animation. Building a shop, product page, or product imagery: load product-pages and product-imagery. For behavior, flows, forms, navigation, notifications, errors, search, onboarding, microcopy, rewards, behavior change, design process, or psychology/cognitive laws, use get-ux-principle instead.
1268
1957
 
1269
1958
  You can either:
1270
1959
  1. Request a specific topic or alias: get-ui-principle({ topic: "spacing" })
@@ -1273,17 +1962,17 @@ You can either:
1273
1962
 
1274
1963
  Use maxTokens to keep responses focused (recommended: 5000\u201310000).`,
1275
1964
  inputSchema: z2.object({
1276
- topic: z2.string().trim().min(1).max(120).optional().describe("Specific UI topic or alias to load, e.g. spacing, gestalt, hierarchy, blur test, accessibility, color, depth, animation."),
1965
+ topic: z2.string().trim().min(1).max(120).optional().describe("Specific UI topic or alias to load, e.g. spacing, gestalt, hierarchy, blur test, accessibility, color, depth, animation, product-pages, product-imagery."),
1277
1966
  query: z2.string().trim().min(1).max(500).optional().describe("Natural language search query to find relevant principles."),
1278
1967
  maxTokens: z2.number().min(500).max(5e4).optional().describe("Maximum tokens to return. Truncates content to fit. Recommended: 5000-10000."),
1279
1968
  context: CONTEXT_ARG
1280
1969
  }),
1281
- annotations: { readOnlyHint: true }
1970
+ annotations: READ_ONLY_TOOL_ANNOTATIONS
1282
1971
  },
1283
1972
  async ({ topic, query, maxTokens, context }) => {
1284
1973
  const start = Date.now();
1285
1974
  try {
1286
- const { response, capture } = await runPrincipleTool(provider, "ui", "UI", "get-ui-principle", { topic, query, maxTokens });
1975
+ const { response, capture } = await runGuidanceTool(provider, userId, clientName(), "ui", "UI", "get-ui-principle", { topic, query, maxTokens });
1287
1976
  return trackSearch(track, "get-ui-principle", { ...capture, context }, start, response);
1288
1977
  } catch (error) {
1289
1978
  return trackSearch(
@@ -1304,7 +1993,7 @@ Use maxTokens to keep responses focused (recommended: 5000\u201310000).`,
1304
1993
  title: "Get UX Design Principle",
1305
1994
  description: `Load UX behavior and product-flow principles into context on-demand.
1306
1995
 
1307
- Use this before implementing flows or interactive behavior: forms, navigation, notifications, errors, dialogs, search, onboarding, responsive behavior, microcopy, rewards and behavior change, design process and clarity audits, motion briefs, interaction patterns, mobile patterns, sound, anti-patterns, understanding users, and cognitive laws / psychology / behavioral science.
1996
+ Use this before implementing flows or interactive behavior: forms, navigation, notifications, errors, dialogs, search, onboarding, responsive behavior, microcopy, rewards and behavior change, design process and clarity audits, motion briefs, interaction patterns, mobile patterns, sound, anti-patterns, understanding users, and cognitive laws / psychology / behavioral science. Building a catalog, collection page, or product listing: load commerce-catalog. Building a cart, checkout, or payment step: load commerce-checkout.
1308
1997
 
1309
1998
  You can either:
1310
1999
  1. Request a specific topic: get-ux-principle({ topic: "behavior-change" })
@@ -1314,17 +2003,17 @@ You can either:
1314
2003
 
1315
2004
  Use maxTokens to keep responses focused (recommended: 5000\u201310000).`,
1316
2005
  inputSchema: z2.object({
1317
- topic: z2.string().trim().min(1).max(120).optional().describe("Specific UX topic or alias to load, e.g. forms, navigation, microcopy, reward design, behavior-change, clarity audit, motion brief, design-process, cognitive-laws, psychology."),
2006
+ topic: z2.string().trim().min(1).max(120).optional().describe("Specific UX topic or alias to load, e.g. forms, navigation, microcopy, reward design, behavior-change, clarity audit, motion brief, design-process, cognitive-laws, psychology, commerce-catalog, commerce-checkout."),
1318
2007
  query: z2.string().trim().min(1).max(500).optional().describe("Natural language search query to find relevant UX principles."),
1319
2008
  maxTokens: z2.number().min(500).max(5e4).optional().describe("Maximum tokens to return. Truncates content to fit. Recommended: 5000-10000."),
1320
2009
  context: CONTEXT_ARG
1321
2010
  }),
1322
- annotations: { readOnlyHint: true }
2011
+ annotations: READ_ONLY_TOOL_ANNOTATIONS
1323
2012
  },
1324
2013
  async ({ topic, query, maxTokens, context }) => {
1325
2014
  const start = Date.now();
1326
2015
  try {
1327
- const { response, capture } = await runPrincipleTool(provider, "ux", "UX", "get-ux-principle", { topic, query, maxTokens });
2016
+ const { response, capture } = await runGuidanceTool(provider, userId, clientName(), "ux", "UX", "get-ux-principle", { topic, query, maxTokens });
1328
2017
  return trackSearch(track, "get-ux-principle", { ...capture, context }, start, response);
1329
2018
  } catch (error) {
1330
2019
  return trackSearch(
@@ -1365,13 +2054,15 @@ Prefer query search when you do not know the topic name. Use maxTokens to keep r
1365
2054
  maxTokens: z2.number().min(500).max(5e4).optional().describe("Maximum tokens to return. Truncates content to fit. Recommended: 5000-10000."),
1366
2055
  context: CONTEXT_ARG
1367
2056
  }),
1368
- annotations: { readOnlyHint: true }
2057
+ annotations: READ_ONLY_TOOL_ANNOTATIONS
1369
2058
  },
1370
2059
  async ({ topic, query, maxTokens, context }) => {
1371
2060
  const start = Date.now();
1372
2061
  try {
1373
- const { response, capture } = await runPrincipleTool(
2062
+ const { response, capture } = await runGuidanceTool(
1374
2063
  provider,
2064
+ userId,
2065
+ clientName(),
1375
2066
  "react-native",
1376
2067
  "React Native",
1377
2068
  "get-react-native-guide",
@@ -1422,13 +2113,15 @@ Prefer query search when you do not know the topic name. Use maxTokens to keep r
1422
2113
  maxTokens: z2.number().min(500).max(5e4).optional().describe("Maximum tokens to return. Truncates content to fit. Recommended: 5000-10000."),
1423
2114
  context: CONTEXT_ARG
1424
2115
  }),
1425
- annotations: { readOnlyHint: true }
2116
+ annotations: READ_ONLY_TOOL_ANNOTATIONS
1426
2117
  },
1427
2118
  async ({ topic, query, maxTokens, context }) => {
1428
2119
  const start = Date.now();
1429
2120
  try {
1430
- const { response, capture } = await runPrincipleTool(
2121
+ const { response, capture } = await runGuidanceTool(
1431
2122
  provider,
2123
+ userId,
2124
+ clientName(),
1432
2125
  "three-js",
1433
2126
  "Three.js",
1434
2127
  "get-three-js-guide",
@@ -1465,30 +2158,60 @@ Use this before writing a Remotion composition, and while debugging one. It cove
1465
2158
 
1466
2159
  The guidance is distilled from building a real 55-second product film, so it names the failures in the order you will hit them.
1467
2160
 
1468
- This tool hands over guidance only. It does not render: that needs Remotion and a headless Chrome in the user's own project. Write the composition into their repo and let them render locally.
2161
+ Start with scaffold: true. It returns a complete Remotion project that already renders, so you adapt something working instead of inventing the layout, the render pipeline and the audio script.
2162
+
2163
+ This tool does not render. Rendering needs Remotion and a headless Chrome in the user's own project, around 600 MB on first install. Write the files into their repo and let them run it.
1469
2164
 
1470
2165
  This complements the design tools: use get-ui-principle for visual decisions and get-ux-principle for product behavior.
1471
2166
 
1472
2167
  You can either:
1473
- 1. Request a known topic: get-motion-guide({ topic: "fundamentals" })
1474
- 2. Use an alias: get-motion-guide({ topic: "cut on the beat" })
1475
- 3. Search for the current task or problem: get-motion-guide({ query: "my animated word swap clips the longer word" })
1476
- 4. Get the full topic index: get-motion-guide({})
2168
+ 1. Get the starter project: get-motion-guide({ scaffold: true })
2169
+ 2. Request a known topic: get-motion-guide({ topic: "video-fundamentals" })
2170
+ 3. Use an alias: get-motion-guide({ topic: "cut on the beat" })
2171
+ 4. Search for the current task or problem: get-motion-guide({ query: "my animated word swap clips the longer word" })
2172
+ 5. Get the full topic index: get-motion-guide({})
1477
2173
 
1478
2174
  Prefer query search when you do not know the topic name. Use maxTokens to keep responses focused (recommended: 5000-10000).`,
1479
2175
  inputSchema: z2.object({
1480
- topic: z2.string().trim().min(1).max(120).optional().describe("Specific motion topic or alias, e.g. fundamentals, text-and-layout, story-and-rhythm, verifying-a-render."),
2176
+ scaffold: z2.boolean().optional().describe("Return a complete Remotion project that already renders, as files to write. Start here when building a video."),
2177
+ topic: z2.string().trim().min(1).max(120).optional().describe("Specific motion topic or alias, e.g. video-fundamentals, text-and-layout, story-and-rhythm, verifying-a-render."),
1481
2178
  query: z2.string().trim().min(1).max(500).optional().describe("Natural language description of the video or animation task or problem."),
1482
2179
  maxTokens: z2.number().min(500).max(5e4).optional().describe("Maximum tokens to return. Truncates content to fit. Recommended: 5000-10000."),
1483
2180
  context: CONTEXT_ARG
1484
2181
  }),
1485
- annotations: { readOnlyHint: true }
2182
+ annotations: READ_ONLY_TOOL_ANNOTATIONS
1486
2183
  },
1487
- async ({ topic, query, maxTokens, context }) => {
2184
+ async ({ scaffold, topic, query, maxTokens, context }) => {
1488
2185
  const start = Date.now();
2186
+ if (scaffold) {
2187
+ const files = MOTION_SCAFFOLD_FILES.map(
2188
+ (file) => `--- ${file.path} ---
2189
+ ${file.contents}`
2190
+ ).join("\n");
2191
+ return trackSearch(
2192
+ track,
2193
+ "get-motion-guide",
2194
+ { query, topic: "scaffold", topResults: [], context },
2195
+ start,
2196
+ {
2197
+ content: [
2198
+ {
2199
+ type: "text",
2200
+ text: `A Remotion project that already renders. Write these files, then follow the steps.
2201
+
2202
+ ${MOTION_SCAFFOLD_STEPS}
2203
+
2204
+ ${files}`
2205
+ }
2206
+ ]
2207
+ }
2208
+ );
2209
+ }
1489
2210
  try {
1490
- const { response, capture } = await runPrincipleTool(
2211
+ const { response, capture } = await runGuidanceTool(
1491
2212
  provider,
2213
+ userId,
2214
+ clientName(),
1492
2215
  "motion",
1493
2216
  "Motion",
1494
2217
  "get-motion-guide",
@@ -1537,6 +2260,7 @@ You MUST call this after writing UI code to self-review your output, then fix ev
1537
2260
  - Microcopy details: pluralization, improvised unit glyphs, empty states without a next action
1538
2261
  - Template layouts: gradient hero + eyebrow pill, left-border callout boxes
1539
2262
  - Reward feedback: confirm the outcome, surface proportional user value, and avoid decorative over-reward
2263
+ - Prose tells in shipped paragraphs (hero, about, pricing, changelog, docs): banned words (delve, leverage, robust, cutting-edge, elevate), binary contrasts ("It's not X, it's Y"), throat-clearing and faux-insight openers, colon reveals, puffery and unsourced "experts agree", fake-profound kickers and recap endings, synonym cycling, emoji headings and mid-sentence bold
1540
2264
 
1541
2265
  **Comprehension (would someone outside the team understand it?):**
1542
2266
  - Mom test: insider vocabulary, unexplained acronyms, sentences too long to follow
@@ -1550,12 +2274,16 @@ Use maxTokens to keep responses focused (recommended: 5000\u201310000).`,
1550
2274
  maxTokens: z2.number().min(500).max(5e4).optional().describe("Maximum tokens to return. Truncates content to fit. Recommended: 5000\u201310000."),
1551
2275
  context: CONTEXT_ARG
1552
2276
  }),
1553
- annotations: { readOnlyHint: true }
2277
+ annotations: READ_ONLY_TOOL_ANNOTATIONS
1554
2278
  },
1555
2279
  async ({ category = "all", maxTokens, context }) => {
1556
2280
  const start = Date.now();
1557
2281
  try {
1558
- const rules = await provider.getReviewRules(category);
2282
+ const [allRules, session] = await Promise.all([
2283
+ provider.getReviewRules(category),
2284
+ loadSessionGuidance(provider, userId, clientName())
2285
+ ]);
2286
+ const { rules, sections } = weightReviewRules(allRules, session.topics);
1559
2287
  const topResults = rules.map((rule) => ({ id: rule.id }));
1560
2288
  if (rules.length === 0) {
1561
2289
  return trackSearch(
@@ -1569,9 +2297,12 @@ Use maxTokens to keep responses focused (recommended: 5000\u201310000).`,
1569
2297
  const rulesOutput = rules.map((rule) => `# ${rule.title}
1570
2298
 
1571
2299
  ${rule.content}`).join("\n\n---\n\n");
2300
+ const preamble = guidancePreamble(session.topics, "the build", sections);
1572
2301
  let text = `# Code Review Rules
1573
2302
 
1574
- ${rulesOutput}`;
2303
+ ${preamble ? `${preamble}
2304
+
2305
+ ` : ""}${rulesOutput}`;
1575
2306
  if (maxTokens) text = truncateToTokens(text, maxTokens);
1576
2307
  return trackSearch(
1577
2308
  track,
@@ -1598,8 +2329,10 @@ var SEARCH_RESULT_PREVIEW_CHARS;
1598
2329
  var init_principle_tools = __esm({
1599
2330
  "../shared/src/mcp/tools/principle-tools.ts"() {
1600
2331
  "use strict";
2332
+ init_motion_scaffold();
1601
2333
  init_topic_utils();
1602
2334
  init_shared();
2335
+ init_session_guidance();
1603
2336
  SEARCH_RESULT_PREVIEW_CHARS = 4e3;
1604
2337
  }
1605
2338
  });
@@ -2079,7 +2812,7 @@ var init_comprehension = __esm({
2079
2812
 
2080
2813
  // ../shared/src/mcp/tools/comprehension-tools.ts
2081
2814
  import { z as z3 } from "zod";
2082
- function registerComprehensionTools({ server, track }) {
2815
+ function registerComprehensionTools({ server, provider, track, userId }) {
2083
2816
  server.registerTool(
2084
2817
  "check-comprehension",
2085
2818
  {
@@ -2107,13 +2840,18 @@ Use knownTerms for words your audience genuinely knows, so the check stops flagg
2107
2840
  knownTerms: z3.array(z3.string().trim().min(1).max(60)).max(50).optional().describe("Words this audience already knows, so they are not flagged as insider language."),
2108
2841
  context: CONTEXT_ARG
2109
2842
  }),
2110
- annotations: { readOnlyHint: true }
2843
+ annotations: READ_ONLY_TOOL_ANNOTATIONS
2111
2844
  },
2112
2845
  async ({ context, ...args }) => {
2113
2846
  const start = Date.now();
2114
2847
  const report = analyzeComprehension(args);
2115
2848
  const hasCopy = Boolean(report.copy.headline) || report.copy.body.length > 0 || report.copy.actions.length > 0;
2116
- const text = hasCopy ? formatComprehensionReport(report) : NOTHING_TO_CHECK;
2849
+ const body = hasCopy ? formatComprehensionReport(report) : NOTHING_TO_CHECK;
2850
+ const session = await loadSessionGuidance(provider, userId, mcpClientName(server));
2851
+ const preamble = guidancePreamble(session.topics, "this copy");
2852
+ const text = preamble ? `${preamble}
2853
+
2854
+ ${body}` : body;
2117
2855
  track(
2118
2856
  "check-comprehension",
2119
2857
  {
@@ -2134,6 +2872,7 @@ var init_comprehension_tools = __esm({
2134
2872
  "use strict";
2135
2873
  init_comprehension();
2136
2874
  init_shared();
2875
+ init_session_guidance();
2137
2876
  NOTHING_TO_CHECK = `# Comprehension check
2138
2877
 
2139
2878
  No user-facing copy was found. Pass the screen's copy directly:
@@ -2292,7 +3031,7 @@ Examples:
2292
3031
  inputSchema: z4.object({
2293
3032
  query: z4.string().describe("Style description or personality traits to match.")
2294
3033
  }),
2295
- annotations: { readOnlyHint: true }
3034
+ annotations: READ_ONLY_TOOL_ANNOTATIONS
2296
3035
  };
2297
3036
  const makeIconLibraryHandler = (toolName) => async ({ query }) => {
2298
3037
  const start = Date.now();
@@ -2362,13 +3101,13 @@ Examples:
2362
3101
  - find-icons({ query: "home", libraryId: "phosphor" })
2363
3102
  - find-icons({ query: "settings", libraryId: "tabler" })
2364
3103
 
2365
- Returns icons formatted for use with Iconify React. If you pass a variant, Better Design will try standard Iconify naming first, such as \`home-bold\`, then fall back to alternate naming and the bare icon name.`,
3104
+ Every name returned exists in the library, so pass them to \`install-icons\` verbatim. A \`variant\` only re-orders the results, putting the names that carry that variant first; it never rewrites a name.`,
2366
3105
  inputSchema: z4.object({
2367
3106
  query: z4.string().describe("What icon you need (e.g., 'home', 'settings', 'user')"),
2368
3107
  libraryId: z4.string().describe("The icon library ID from find-icon-library"),
2369
3108
  variant: z4.string().optional().describe("Optional variant style (e.g., 'bold', 'outline', 'duotone').")
2370
3109
  }),
2371
- annotations: { readOnlyHint: true }
3110
+ annotations: READ_ONLY_TOOL_ANNOTATIONS
2372
3111
  };
2373
3112
  const makeIconSearchHandler = (toolName) => async ({ query, libraryId, variant }) => {
2374
3113
  const start = Date.now();
@@ -2452,7 +3191,7 @@ If \`components/icons/index.ts\` already exists in the project, MERGE the new ex
2452
3191
  variant: z4.string().optional().describe("Optional library variant (e.g. 'outline', 'filled')."),
2453
3192
  context: CONTEXT_ARG
2454
3193
  }),
2455
- annotations: { readOnlyHint: true }
3194
+ annotations: READ_ONLY_TOOL_ANNOTATIONS
2456
3195
  },
2457
3196
  async ({ libraryId, names, targetDir, format, variant, context }) => tracked(
2458
3197
  "install-icons",
@@ -2657,7 +3396,7 @@ Returns either:
2657
3396
  manifestContent: z4.string().optional().describe("Contents of the project's components/icons/.library.json file, if it exists. The agent should attempt to read this file from disk and pass it here. If the file doesn't exist or can't be read, omit this field."),
2658
3397
  context: CONTEXT_ARG
2659
3398
  }),
2660
- annotations: { readOnlyHint: true }
3399
+ annotations: READ_ONLY_TOOL_ANNOTATIONS
2661
3400
  },
2662
3401
  async ({ manifestContent, context }) => tracked("detect-icon-library", { hasManifest: Boolean(manifestContent), context }, async () => {
2663
3402
  if (!manifestContent || !manifestContent.trim()) {
@@ -2735,6 +3474,21 @@ context in the supporting line or after the title. Skip the generated-template s
2735
3474
  gradient hero with an uppercase eyebrow pill, centered three-card feature grid,
2736
3475
  left-border callout boxes, walls of text.
2737
3476
 
3477
+ **Count the copy before you present.** This is a check to run, not advice to weigh.
3478
+ On the first view: at most 60 visible words, at most 25 separate text fragments, one
3479
+ headline of about 6 words, and at most one supporting line of about 15 words per
3480
+ section. Count them. If you are over, delete copy; do not shrink the type to fit and
3481
+ do not move the words into a tooltip. A section that needs three sentences to explain
3482
+ itself is a section whose layout is wrong.
3483
+
3484
+ **Labels are sentence case, never letter-spaced capitals.** Do not style section
3485
+ labels, stat captions, table column headers, card headers, or badges as tiny spaced
3486
+ capitals (\`text-xs uppercase tracking-widest\`, with or without \`font-mono\`). It is
3487
+ the single fastest generated-UI tell, and it costs legibility at exactly the size
3488
+ where legibility is scarcest. Use sentence case at the DS's own label size and muted
3489
+ color. Keep \`font-mono\` for code, IDs, keyboard shortcuts, and figures that must
3490
+ align in a column.
3491
+
2738
3492
  **Hierarchy is pass/fail.** Give the first view one P0 task and at most one P1
2739
3493
  summary; move P2 history, standings, detailed metadata, and empty-state chrome
2740
3494
  below it or behind a tab/disclosure. Unless the user explicitly asks for a dense
@@ -2758,7 +3512,8 @@ After writing UI, self-review with
2758
3512
  `;
2759
3513
  DS_ESLINT_CONFIG = `// Better Design \u2014 design-system guardrail (flat ESLint config fragment).
2760
3514
  // Flags raw HTML primitives in feature code when the installed DS ships an
2761
- // equivalent in components/ui/*. Wire it into eslint.config.mjs:
3515
+ // equivalent in components/ui/*, plus the uppercase micro-label tell.
3516
+ // Wire it into eslint.config.mjs:
2762
3517
  //
2763
3518
  // import dsGuardrail from "./.better-design/eslint-design-system.mjs"; // better-design-guardrail
2764
3519
  // export default [ ...dsGuardrail, /* ...your config */ ];
@@ -2771,6 +3526,7 @@ const banned = [
2771
3526
  { selector: "JSXOpeningElement[name.name='input']", message: "Use <Input> from @/components/ui/input instead of a raw <input>." },
2772
3527
  { selector: "JSXOpeningElement[name.name='select']", message: "Use <Select> from @/components/ui/select instead of a raw <select>." },
2773
3528
  { selector: "JSXOpeningElement[name.name='textarea']", message: "Use <Textarea> from @/components/ui/textarea instead of a raw <textarea>." },
3529
+ { selector: "JSXAttribute[name.name='className'] Literal[value=/(^|[ ])uppercase([ ]|$)/][value=/(^|[ ])tracking-[^ ]+([ ]|$)/]", message: "Letter-spaced capitals are a generated-UI tell. Use sentence case at the design system's own label size; keep font-mono for code, IDs, and aligned figures." },
2774
3530
  ];
2775
3531
 
2776
3532
  export default [
@@ -2922,12 +3678,14 @@ function buildDsHtmlKit({ name, tokensCss, viewUrl, source, componentKit }) {
2922
3678
  '- Status colors: the `ds-badge-safe|warn|danger|unknown|verified` classes are colour modifiers, so add them ALONGSIDE the base `ds-badge`, for example `<span class="ds-badge ds-badge-safe">Sesame-free</span>`. They map to the system\'s own status tokens. Reach for these instead of hand-writing a colored badge from a raw token.',
2923
3679
  "- Filters, toggles and any tappable pill use `ds-chip` (or `ds-btn`) rendered as a real `<button>`, never a `ds-badge` span. Badges are read-only status labels.",
2924
3680
  '- Make the mock WORK, not just render: wire a few lines of vanilla JS so every control has a VISIBLE EFFECT. Filter chips toggle `aria-pressed` (the kit styles `ds-chip[aria-pressed="true"]` as selected) AND actually filter: tag each list item with `data-tags="..."` and hide items that do not match the pressed chips. Search inputs filter the list on `input`. A control that changes nothing reads as broken.',
2925
- "- Custom classes are for LAYOUT only (position, spacing, grid). Never restyle a `ds-*` component's geometry or surface (border-radius, borders, shadows, fills) \u2014 those carry this system's identity. If a component looks wrong, use a different `ds-*` component, do not reshape it.",
3681
+ "- Custom classes are for LAYOUT only (position, spacing, grid). Never restyle a `ds-*` component's geometry or surface (border-radius, borders, shadows, fills) \u2014 those carry this system's identity. If a component looks wrong, use a different `ds-*` component, do not reshape it. An asset you draw yourself (an inline `<svg>` or an illustration block) is NOT a `ds-*` component: style it freely, as long as every colour comes from these tokens.",
2926
3682
  "- Cards in a grid must stay aligned: the kit pins `ds-card-footer` to the card bottom so rows of cards keep equal footer lines even when a badge row wraps \u2014 do not override that. Keep wrap-prone rows (badges, chips) in the same slot on every card so nothing reads as misaligned.",
2927
3683
  '- Build any layout you want with these tokens, do not invent new colors. For the full React component set in a real project, call `get-design-system-kit` with `target: "react"` instead.',
2928
3684
  "",
2929
3685
  "## Keep it tight",
2930
- "Build the screens the user actually asked for (one, unless they asked for more) and compose the `ds-*` components above. Do not hand-roll a pile of bespoke decorative CSS (illustrative art, faux maps, gradients, glassmorphism panels, radial-glow backgrounds): reach for the `ds-*` classes and these tokens first. Skip a long write-up: ship the artifact with a one-line note, no multi-paragraph explanation or trailing follow-up questions.",
3686
+ "Build the screens the user actually asked for (one, unless they asked for more) and compose the `ds-*` components above.",
3687
+ BESPOKE_ASSET_POLICY,
3688
+ "Skip a long write-up: ship the artifact with a one-line note, no multi-paragraph explanation or trailing follow-up questions.",
2931
3689
  ...componentKit?.excluded?.length ? [
2932
3690
  "",
2933
3691
  "## Not in this kit (use the real components)",
@@ -2937,7 +3695,7 @@ function buildDsHtmlKit({ name, tokensCss, viewUrl, source, componentKit }) {
2937
3695
  Full live showcase: ${viewUrl}` : ""
2938
3696
  ].join("\n");
2939
3697
  }
2940
- var MINIMUM_TEXT_CONTRAST, HOVER_POLICY, INLINE_TEXT_COLOR_POLICY, HOST_ISOLATION_CSS_TEMPLATE, INLINE_COMPOSITION_POLICY, INLINE_HIERARCHY_POLICY, DS_KIT_CSS, DS_KIT_EXAMPLE_HTML, HTML_KIT_CONDUCT_MD;
3698
+ var MINIMUM_TEXT_CONTRAST, HOVER_POLICY, INLINE_TEXT_COLOR_POLICY, HOST_ISOLATION_CSS_TEMPLATE, BESPOKE_ASSET_POLICY, INLINE_COMPOSITION_POLICY, INLINE_HIERARCHY_POLICY, DS_KIT_CSS, DS_KIT_EXAMPLE_HTML, HTML_KIT_CONDUCT_MD;
2941
3699
  var init_html_kit = __esm({
2942
3700
  "../shared/src/mcp/html-kit.ts"() {
2943
3701
  "use strict";
@@ -2950,6 +3708,13 @@ var init_html_kit = __esm({
2950
3708
  ].join(" ");
2951
3709
  HOST_ISOLATION_CSS_TEMPLATE = `APP_ROOT{color:var(--foreground,oklch(0.2 0 0));background:var(--background,oklch(1 0 0))}
2952
3710
  APP_ROOT :where(h1,h2,h3,h4,h5,h6,p,strong,label,legend,li,dt,dd,small):not(.ds-supporting):not(.ds-card-desc):not(.ds-muted){color:inherit!important}`;
3711
+ BESPOKE_ASSET_POLICY = [
3712
+ "Cut GENERIC decoration: faux maps, gradient washes, glassmorphism panels, radial-glow backgrounds. Those attach to no particular product, so they read as filler.",
3713
+ "A bespoke asset is the opposite and it is welcome. ONE illustration, section seam or piece of hero art, drawn from what this product actually does, is what makes a page look authored instead of generated.",
3714
+ "Apply this test before you keep it: could this asset sit unchanged on a competitor's page? If yes, cut it. If no, keep it.",
3715
+ "Draw it with these tokens, and reuse its visual language (its drawing style, palette, grid and line weight) for every later asset so the set reads as one family rather than clip art from different sources.",
3716
+ "An asset you draw yourself, an inline `<svg>` or an illustration block, is NOT a `ds-*` component: style it freely, as long as every colour comes from these tokens."
3717
+ ].join(" ");
2953
3718
  INLINE_COMPOSITION_POLICY = [
2954
3719
  "Start with the primary user moment and make it the largest, highest-contrast composition in the first view; supporting data must read as secondary.",
2955
3720
  "Keep one visible identity block per level. Do not stack an app logo and title directly above a second logo and title for the same experience; make a pool, league, or workspace name subordinate context inside the primary composition instead of another header.",
@@ -2982,6 +3747,11 @@ APP_ROOT :where(h1,h2,h3,h4,h5,h6,p,strong,label,legend,li,dt,dd,small):not(.ds-
2982
3747
  .ds .ds-section-title{font-family:inherit;font-size:1.25rem;font-weight:600;line-height:1.2;letter-spacing:-.01em;margin:0;color:var(--foreground,oklch(0.2 0 0))}
2983
3748
  .ds .ds-supporting{font-size:.875rem;font-weight:400;line-height:1.5;color:var(--muted-foreground,oklch(0.55 0 0));margin:0}
2984
3749
  .ds a{color:var(--primary,oklch(0.45 0.15 264));text-decoration:none}
3750
+ /* A link styled as a button keeps the button's own label colour. The .ds a rule
3751
+ above is specificity (0,1,1) and beat .ds-btn-primary at (0,1,0), so every
3752
+ anchor call to action painted its label in the fill colour. [BD-449] */
3753
+ .ds a.ds-btn{color:inherit}
3754
+ .ds a.ds-btn:hover{text-decoration:none}
2985
3755
  .ds p{margin:0 0 1em}
2986
3756
  .ds-muted{color:var(--muted-foreground,oklch(0.55 0 0))}
2987
3757
 
@@ -3122,6 +3892,9 @@ APP_ROOT :where(h1,h2,h3,h4,h5,h6,p,strong,label,legend,li,dt,dd,small):not(.ds-
3122
3892
  "## Hierarchy and information budget (pass/fail)",
3123
3893
  INLINE_HIERARCHY_POLICY,
3124
3894
  "",
3895
+ "## Draw one asset that only this product could carry",
3896
+ BESPOKE_ASSET_POLICY,
3897
+ "",
3125
3898
  "## Compose the screen, do not decorate the canvas",
3126
3899
  INLINE_COMPOSITION_POLICY,
3127
3900
  "",
@@ -3144,18 +3917,40 @@ function fence2(lang, body) {
3144
3917
  ${body}
3145
3918
  \`\`\``;
3146
3919
  }
3920
+ function pointerSafeMaterialCss(css) {
3921
+ const lines = css?.trim().split("\n");
3922
+ if (!lines?.length) return void 0;
3923
+ const hoverRules = lines.filter((line) => line.includes(":hover"));
3924
+ const baseRules = lines.filter((line) => !line.includes(":hover"));
3925
+ if (hoverRules.length) {
3926
+ baseRules.push(`@media (hover:hover) and (pointer:fine){${hoverRules.join("\n")}}`);
3927
+ }
3928
+ return baseRules.join("\n").trim() || void 0;
3929
+ }
3930
+ function primaryButtonTextCss(css) {
3931
+ if (!css) return void 0;
3932
+ let foreground;
3933
+ for (const match of css.matchAll(/\.ds-btn(?:-primary)?\s*\{([^}]*)\}/g)) {
3934
+ for (const color of match[1]?.matchAll(/(?:^|;)\s*color\s*:\s*([^;}]+)/g) ?? []) {
3935
+ foreground = color[1]?.trim() ?? foreground;
3936
+ }
3937
+ }
3938
+ return foreground ? `.ds.ds-embed .ds-btn-primary{color:${foreground}!important;-webkit-text-fill-color:currentColor}` : void 0;
3939
+ }
3147
3940
  function buildDsShopifyFiles(input) {
3148
- const materialCss = input.componentKit?.css?.trim();
3941
+ const materialCss = pointerSafeMaterialCss(input.componentKit?.css);
3942
+ const primaryTextCss = primaryButtonTextCss(materialCss);
3149
3943
  const assetCss = [
3150
3944
  compactTokensCss(input.tokensCss),
3151
3945
  DS_KIT_CSS,
3152
3946
  DS_SHOPIFY_CSS,
3153
- ...materialCss ? [materialCss] : []
3947
+ ...materialCss ? [materialCss] : [],
3948
+ ...primaryTextCss ? [primaryTextCss] : []
3154
3949
  ].join("\n\n");
3155
3950
  return [{ path: "assets/ds-kit.css", content: assetCss }, ...SNIPPETS];
3156
3951
  }
3157
- function buildDsShopifyKit({ name, tokensCss, viewUrl, source, componentKit }) {
3158
- const files = buildDsShopifyFiles({ tokensCss, componentKit });
3952
+ function buildDsShopifyKit({ name, tokensCss, viewUrl, source, componentKit, themeFiles }) {
3953
+ const files = themeFiles ?? buildDsShopifyFiles({ tokensCss, componentKit });
3159
3954
  const sourceLine = source?.kind === "catalog" ? `Source designSystemId: \`${source.designSystemId}\`` : source?.kind === "project" ? `Source projectId: \`${source.projectId}\`` : null;
3160
3955
  const fileBlocks = files.flatMap((f) => [
3161
3956
  `### \`${f.path}\``,
@@ -3166,7 +3961,7 @@ function buildDsShopifyKit({ name, tokensCss, viewUrl, source, componentKit }) {
3166
3961
  return [
3167
3962
  `# ${name}: Shopify theme kit`,
3168
3963
  "",
3169
- "Full design system for an existing Online Store 2.0 theme: one CSS asset carries this system's real tokens and component material, and the Liquid snippets, sections, and alternate templates cover a complete storefront (header, footer, hero, product page, collection page, content sections) with no JavaScript. Write each file to the path in its heading.",
3964
+ "Full design system for an Online Store 2.0 theme: one CSS asset carries this system's real tokens and component material, while the Liquid snippets, global header/footer groups, and primary storefront templates compose the full store with no JavaScript. Write each file to the path in its heading.",
3170
3965
  ...sourceLine ? ["", sourceLine] : [],
3171
3966
  "",
3172
3967
  SHOPIFY_KIT_CONDUCT_MD,
@@ -3176,57 +3971,150 @@ function buildDsShopifyKit({ name, tokensCss, viewUrl, source, componentKit }) {
3176
3971
  ...fileBlocks,
3177
3972
  "## After installing",
3178
3973
  "- Preview an unpublished copy of the theme first; never edit the live theme directly.",
3179
- "- The kit styles parts of a storefront; page composition, cart mechanics, and checkout stay with the theme.",
3974
+ "- The kit owns the storefront composition and cart page; Shopify continues to own checkout.",
3180
3975
  ...viewUrl ? ["", `Full live showcase: ${viewUrl}`] : []
3181
3976
  ].join("\n");
3182
3977
  }
3183
- var DS_SHOPIFY_CSS, SNIPPET_BADGE, SNIPPET_PRICE, SNIPPET_BUY_BUTTON, SNIPPET_VARIANT_SELECT, SNIPPET_PAGINATION, SNIPPET_PRODUCT_CARD, SNIPPET_ANNOUNCEMENT, SNIPPET_NEWSLETTER, SECTION_FAQ, SECTION_FEATURED_COLLECTION, SECTION_HEADER, SECTION_FOOTER, SECTION_HERO, SECTION_RICH_TEXT, SECTION_IMAGE_WITH_TEXT, SECTION_MAIN_PRODUCT, SECTION_MAIN_COLLECTION, SECTION_CONTACT, SECTION_MULTICOLUMN, TEMPLATE_PRODUCT_JSON, TEMPLATE_COLLECTION_JSON, SNIPPETS, SHOPIFY_KIT_CONDUCT_MD;
3978
+ var DS_SHOPIFY_CSS, SNIPPET_BADGE, SNIPPET_PRICE, SNIPPET_BUY_BUTTON, SNIPPET_VARIANT_SELECT, SNIPPET_PAGINATION, SNIPPET_ICON, SNIPPET_PRODUCT_CARD, SNIPPET_ANNOUNCEMENT, SNIPPET_NEWSLETTER, SECTION_FAQ, SECTION_FEATURED_COLLECTION, SECTION_HEADER, SECTION_FOOTER, SECTION_HERO, SECTION_RICH_TEXT, SECTION_IMAGE_WITH_TEXT, SECTION_MAIN_PRODUCT, SECTION_MAIN_COLLECTION, SECTION_CONTACT, SECTION_MULTICOLUMN, SECTION_MAIN_PAGE, SECTION_MAIN_CART, SECTION_MAIN_SEARCH, SECTION_MAIN_404, TEMPLATE_INDEX_JSON, TEMPLATE_PRODUCT_JSON, TEMPLATE_PAGE_JSON, TEMPLATE_CART_JSON, TEMPLATE_SEARCH_JSON, TEMPLATE_404_JSON, HEADER_GROUP_JSON, FOOTER_GROUP_JSON, TEMPLATE_COLLECTION_JSON, SNIPPETS, SHOPIFY_KIT_CONDUCT_MD;
3184
3979
  var init_shopify_kit = __esm({
3185
3980
  "../shared/src/mcp/shopify-kit.ts"() {
3186
3981
  "use strict";
3187
3982
  init_html_kit();
3188
- DS_SHOPIFY_CSS = `.ds.ds-embed{background:transparent}
3189
- .ds-announce{background:var(--primary,oklch(0.25 0.01 260));color:var(--primary-foreground,oklch(1 0 0));text-align:center;padding:.5rem 1rem;font-size:.8125rem}
3983
+ DS_SHOPIFY_CSS = `html:root{font-size:100%;background:var(--background,oklch(1 0 0));color:var(--foreground,oklch(0.2 0 0))}
3984
+ body{margin:0;background:var(--background,oklch(1 0 0));color:var(--foreground,oklch(0.2 0 0));font-family:var(--font-sans,ui-sans-serif,system-ui,sans-serif);font-size:1rem;line-height:1.5;letter-spacing:normal;text-rendering:optimizeLegibility;-webkit-font-smoothing:antialiased}
3985
+ body.gradient,body.color-background-1,main#MainContent,.content-for-layout{background:var(--background,oklch(1 0 0));color:var(--foreground,oklch(0.2 0 0))}
3986
+ body :where(button,input,select,textarea){font:inherit}
3987
+ body :where(.gradient,.color-background-1,.color-background-2){background:var(--background,oklch(1 0 0));color:var(--foreground,oklch(0.2 0 0))}
3988
+ body :where(.button,.shopify-payment-button__button){border-radius:var(--radius-md,var(--radius,0.5rem));background:var(--primary,oklch(0.25 0.01 260));color:var(--primary-foreground,oklch(0.98 0 0));box-shadow:var(--shadow-sm,none)}
3989
+ body :where(.field__input,.select__select,.customer .field input,.customer select){border-radius:var(--radius-md,var(--radius,0.5rem));background:var(--input,var(--background,oklch(1 0 0)));color:var(--foreground,oklch(0.2 0 0))}
3990
+ body :where(.card,.card__inner,.product__media,.cart-item__image-container){border-radius:var(--radius-lg,var(--radius,0.75rem))}
3991
+ .ds.ds-embed{background:transparent;font-family:var(--font-sans,ui-sans-serif,system-ui,sans-serif);font-size:1rem;letter-spacing:normal}
3992
+ .ds-container{width:min(calc(100% - 2.5rem),76rem);max-width:76rem;margin-inline:auto;padding:0}
3993
+ .ds-section{padding-block:clamp(3rem,7vw,6rem)}
3994
+ .ds-section-compact{padding-block:clamp(2rem,4vw,3.5rem)}
3995
+ .ds-section-head{display:flex;align-items:end;justify-content:space-between;gap:1.5rem;margin-bottom:clamp(1.5rem,3vw,2.5rem)}
3996
+ .ds-section-head .ds-section-title{font-size:clamp(1.65rem,3vw,2.5rem)}
3997
+ .ds-section-copy{max-width:42rem;color:var(--muted-foreground,oklch(0.45 0.01 260))}
3998
+ .ds-display,.ds-section-title,.ds-card-title{text-wrap:balance}
3999
+ .ds-section-copy,.ds-supporting,.ds-card-desc{text-wrap:pretty}
4000
+ .ds.ds-announce{background:var(--primary,oklch(0.25 0.01 260));color:var(--primary-foreground,oklch(0.98 0 0));text-align:center;padding:.55rem 1rem;font-size:.8125rem}
3190
4001
  .ds-announce p{margin:0}
3191
4002
  .ds-announce a{color:inherit;text-decoration:underline}
3192
- .ds-price{display:inline-flex;align-items:baseline;gap:.5rem;flex-wrap:wrap}
3193
- .ds-price-current{font-weight:600;color:var(--foreground,oklch(0.2 0 0))}
3194
- .ds-price-compare{color:var(--muted-foreground,oklch(0.55 0.01 260))}
3195
- .ds-card-media{display:block}
3196
- .ds-card-media img{display:block;width:100%;height:auto}
4003
+ .ds-price{display:inline-flex;align-items:baseline;gap:.6rem;flex-wrap:wrap}
4004
+ .ds-price-current{font-size:1.35rem;font-weight:700;letter-spacing:-.02em;color:var(--foreground,oklch(0.2 0 0))}
4005
+ .ds-price-compare{color:var(--muted-foreground,oklch(0.45 0.01 260))}
4006
+ .ds.ds-embed .ds-btn{min-height:2.75rem;white-space:nowrap;text-decoration:none}
4007
+ .ds.ds-embed .ds-btn-primary{color:var(--primary-foreground,oklch(0.98 0 0))!important;-webkit-text-fill-color:currentColor}
4008
+ .ds-card-media{display:block;overflow:hidden;background:var(--muted,oklch(0.95 0.01 90));aspect-ratio:4/5}
4009
+ .ds-card-media img,.ds-card-media svg{display:block;width:100%;height:100%;object-fit:cover}
4010
+ .ds-product-card{height:100%}
4011
+ .ds-product-card .ds-card-body{padding:1.1rem 1.25rem .65rem}
4012
+ .ds-product-card .ds-card-footer{padding:.75rem 1.25rem 1.25rem;border:0}
3197
4013
  .ds-product-card .ds-card-title a{color:inherit;text-decoration:none}
3198
- .ds-faq-item{border-bottom:1px solid var(--border,oklch(0.9 0.01 260));padding:.25rem 0}
3199
- .ds-faq-item summary{cursor:pointer;padding:.75rem 0;font-weight:600;color:var(--foreground,oklch(0.2 0 0))}
3200
- .ds-faq-answer{padding:0 0 1rem}
3201
- .ds-grid{display:grid;grid-template-columns:repeat(var(--ds-cols,4),1fr);gap:1rem}
3202
- @media (max-width:749px){.ds-grid{grid-template-columns:repeat(2,1fr)}}
3203
- .ds-newsletter .ds-control-row{margin-top:.75rem}
4014
+ .ds-product-card .ds-supporting{margin-bottom:.25rem}
4015
+ .ds-faq{max-width:52rem}
4016
+ .ds-faq-item.ds-card{display:block;padding:.25rem 1.25rem;margin:.75rem 0}
4017
+ .ds-faq-item summary{cursor:pointer;padding:1.15rem 0;font-weight:600;color:var(--foreground,oklch(0.2 0 0))}
4018
+ .ds-faq-answer{padding:0 0 1.25rem;color:var(--muted-foreground,oklch(0.45 0.01 260))}
4019
+ .ds-grid{display:grid;grid-template-columns:repeat(var(--ds-cols,4),minmax(0,1fr));gap:clamp(1rem,2vw,1.75rem)}
4020
+ .ds-grid-empty{grid-column:1/-1}
4021
+ .ds-newsletter .ds-control-row{margin-top:1rem}
3204
4022
  .ds-buy-form{margin:0;display:grid;gap:.75rem;justify-items:start}
3205
- .ds-featured{padding:2rem 0}
4023
+ .ds-buy-form .ds-btn{min-width:min(100%,16rem)}
4024
+ .ds-featured{display:block}
3206
4025
  .ds-qty{width:6rem}
3207
- .ds-header{display:flex;align-items:center;gap:1.5rem;padding:.85rem 1.5rem;background:var(--background,oklch(1 0 0));border-bottom:1px solid var(--border,oklch(0.9 0.01 260))}
3208
- .ds-header-brand{font-weight:700;font-size:1.05rem;color:var(--foreground,oklch(0.2 0 0));text-decoration:none}
3209
- .ds-header-logo{display:block;max-height:2rem;width:auto}
3210
- .ds-header-nav{display:flex;flex:1;gap:1.1rem;flex-wrap:wrap}
3211
- .ds-header-nav a{color:var(--foreground,oklch(0.2 0 0));text-decoration:none;font-size:.9rem}
3212
- .ds-footer{display:flex;gap:2rem;flex-wrap:wrap;justify-content:space-between;align-items:center;padding:2rem 1.5rem;background:var(--background,oklch(1 0 0));border-top:1px solid var(--border,oklch(0.9 0.01 260))}
3213
- .ds-footer nav{display:flex;gap:1rem;flex-wrap:wrap}
3214
- .ds-footer a{color:var(--muted-foreground,oklch(0.55 0.01 260));text-decoration:none;font-size:.875rem}
3215
- .ds-hero{display:grid;gap:1.25rem;padding:2.5rem 0}
3216
- .ds-hero img{display:block;width:100%;height:auto}
3217
- .ds-hero-actions{display:flex;gap:.75rem;flex-wrap:wrap}
3218
- .ds-image-text{display:grid;gap:1.5rem;align-items:center;padding:2rem 0}
3219
- .ds-product-layout{display:grid;gap:2rem;padding:2rem 0}
3220
- .ds-product-media img{display:block;width:100%;height:auto}
3221
- .ds-product-thumbs{display:grid;grid-template-columns:repeat(4,1fr);gap:.5rem;margin-top:.5rem}
3222
- .ds-product-info{display:grid;gap:1rem;align-content:start}
3223
- .ds-product-page{padding:2rem 0}
3224
- .ds-collection-page{padding:2rem 0}
3225
- .ds-pagination{display:block;text-align:center;margin:1.75rem 0 0}
3226
- .ds-pagination .page,.ds-pagination .next,.ds-pagination .prev{display:inline-block;padding:.35rem .7rem}
4026
+ .ds.ds-header-shell{position:relative;z-index:20;background:color-mix(in oklab,var(--background,oklch(1 0 0)) 90%,transparent);padding:1rem 0}
4027
+ .ds-header-shell.is-sticky{position:sticky;top:0;backdrop-filter:blur(1rem)}
4028
+ .ds-header.ds-card{min-height:4.75rem;display:grid;grid-template-columns:auto 1fr auto;align-items:center;gap:2rem;padding:0 1.25rem;overflow:visible}
4029
+ .ds-header-brand{font-weight:700;font-size:1.1rem;letter-spacing:-.02em;color:var(--foreground,oklch(0.2 0 0));text-decoration:none;white-space:nowrap}
4030
+ .ds-header-logo{display:block;max-height:2.25rem;width:auto}
4031
+ .ds-header-nav{display:flex;justify-content:center;gap:clamp(1rem,2.5vw,2rem);min-width:0}
4032
+ .ds-header-link,.ds-header-nav a{color:var(--foreground,oklch(0.2 0 0));text-decoration:none;font-size:.9rem;font-weight:500}
4033
+ .ds-header-link:focus-visible,.ds-header-nav a:focus-visible,.ds-footer a:focus-visible{outline:2px solid var(--ring,var(--primary,oklch(0.45 0.15 264)));outline-offset:.3rem;border-radius:var(--radius-sm,.25rem)}
4034
+ .ds-header-actions{display:flex;align-items:center;justify-content:flex-end;gap:1rem}
4035
+ .ds-cart-link{display:inline-flex;align-items:center;gap:.35rem}
4036
+ .ds-cart-count{display:inline-grid;place-items:center;min-width:1.35rem;height:1.35rem;padding:0 .3rem;border-radius:999px;background:var(--primary,oklch(0.25 0.01 260));color:var(--primary-foreground,oklch(0.98 0 0));font-size:.7rem;font-weight:700}
4037
+ .ds-mobile-nav{display:none}
4038
+ .ds-mobile-nav summary{list-style:none;cursor:pointer}
4039
+ .ds-mobile-nav summary::-webkit-details-marker{display:none}
4040
+ .ds-mobile-nav-panel{position:absolute;inset-inline:0;top:calc(100% + .5rem);padding:1.25rem;display:grid;gap:1rem;background:var(--card,var(--background,oklch(1 0 0)));border:1px solid var(--border,oklch(0.88 0.01 260));border-radius:var(--radius-xl,var(--radius,1rem));box-shadow:var(--shadow-lg,var(--shadow-md,none))}
4041
+ .ds.ds-footer{background:var(--background,oklch(1 0 0));padding-block:1.25rem 2rem}
4042
+ .ds-footer-panel.ds-card{display:block;padding:clamp(2rem,5vw,4rem)}
4043
+ .ds-footer-grid{display:grid;grid-template-columns:minmax(14rem,1.4fr) repeat(2,minmax(10rem,1fr));gap:clamp(2rem,5vw,5rem)}
4044
+ .ds-footer-brand{max-width:28rem}
4045
+ .ds-footer-title{font-size:.8rem;font-weight:700;text-transform:uppercase;letter-spacing:.08em;margin:0 0 1rem}
4046
+ .ds-footer nav{display:grid;gap:.7rem}
4047
+ .ds-footer a{color:var(--muted-foreground,oklch(0.45 0.01 260));text-decoration:none;font-size:.9rem}
4048
+ .ds-footer-bottom{display:flex;align-items:center;justify-content:space-between;gap:1.5rem;margin-top:3rem;padding-top:1.5rem;border-top:1px solid var(--border,oklch(0.88 0.01 260))}
4049
+ .ds-policy-list,.ds-payment-list{list-style:none;padding:0;margin:0;display:flex;align-items:center;gap:.75rem;flex-wrap:wrap}
4050
+ .ds-payment-icon{display:block;width:2.25rem;height:auto}
4051
+ .ds.ds-hero-section{background:var(--background,oklch(1 0 0));overflow:hidden;padding-block:1.25rem clamp(3rem,7vw,6rem)}
4052
+ .ds-hero{display:grid;grid-template-columns:minmax(0,.85fr) minmax(0,1.15fr);align-items:center;gap:clamp(2.5rem,7vw,7rem);padding-block:clamp(2rem,5vw,4.5rem)}
4053
+ .ds-hero.ds-hero-single{grid-template-columns:minmax(0,42rem);justify-content:start}
4054
+ .ds-hero-content{display:grid;gap:1.25rem;align-content:center;max-width:38rem}
4055
+ .ds-hero .ds-display{font-size:clamp(2.6rem,6vw,5.5rem);line-height:.98}
4056
+ .ds-hero .ds-supporting{font-size:clamp(1rem,1.5vw,1.2rem);max-width:35rem}
4057
+ .ds-hero-media{position:relative;order:2;min-width:0}
4058
+ .ds-hero-media-has-image:before,.ds-hero-media-has-image:after{content:"";position:absolute;z-index:-1;border-radius:var(--radius-3xl,var(--radius-xl,var(--radius,1rem)));pointer-events:none}
4059
+ .ds-hero-media-has-image:before{width:32%;height:28%;inset:-1rem auto auto -1rem;background:var(--pop-blue,transparent)}
4060
+ .ds-hero-media-has-image:after{width:28%;height:24%;inset:auto -1rem -1rem auto;background:var(--pop-orange,transparent)}
4061
+ .ds-hero-media img{display:block;width:100%;height:clamp(24rem,50vw,42rem);object-fit:cover;border-radius:var(--radius-3xl,var(--radius-xl,var(--radius,1rem)));background:var(--muted,oklch(0.95 0.01 90))}
4062
+ .ds-hero-media>.ds-media-empty{min-height:clamp(24rem,50vw,42rem)}
4063
+ .ds-hero-actions{display:flex;align-items:center;gap:.75rem;flex-wrap:wrap}
4064
+ .ds-hero-trust{font-size:.8rem;color:var(--muted-foreground,oklch(0.45 0.01 260));margin:0}
4065
+ .ds-image-text{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:clamp(2rem,6vw,6rem);align-items:center}
4066
+ .ds-image-text.ds-image-text-single{grid-template-columns:minmax(0,42rem)}
4067
+ .ds-image-text-media.ds-card{display:block}
4068
+ .ds-image-text-media img{display:block;width:100%;height:clamp(20rem,45vw,38rem);object-fit:cover;background:var(--muted,oklch(0.95 0.01 90))}
4069
+ .ds-rich-text{max-width:52rem;text-align:center}
4070
+ .ds-rich-text .ds-section-copy{margin-inline:auto}
4071
+ .ds-rich-text .ds-btn{margin-top:1.25rem}
4072
+ .ds-value-panel.ds-card{display:grid;grid-template-columns:repeat(var(--ds-cols,3),minmax(0,1fr));overflow:hidden}
4073
+ .ds-value-item{padding:clamp(1.35rem,3vw,2rem)}
4074
+ .ds-value-item+.ds-value-item{border-left:1px solid var(--border,oklch(0.88 0.01 260))}
4075
+ .ds-value-title-row{display:flex;align-items:center;gap:.75rem}
4076
+ .ds-icon{display:block;width:1.25rem;height:1.25rem;flex:none}
4077
+ .ds-icon-tile{display:grid;place-items:center;flex:0 0 auto;width:2.5rem;height:2.5rem;border-radius:var(--radius-lg,var(--radius,1rem));background:var(--secondary,oklch(0.93 0.01 90));color:var(--secondary-foreground,var(--foreground,oklch(.31 .006 75)))}
4078
+ .ds-icon-tile-blue{background:var(--pop-blue,var(--secondary,oklch(0.93 0.01 90)));color:var(--pop-blue-foreground,var(--foreground,oklch(.31 .006 75)))}
4079
+ .ds-icon-tile-orange{background:var(--pop-orange,var(--secondary,oklch(0.93 0.01 90)));color:var(--pop-orange-foreground,var(--foreground,oklch(.31 .006 75)))}
4080
+ .ds-icon-tile-green{background:var(--pop-green,var(--secondary,oklch(0.93 0.01 90)));color:var(--pop-green-foreground,var(--foreground,oklch(.31 .006 75)))}
4081
+ .ds-product-layout{display:grid;grid-template-columns:minmax(0,1.1fr) minmax(20rem,.9fr);gap:clamp(2rem,6vw,6rem);padding-block:clamp(3rem,7vw,6rem)}
4082
+ .ds-product-media-main.ds-card{display:block}
4083
+ .ds-product-media-main img{display:block;width:100%;height:auto;min-height:30rem;max-height:46rem;object-fit:cover;background:var(--muted,oklch(0.95 0.01 90))}
4084
+ .ds-product-media-main>.ds-media-empty{min-height:30rem}
4085
+ .ds-product-thumbs{display:grid;grid-template-columns:repeat(4,1fr);gap:.75rem;margin-top:.75rem}
4086
+ .ds-product-thumbs img{display:block;width:100%;aspect-ratio:1;object-fit:cover;border-radius:var(--radius-md,var(--radius,.5rem))}
4087
+ .ds-product-info{display:grid;gap:1.25rem;align-content:start}
4088
+ .ds-product-info.ds-card{padding:clamp(1.5rem,4vw,3rem)}
4089
+ .ds-product-info .ds-display{font-size:clamp(2rem,4vw,3.75rem)}
4090
+ .ds-product-description{color:var(--muted-foreground,oklch(0.42 0.01 260))}
4091
+ .ds-page-content.ds-card{display:block;padding:clamp(1.5rem,4vw,3rem)}
4092
+ .ds-contact-form.ds-card{display:grid;gap:1rem;max-width:42rem;margin-top:2rem;padding:clamp(1.5rem,4vw,3rem)}
4093
+ .ds-product-page,.ds-collection-page,.ds-page,.ds-cart-page,.ds-search-page{padding-block:clamp(3rem,7vw,6rem)}
4094
+ .ds-collection-head,.ds-page-head{display:grid;gap:1rem;margin-bottom:clamp(2rem,5vw,4rem);max-width:52rem}
4095
+ .ds-collection-head .ds-display,.ds-page-head .ds-display{font-size:clamp(2.4rem,5vw,4.75rem)}
4096
+ .ds-cart-list{display:grid;gap:1rem;margin:2rem 0;padding:0;list-style:none}
4097
+ .ds-cart-item.ds-card{display:grid;grid-template-columns:7rem minmax(0,1fr) auto;gap:1.25rem;align-items:center;padding:1rem}
4098
+ .ds-cart-item img{display:block;width:7rem;aspect-ratio:1;object-fit:cover;border-radius:var(--radius-md,var(--radius,.5rem))}
4099
+ .ds-cart-item .ds-media-empty{min-height:7rem;padding:.75rem}
4100
+ .ds-cart-summary.ds-card{display:grid;justify-items:end;gap:1rem;margin-left:auto;max-width:28rem;padding:1.5rem;text-align:right}
4101
+ .ds-search-form.ds-card{display:grid;grid-template-columns:minmax(0,32rem) auto;gap:.75rem;align-items:end;width:max-content;max-width:100%;margin:2rem 0 3rem;padding:1.25rem}
4102
+ .ds-empty{display:grid;justify-items:start;gap:1rem;padding-block:2rem}
4103
+ .ds-empty.ds-card{padding:1.5rem}
4104
+ .ds-media-empty{display:grid;place-items:center;align-content:center;gap:.75rem;width:100%;min-height:18rem;padding:2rem;text-align:center;background:var(--secondary,var(--muted,oklch(0.94 0.01 90)));color:var(--muted-foreground,oklch(.55 .009 80))}
4105
+ .ds-media-empty .ds-icon{width:1.5rem;height:1.5rem}
4106
+ .ds-media-empty-title{margin:0;color:var(--foreground,oklch(.31 .006 75));font-weight:600}
4107
+ .ds-media-empty-copy{max-width:22rem;margin:0;font-size:.875rem}
4108
+ .ds-pagination{display:block;text-align:center;margin:2.5rem 0 0}
4109
+ .ds-pagination .page,.ds-pagination .next,.ds-pagination .prev{display:inline-block;padding:.45rem .75rem}
3227
4110
  .ds-pagination a{color:var(--foreground,oklch(0.2 0 0))}
3228
4111
  .ds-pagination .current{font-weight:700;color:var(--primary,oklch(0.25 0.01 260))}
3229
- @media (min-width:750px){.ds-product-layout{grid-template-columns:1fr 1fr}.ds-image-text{grid-template-columns:1fr 1fr}}`;
4112
+ @media (max-width:989px){.ds-header.ds-card{grid-template-columns:auto 1fr auto;gap:1rem}.ds-mobile-nav{display:block}.ds-header-brand{justify-self:center}.ds-header-nav{display:none}.ds-header-actions .ds-header-link:not(.ds-cart-link){display:none}.ds-hero{grid-template-columns:1fr}.ds-hero-media{order:2}.ds-hero-media img{height:clamp(20rem,80vw,34rem)}.ds-value-panel.ds-card{grid-template-columns:1fr}.ds-value-item+.ds-value-item{border-left:0;border-top:1px solid var(--border,oklch(0.88 0.01 260))}.ds-footer-grid{grid-template-columns:1fr 1fr}.ds-footer-brand{grid-column:1/-1}.ds-product-layout{grid-template-columns:1fr}}
4113
+ @media (max-width:749px){.ds-section-head{display:grid}.ds-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.ds-header.ds-card{min-height:4rem}.ds-header-shell.is-sticky{position:relative}.ds-hero .ds-display{font-size:clamp(2.4rem,13vw,4rem)}.ds-image-text{grid-template-columns:1fr}.ds-footer-panel.ds-card{padding:2rem 1.25rem}.ds-footer-grid{grid-template-columns:1fr}.ds-footer-brand{grid-column:auto}.ds-footer-bottom{align-items:flex-start;flex-direction:column}.ds-cart-item.ds-card{grid-template-columns:5rem minmax(0,1fr)}.ds-cart-item img{width:5rem}.ds-cart-item .ds-media-empty{min-height:5rem}.ds-cart-item-total{grid-column:2}.ds-search-form.ds-card{grid-template-columns:1fr;width:100%}.ds-search-form .ds-btn{width:100%}}
4114
+ @media (max-width:479px){.ds-grid{grid-template-columns:1fr}.ds-header-actions{gap:.6rem}.ds-cart-link>span:first-child{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}}
4115
+ @media (pointer:coarse){.ds-mobile-nav summary,.ds-header-actions a{display:inline-flex;align-items:center;min-width:44px;min-height:44px}}
4116
+ @media (hover:hover) and (pointer:fine){.ds-header-link:hover,.ds-header-nav a:hover,.ds-footer a:hover{text-decoration:underline;text-underline-offset:.25rem}.ds-card-media img{transition:transform .3s ease}.ds-card-media:hover img{transform:scale(1.025)}}
4117
+ @media (prefers-reduced-motion:reduce){html{scroll-behavior:auto}.ds-card-media img{transition:none}}`;
3230
4118
  SNIPPET_BADGE = `{% comment %}
3231
4119
  Status label. Usage: {% render 'ds-badge', label: 'New' %}
3232
4120
  Optional tone: primary | outline | safe | warn | danger | unknown | verified
@@ -3251,7 +4139,7 @@ var init_shopify_kit = __esm({
3251
4139
  <span class="ds-price-current">{{ ds_price | money }}</span>
3252
4140
  {%- if ds_compare_at > ds_price -%}
3253
4141
  <s class="ds-price-compare">{{ ds_compare_at | money }}</s>
3254
- {% render 'ds-badge', label: 'Sale', tone: 'primary' %}
4142
+ {% render 'ds-badge', label: 'Sale', tone: 'warn' %}
3255
4143
  {%- endif -%}
3256
4144
  </span>`;
3257
4145
  SNIPPET_BUY_BUTTON = `{% comment %}
@@ -3302,6 +4190,21 @@ var init_shopify_kit = __esm({
3302
4190
  {{ paginate | default_pagination }}
3303
4191
  </nav>
3304
4192
  {%- endif -%}`;
4193
+ SNIPPET_ICON = `{% comment %}
4194
+ Iconoir storefront icons (MIT License), installed through Better Design.
4195
+ Usage: {% render 'ds-icon', icon: 'delivery' %}
4196
+ Available: quality | delivery | support | image.
4197
+ {% endcomment %}
4198
+ {%- case icon -%}
4199
+ {%- when 'quality' -%}
4200
+ <svg class="ds-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" aria-hidden="true" focusable="false"><path d="m8.5 11.5 3 3 5-5"/><path d="M5 18 3.13 4.913a.996.996 0 0 1 .774-1.114l7.662-1.703a2 2 0 0 1 .868 0L20.096 3.8c.51.113.848.596.774 1.114L19 18c-.07.495-.5 3.5-7 3.5S5.07 18.495 5 18"/></svg>
4201
+ {%- when 'delivery' -%}
4202
+ <svg class="ds-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-width="1.5" aria-hidden="true" focusable="false"><path stroke-linejoin="round" stroke-miterlimit="1.5" d="M8 19a2 2 0 1 0 0-4 2 2 0 0 0 0 4m10 0a2 2 0 1 0 0-4 2 2 0 0 0 0 4"/><path d="M10.05 17H15V6.6a.6.6 0 0 0-.6-.6H1m4.65 11H3.6a.6.6 0 0 1-.6-.6v-4.9"/><path stroke-linejoin="round" d="M2 9h4"/><path d="M15 9h5.61a.6.6 0 0 1 .548.356l1.79 4.028a.6.6 0 0 1 .052.243V16.4a.6.6 0 0 1-.6.6h-1.9M15 17h1"/></svg>
4203
+ {%- when 'support' -%}
4204
+ <svg class="ds-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" aria-hidden="true" focusable="false"><path d="M8 10h8m-8 4h4m0 8c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12c0 1.821.487 3.53 1.338 5L2.5 21.5l4.5-.838A9.96 9.96 0 0 0 12 22"/></svg>
4205
+ {%- else -%}
4206
+ <svg class="ds-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" aria-hidden="true" focusable="false"><path d="M21 3.6v16.8a.6.6 0 0 1-.6.6H3.6a.6.6 0 0 1-.6-.6V3.6a.6.6 0 0 1 .6-.6h16.8a.6.6 0 0 1 .6.6"/><path d="m3 16 7-3 11 5m-5-8a2 2 0 1 1 0-4 2 2 0 0 1 0 4"/></svg>
4207
+ {%- endcase -%}`;
3305
4208
  SNIPPET_PRODUCT_CARD = `{% comment %}
3306
4209
  Product tile for grids. Usage: {% render 'ds-product-card', product: product %}
3307
4210
  Optional: show_vendor: true, enable_quick_add: true
@@ -3311,11 +4214,14 @@ var init_shopify_kit = __esm({
3311
4214
  {%- if product.featured_image -%}
3312
4215
  {%- assign ds_image_alt = product.featured_image.alt | default: product.title -%}
3313
4216
  {{ product.featured_image | image_url: width: 900 | image_tag: widths: '360, 540, 720, 900', sizes: '(min-width: 990px) 25vw, 50vw', alt: ds_image_alt }}
4217
+ {%- else -%}
4218
+ <span class="ds-media-empty" role="img" aria-label="Image unavailable">{% render 'ds-icon', icon: 'image' %}</span>
3314
4219
  {%- endif -%}
3315
4220
  </a>
3316
4221
  <div class="ds-card-body">
3317
- {%- if show_vendor -%}<p class="ds-supporting">{{ product.vendor }}</p>{%- endif -%}
3318
4222
  <h3 class="ds-card-title"><a href="{{ product.url }}">{{ product.title }}</a></h3>
4223
+ {%- comment -%}Vendor sits under the title: a label above a heading is banned house style.{%- endcomment -%}
4224
+ {%- if show_vendor -%}<p class="ds-supporting">{{ product.vendor }}</p>{%- endif -%}
3319
4225
  {% render 'ds-price', product: product %}
3320
4226
  </div>
3321
4227
  {%- if enable_quick_add -%}
@@ -3374,17 +4280,17 @@ var init_shopify_kit = __esm({
3374
4280
  FAQ accordion using the browser's native details element. Merchants add it
3375
4281
  from the theme editor as "DS FAQ".
3376
4282
  {% endcomment %}
3377
- <div class="ds ds-embed ds-container ds-faq">
3378
- {%- if section.settings.heading != blank -%}
3379
- <h2 class="ds-section-title">{{ section.settings.heading }}</h2>
3380
- {%- endif -%}
4283
+ <section class="ds ds-embed ds-section">
4284
+ <div class="ds-container ds-faq">
4285
+ {%- if section.settings.heading != blank -%}<div class="ds-section-head"><h2 class="ds-section-title">{{ section.settings.heading }}</h2></div>{%- endif -%}
3381
4286
  {%- for block in section.blocks -%}
3382
- <details class="ds-faq-item" {{ block.shopify_attributes }}>
4287
+ <details class="ds-card ds-faq-item" {{ block.shopify_attributes }}>
3383
4288
  <summary>{{ block.settings.question }}</summary>
3384
4289
  <div class="ds-faq-answer">{{ block.settings.answer }}</div>
3385
4290
  </details>
3386
4291
  {%- endfor -%}
3387
4292
  </div>
4293
+ </section>
3388
4294
  {% schema %}
3389
4295
  {
3390
4296
  "name": "DS FAQ",
@@ -3409,19 +4315,28 @@ var init_shopify_kit = __esm({
3409
4315
  Product grid from a merchant-chosen collection. Merchants add it from the
3410
4316
  theme editor as "DS Featured collection".
3411
4317
  {% endcomment %}
3412
- {%- assign ds_collection = section.settings.collection -%}
3413
- <div class="ds ds-embed ds-container ds-featured">
3414
- {%- if section.settings.heading != blank -%}
3415
- <h2 class="ds-section-title">{{ section.settings.heading }}</h2>
3416
- {%- endif -%}
4318
+ {%- assign ds_collection = section.settings.collection | default: collections.all -%}
4319
+ {%- if ds_collection.products_count > 0 or request.design_mode -%}
4320
+ <section class="ds ds-embed ds-section">
4321
+ <div class="ds-container ds-featured">
4322
+ <div class="ds-section-head">
4323
+ {%- if section.settings.heading != blank -%}<h2 class="ds-section-title">{{ section.settings.heading }}</h2>{%- endif -%}
4324
+ {%- if section.settings.show_view_all -%}<a class="ds-header-link" href="{{ ds_collection.url | default: routes.all_products_collection_url }}">View all products</a>{%- endif -%}
4325
+ </div>
3417
4326
  <div class="ds-grid" style="--ds-cols: {{ section.settings.columns_desktop }}">
3418
4327
  {%- for product in ds_collection.products limit: section.settings.products_to_show -%}
3419
4328
  {% render 'ds-product-card', product: product, show_vendor: section.settings.show_vendor, enable_quick_add: section.settings.enable_quick_add %}
3420
4329
  {%- else -%}
3421
- <p class="ds-supporting">Choose a collection in the theme editor.</p>
4330
+ <div class="ds-card ds-empty ds-grid-empty">
4331
+ <span class="ds-icon-tile ds-icon-tile-blue">{% render 'ds-icon', icon: 'image' %}</span>
4332
+ <h3 class="ds-card-title">Add products to this collection</h3>
4333
+ <p class="ds-card-desc">Real product images, titles, and prices will appear here.</p>
4334
+ </div>
3422
4335
  {%- endfor -%}
3423
4336
  </div>
3424
4337
  </div>
4338
+ </section>
4339
+ {%- endif -%}
3425
4340
  {% schema %}
3426
4341
  {
3427
4342
  "name": "DS Featured collection",
@@ -3431,90 +4346,189 @@ var init_shopify_kit = __esm({
3431
4346
  { "type": "range", "id": "products_to_show", "min": 2, "max": 12, "step": 1, "default": 4, "label": "Products to show" },
3432
4347
  { "type": "range", "id": "columns_desktop", "min": 2, "max": 5, "step": 1, "default": 4, "label": "Columns on desktop" },
3433
4348
  { "type": "checkbox", "id": "show_vendor", "label": "Show vendor", "default": false },
3434
- { "type": "checkbox", "id": "enable_quick_add", "label": "Enable quick add", "default": false }
4349
+ { "type": "checkbox", "id": "enable_quick_add", "label": "Enable quick add", "default": false },
4350
+ { "type": "checkbox", "id": "show_view_all", "label": "Show view all link", "default": true }
3435
4351
  ],
3436
4352
  "presets": [{ "name": "DS Featured collection" }]
3437
4353
  }
3438
4354
  {% endschema %}`;
3439
4355
  SECTION_HEADER = `{% comment %}
3440
- Site header. Merchants add it to the theme's header group in the editor.
4356
+ Complete storefront header, including announcement, responsive navigation,
4357
+ search, account, and cart. The generated header group installs it globally.
3441
4358
  {% endcomment %}
3442
- <header class="ds ds-embed ds-header">
3443
- <a class="ds-header-brand" href="{{ routes.root_url }}">
3444
- {%- if section.settings.logo -%}
3445
- {{ section.settings.logo | image_url: width: 300 | image_tag: alt: shop.name, class: 'ds-header-logo' }}
3446
- {%- else -%}
3447
- {{ shop.name }}
3448
- {%- endif -%}
3449
- </a>
3450
- <nav class="ds-header-nav" aria-label="Main">
3451
- {%- for link in section.settings.menu.links -%}
3452
- <a href="{{ link.url }}"{% if link.current %} aria-current="page"{% endif %}>{{ link.title }}</a>
3453
- {%- endfor -%}
3454
- </nav>
3455
- <a class="ds-btn ds-btn-outline" href="{{ routes.cart_url }}">Cart ({{ cart.item_count }})</a>
3456
- </header>
4359
+ {%- if section.settings.announcement_text != blank -%}
4360
+ {% render 'ds-announcement', text: section.settings.announcement_text, link: section.settings.announcement_link, link_label: section.settings.announcement_link_label %}
4361
+ {%- endif -%}
4362
+ <div class="ds ds-embed ds-header-shell{% if section.settings.sticky %} is-sticky{% endif %}">
4363
+ <header class="ds-container ds-card ds-header">
4364
+ <details class="ds-mobile-nav">
4365
+ <summary class="ds-header-link" aria-label="Open navigation menu">Menu</summary>
4366
+ <nav class="ds-mobile-nav-panel" aria-label="Mobile">
4367
+ {%- for link in section.settings.menu.links -%}
4368
+ <a class="ds-header-link" href="{{ link.url }}"{% if link.current %} aria-current="page"{% endif %}>{{ link.title }}</a>
4369
+ {%- endfor -%}
4370
+ {%- if section.settings.show_search -%}<a class="ds-header-link" href="{{ routes.search_url }}">Search</a>{%- endif -%}
4371
+ {%- if shop.customer_accounts_enabled and section.settings.show_account -%}<a class="ds-header-link" href="{{ routes.account_url }}">Account</a>{%- endif -%}
4372
+ </nav>
4373
+ </details>
4374
+ <a class="ds-header-brand" href="{{ routes.root_url }}">
4375
+ {%- if section.settings.logo -%}
4376
+ {{ section.settings.logo | image_url: width: 360 | image_tag: widths: '180, 240, 360', alt: shop.name, class: 'ds-header-logo' }}
4377
+ {%- else -%}
4378
+ {{ shop.name }}
4379
+ {%- endif -%}
4380
+ </a>
4381
+ <nav class="ds-header-nav" aria-label="Main">
4382
+ {%- for link in section.settings.menu.links -%}
4383
+ <a href="{{ link.url }}"{% if link.current %} aria-current="page"{% endif %}>{{ link.title }}</a>
4384
+ {%- endfor -%}
4385
+ </nav>
4386
+ <div class="ds-header-actions">
4387
+ {%- if section.settings.show_search -%}<a class="ds-header-link" href="{{ routes.search_url }}">Search</a>{%- endif -%}
4388
+ {%- if shop.customer_accounts_enabled and section.settings.show_account -%}<a class="ds-header-link" href="{{ routes.account_url }}">Account</a>{%- endif -%}
4389
+ <a class="ds-header-link ds-cart-link" href="{{ routes.cart_url }}"><span>Cart</span><span class="ds-cart-count">{{ cart.item_count }}</span></a>
4390
+ </div>
4391
+ </header>
4392
+ </div>
3457
4393
  {% schema %}
3458
4394
  {
3459
4395
  "name": "DS Header",
3460
4396
  "settings": [
3461
4397
  { "type": "image_picker", "id": "logo", "label": "Logo" },
3462
- { "type": "link_list", "id": "menu", "label": "Menu", "default": "main-menu" }
4398
+ { "type": "link_list", "id": "menu", "label": "Menu", "default": "main-menu" },
4399
+ { "type": "text", "id": "announcement_text", "label": "Announcement", "default": "Free shipping and easy returns" },
4400
+ { "type": "text", "id": "announcement_link_label", "label": "Announcement link label" },
4401
+ { "type": "url", "id": "announcement_link", "label": "Announcement link" },
4402
+ { "type": "checkbox", "id": "show_search", "label": "Show search", "default": true },
4403
+ { "type": "checkbox", "id": "show_account", "label": "Show account", "default": true },
4404
+ { "type": "checkbox", "id": "sticky", "label": "Sticky header", "default": true }
3463
4405
  ],
3464
4406
  "presets": [{ "name": "DS Header" }]
3465
4407
  }
3466
4408
  {% endschema %}`;
3467
4409
  SECTION_FOOTER = `{% comment %}
3468
- Site footer. Merchants add it to the theme's footer group in the editor.
4410
+ Complete storefront footer with navigation, newsletter, policies, and
4411
+ payment methods. The generated footer group installs it globally.
3469
4412
  {% endcomment %}
3470
4413
  <footer class="ds ds-embed ds-footer">
3471
- <span class="ds-header-brand">{{ shop.name }}</span>
3472
- <nav aria-label="Footer">
3473
- {%- for link in section.settings.menu.links -%}
3474
- <a href="{{ link.url }}">{{ link.title }}</a>
3475
- {%- endfor -%}
3476
- </nav>
3477
- <span class="ds-supporting">&copy; {{ 'now' | date: '%Y' }} {{ shop.name }}</span>
4414
+ <div class="ds-container ds-card ds-footer-panel">
4415
+ <div class="ds-footer-grid">
4416
+ <div class="ds-footer-brand">
4417
+ <a class="ds-header-brand" href="{{ routes.root_url }}">{{ shop.name }}</a>
4418
+ {%- if section.settings.text != blank -%}<div class="ds-section-copy">{{ section.settings.text }}</div>{%- endif -%}
4419
+ {%- if section.settings.show_newsletter -%}
4420
+ {% render 'ds-newsletter', id: section.id, heading: section.settings.newsletter_heading, button_label: section.settings.newsletter_button_label %}
4421
+ {%- endif -%}
4422
+ </div>
4423
+ <div>
4424
+ <h2 class="ds-footer-title">{{ section.settings.menu_heading }}</h2>
4425
+ <nav aria-label="{{ section.settings.menu_heading | escape }}">
4426
+ {%- for link in section.settings.menu.links -%}<a href="{{ link.url }}">{{ link.title }}</a>{%- endfor -%}
4427
+ </nav>
4428
+ </div>
4429
+ <div>
4430
+ <h2 class="ds-footer-title">{{ section.settings.secondary_menu_heading }}</h2>
4431
+ <nav aria-label="{{ section.settings.secondary_menu_heading | escape }}">
4432
+ {%- for link in section.settings.secondary_menu.links -%}<a href="{{ link.url }}">{{ link.title }}</a>{%- endfor -%}
4433
+ </nav>
4434
+ </div>
4435
+ </div>
4436
+ <div class="ds-footer-bottom">
4437
+ <div>
4438
+ <p class="ds-supporting">&copy; {{ 'now' | date: '%Y' }} {{ shop.name }}</p>
4439
+ {%- if section.settings.show_policies -%}
4440
+ <ul class="ds-policy-list" role="list">
4441
+ {%- for policy in shop.policies -%}{%- if policy != blank -%}<li><a href="{{ policy.url }}">{{ policy.title }}</a></li>{%- endif -%}{%- endfor -%}
4442
+ </ul>
4443
+ {%- endif -%}
4444
+ </div>
4445
+ {%- if section.settings.show_payment_icons -%}
4446
+ <ul class="ds-payment-list" role="list" aria-label="Accepted payment methods">
4447
+ {%- for type in shop.enabled_payment_types -%}<li>{{ type | payment_type_svg_tag: class: 'ds-payment-icon' }}</li>{%- endfor -%}
4448
+ </ul>
4449
+ {%- endif -%}
4450
+ </div>
4451
+ </div>
3478
4452
  </footer>
3479
4453
  {% schema %}
3480
4454
  {
3481
4455
  "name": "DS Footer",
3482
4456
  "settings": [
3483
- { "type": "link_list", "id": "menu", "label": "Menu", "default": "footer" }
4457
+ { "type": "richtext", "id": "text", "label": "Store description", "default": "<p>Thoughtful products, clear service, and a storefront designed around what matters.</p>" },
4458
+ { "type": "text", "id": "menu_heading", "label": "Menu heading", "default": "Shop" },
4459
+ { "type": "link_list", "id": "menu", "label": "Menu", "default": "main-menu" },
4460
+ { "type": "text", "id": "secondary_menu_heading", "label": "Secondary menu heading", "default": "Help" },
4461
+ { "type": "link_list", "id": "secondary_menu", "label": "Secondary menu", "default": "footer" },
4462
+ { "type": "checkbox", "id": "show_newsletter", "label": "Show newsletter", "default": true },
4463
+ { "type": "text", "id": "newsletter_heading", "label": "Newsletter heading", "default": "Get product news and useful updates" },
4464
+ { "type": "text", "id": "newsletter_button_label", "label": "Newsletter button label", "default": "Subscribe" },
4465
+ { "type": "checkbox", "id": "show_policies", "label": "Show policies", "default": true },
4466
+ { "type": "checkbox", "id": "show_payment_icons", "label": "Show payment icons", "default": true }
3484
4467
  ],
3485
4468
  "presets": [{ "name": "DS Footer" }]
3486
4469
  }
3487
4470
  {% endschema %}`;
3488
4471
  SECTION_HERO = `{% comment %}
3489
- Hero with image, heading, supporting line, and up to 2 actions.
4472
+ Product-led hero. A selected product supplies its image, price, and link;
4473
+ otherwise the section falls back to the first all-products item.
3490
4474
  {% endcomment %}
3491
- <div class="ds ds-embed ds-container ds-hero">
3492
- {%- if section.settings.image -%}
3493
- {%- assign ds_image_alt = section.settings.heading | strip_html -%}
3494
- {{ section.settings.image | image_url: width: 1600 | image_tag: widths: '750, 1100, 1600', sizes: '100vw', alt: ds_image_alt }}
3495
- {%- endif -%}
3496
- <h2 class="ds-display">{{ section.settings.heading }}</h2>
3497
- {%- if section.settings.text != blank -%}<p class="ds-supporting">{{ section.settings.text }}</p>{%- endif -%}
3498
- <div class="ds-hero-actions">
3499
- {%- if section.settings.button_label != blank -%}
3500
- <a class="ds-btn ds-btn-primary" href="{{ section.settings.button_link }}">{{ section.settings.button_label }}</a>
3501
- {%- endif -%}
3502
- {%- if section.settings.secondary_label != blank -%}
3503
- <a class="ds-btn ds-btn-outline" href="{{ section.settings.secondary_link }}">{{ section.settings.secondary_label }}</a>
3504
- {%- endif -%}
4475
+ {%- liquid
4476
+ assign ds_product = section.settings.product
4477
+ if ds_product == blank
4478
+ assign ds_product = collections.all.products.first
4479
+ endif
4480
+ assign ds_has_hero_image = false
4481
+ if section.settings.image or ds_product.featured_image
4482
+ assign ds_has_hero_image = true
4483
+ endif
4484
+ assign ds_show_hero_media = ds_has_hero_image
4485
+ if request.design_mode
4486
+ assign ds_show_hero_media = true
4487
+ endif
4488
+ assign ds_button_link = section.settings.button_link | default: ds_product.url | default: routes.all_products_collection_url
4489
+ -%}
4490
+ <section class="ds ds-embed ds-hero-section">
4491
+ <div class="ds-container ds-hero{% unless ds_show_hero_media %} ds-hero-single{% endunless %}">
4492
+ <div class="ds-hero-content">
4493
+ {%- if request.page_type == 'index' -%}<h1 class="ds-display">{{ section.settings.heading }}</h1>{%- else -%}<h2 class="ds-display">{{ section.settings.heading }}</h2>{%- endif -%}
4494
+ {%- if section.settings.text != blank -%}<p class="ds-supporting">{{ section.settings.text }}</p>{%- endif -%}
4495
+ {%- if ds_product != blank -%}{% render 'ds-price', product: ds_product %}{%- endif -%}
4496
+ <div class="ds-hero-actions">
4497
+ {%- if section.settings.button_label != blank -%}<a class="ds-btn ds-btn-primary" href="{{ ds_button_link }}">{{ section.settings.button_label }}</a>{%- endif -%}
4498
+ {%- if section.settings.secondary_label != blank -%}<a class="ds-btn ds-btn-outline" href="{{ section.settings.secondary_link }}">{{ section.settings.secondary_label }}</a>{%- endif -%}
4499
+ </div>
4500
+ {%- if section.settings.trust_text != blank -%}<p class="ds-hero-trust">{{ section.settings.trust_text }}</p>{%- endif -%}
4501
+ </div>
4502
+ {%- if ds_show_hero_media -%}<div class="ds-hero-media{% if ds_has_hero_image %} ds-hero-media-has-image{% endif %}">
4503
+ {%- if section.settings.image -%}
4504
+ {%- assign ds_image_alt = section.settings.heading | strip_html -%}
4505
+ {{ section.settings.image | image_url: width: 1600 | image_tag: widths: '600, 900, 1200, 1600', sizes: '(min-width: 990px) 55vw, 100vw', alt: ds_image_alt }}
4506
+ {%- elsif ds_product.featured_image -%}
4507
+ {%- assign ds_image_alt = ds_product.featured_image.alt | default: ds_product.title -%}
4508
+ {{ ds_product.featured_image | image_url: width: 1600 | image_tag: widths: '600, 900, 1200, 1600', sizes: '(min-width: 990px) 55vw, 100vw', alt: ds_image_alt }}
4509
+ {%- else -%}
4510
+ <div class="ds-card ds-media-empty">
4511
+ <span class="ds-icon-tile ds-icon-tile-blue">{% render 'ds-icon', icon: 'image' %}</span>
4512
+ <p class="ds-media-empty-title">Choose a hero image</p>
4513
+ <p class="ds-media-empty-copy">Select an image or featured product in this section.</p>
4514
+ </div>
4515
+ {%- endif -%}
4516
+ </div>{%- endif -%}
3505
4517
  </div>
3506
- </div>
4518
+ </section>
3507
4519
  {% schema %}
3508
4520
  {
3509
4521
  "name": "DS Hero",
3510
4522
  "settings": [
4523
+ { "type": "product", "id": "product", "label": "Featured product" },
3511
4524
  { "type": "image_picker", "id": "image", "label": "Image" },
3512
- { "type": "inline_richtext", "id": "heading", "label": "Heading", "default": "Tell your story" },
3513
- { "type": "text", "id": "text", "label": "Supporting line" },
3514
- { "type": "text", "id": "button_label", "label": "Button label", "default": "Shop now" },
4525
+ { "type": "inline_richtext", "id": "heading", "label": "Heading", "default": "Made for every day" },
4526
+ { "type": "text", "id": "text", "label": "Supporting line", "default": "Useful products, thoughtful details, and clear service." },
4527
+ { "type": "text", "id": "button_label", "label": "Button label", "default": "Shop the collection" },
3515
4528
  { "type": "url", "id": "button_link", "label": "Button link" },
3516
4529
  { "type": "text", "id": "secondary_label", "label": "Secondary button label" },
3517
- { "type": "url", "id": "secondary_link", "label": "Secondary button link" }
4530
+ { "type": "url", "id": "secondary_link", "label": "Secondary button link" },
4531
+ { "type": "text", "id": "trust_text", "label": "Trust line", "default": "Secure checkout. Easy returns. Human support." }
3518
4532
  ],
3519
4533
  "presets": [{ "name": "DS Hero" }]
3520
4534
  }
@@ -3522,13 +4536,15 @@ var init_shopify_kit = __esm({
3522
4536
  SECTION_RICH_TEXT = `{% comment %}
3523
4537
  Heading plus rich text, with an optional button.
3524
4538
  {% endcomment %}
3525
- <div class="ds ds-embed ds-container" style="padding:2rem 0">
4539
+ <section class="ds ds-embed ds-section">
4540
+ <div class="ds-container ds-rich-text">
3526
4541
  {%- if section.settings.heading != blank -%}<h2 class="ds-section-title">{{ section.settings.heading }}</h2>{%- endif -%}
3527
- <div>{{ section.settings.text }}</div>
4542
+ <div class="ds-section-copy">{{ section.settings.text }}</div>
3528
4543
  {%- if section.settings.button_label != blank -%}
3529
- <p><a class="ds-btn ds-btn-secondary" href="{{ section.settings.button_link }}">{{ section.settings.button_label }}</a></p>
4544
+ <a class="ds-btn ds-btn-secondary" href="{{ section.settings.button_link }}">{{ section.settings.button_label }}</a>
3530
4545
  {%- endif -%}
3531
4546
  </div>
4547
+ </section>
3532
4548
  {% schema %}
3533
4549
  {
3534
4550
  "name": "DS Rich text",
@@ -3544,23 +4560,38 @@ var init_shopify_kit = __esm({
3544
4560
  SECTION_IMAGE_WITH_TEXT = `{% comment %}
3545
4561
  Image beside heading, text, and an optional button.
3546
4562
  {% endcomment %}
3547
- <div class="ds ds-embed ds-container ds-image-text">
4563
+ {%- assign ds_story_product = section.settings.product | default: collections.all.products[1] | default: collections.all.products.first -%}
4564
+ <section class="ds ds-embed ds-section">
4565
+ <div class="ds-container ds-image-text{% unless section.settings.image or ds_story_product.featured_image or request.design_mode %} ds-image-text-single{% endunless %}">
4566
+ {%- if section.settings.image or ds_story_product.featured_image or request.design_mode -%}<div class="ds-card ds-image-text-media">
3548
4567
  {%- if section.settings.image -%}
3549
4568
  {%- assign ds_image_alt = section.settings.heading | strip_html -%}
3550
4569
  {{ section.settings.image | image_url: width: 900 | image_tag: widths: '480, 720, 900', sizes: '(min-width: 750px) 50vw, 100vw', alt: ds_image_alt }}
4570
+ {%- elsif ds_story_product.featured_image -%}
4571
+ {%- assign ds_image_alt = ds_story_product.featured_image.alt | default: ds_story_product.title -%}
4572
+ {{ ds_story_product.featured_image | image_url: width: 1200 | image_tag: widths: '480, 720, 900, 1200', sizes: '(min-width: 750px) 50vw, 100vw', alt: ds_image_alt }}
4573
+ {%- else -%}
4574
+ <div class="ds-media-empty">
4575
+ <span class="ds-icon-tile ds-icon-tile-orange">{% render 'ds-icon', icon: 'image' %}</span>
4576
+ <p class="ds-media-empty-title">Choose a story image</p>
4577
+ <p class="ds-media-empty-copy">Select an image or product for this section.</p>
4578
+ </div>
3551
4579
  {%- endif -%}
4580
+ </div>{%- endif -%}
3552
4581
  <div class="ds-product-info">
3553
4582
  <h2 class="ds-section-title">{{ section.settings.heading }}</h2>
3554
- <div>{{ section.settings.text }}</div>
4583
+ <div class="ds-section-copy">{{ section.settings.text }}</div>
3555
4584
  {%- if section.settings.button_label != blank -%}
3556
4585
  <p><a class="ds-btn ds-btn-primary" href="{{ section.settings.button_link }}">{{ section.settings.button_label }}</a></p>
3557
4586
  {%- endif -%}
3558
4587
  </div>
3559
4588
  </div>
4589
+ </section>
3560
4590
  {% schema %}
3561
4591
  {
3562
4592
  "name": "DS Image with text",
3563
4593
  "settings": [
4594
+ { "type": "product", "id": "product", "label": "Product image fallback" },
3564
4595
  { "type": "image_picker", "id": "image", "label": "Image" },
3565
4596
  { "type": "inline_richtext", "id": "heading", "label": "Heading", "default": "Pair text with an image" },
3566
4597
  { "type": "richtext", "id": "text", "label": "Text" },
@@ -3577,10 +4608,14 @@ var init_shopify_kit = __esm({
3577
4608
  <div class="ds ds-embed ds-container ds-product-page">
3578
4609
  <div class="ds-product-layout">
3579
4610
  <div class="ds-product-media">
4611
+ <div class="ds-card ds-product-media-main">
3580
4612
  {%- if product.featured_image -%}
3581
4613
  {%- assign ds_image_alt = product.featured_image.alt | default: product.title -%}
3582
4614
  {{ product.featured_image | image_url: width: 1200 | image_tag: widths: '480, 720, 960, 1200', sizes: '(min-width: 750px) 50vw, 100vw', alt: ds_image_alt }}
4615
+ {%- else -%}
4616
+ <div class="ds-media-empty" role="img" aria-label="Product image unavailable">{% render 'ds-icon', icon: 'image' %}</div>
3583
4617
  {%- endif -%}
4618
+ </div>
3584
4619
  {%- if product.images.size > 1 -%}
3585
4620
  <div class="ds-product-thumbs">
3586
4621
  {%- for image in product.images offset: 1 limit: 4 -%}
@@ -3590,12 +4625,13 @@ var init_shopify_kit = __esm({
3590
4625
  </div>
3591
4626
  {%- endif -%}
3592
4627
  </div>
3593
- <div class="ds-product-info">
3594
- {%- if section.settings.show_vendor -%}<p class="ds-supporting">{{ product.vendor }}</p>{%- endif -%}
4628
+ <div class="ds-card ds-product-info">
3595
4629
  <h1 class="ds-display">{{ product.title }}</h1>
4630
+ {%- comment -%}Vendor sits under the title: a label above a heading is banned house style.{%- endcomment -%}
4631
+ {%- if section.settings.show_vendor -%}<p class="ds-supporting">{{ product.vendor }}</p>{%- endif -%}
3596
4632
  {% render 'ds-price', variant: product.selected_or_first_available_variant %}
3597
4633
  {% render 'ds-buy-button', product: product, show_quantity: section.settings.show_quantity %}
3598
- <div>{{ product.description }}</div>
4634
+ <div class="ds-product-description">{{ product.description }}</div>
3599
4635
  </div>
3600
4636
  </div>
3601
4637
  </div>
@@ -3615,9 +4651,11 @@ var init_shopify_kit = __esm({
3615
4651
  {% endcomment %}
3616
4652
  {% paginate collection.products by section.settings.products_per_page %}
3617
4653
  <div class="ds ds-embed ds-container ds-collection-page">
3618
- <h1 class="ds-display">{{ collection.title }}</h1>
3619
- {%- if collection.description != blank -%}<div class="ds-supporting">{{ collection.description }}</div>{%- endif -%}
3620
- <div class="ds-grid" style="--ds-cols: {{ section.settings.columns_desktop }}; margin-top: 1.5rem">
4654
+ <div class="ds-collection-head">
4655
+ <h1 class="ds-display">{{ collection.title }}</h1>
4656
+ {%- if collection.description != blank -%}<div class="ds-section-copy">{{ collection.description }}</div>{%- endif -%}
4657
+ </div>
4658
+ <div class="ds-grid" style="--ds-cols: {{ section.settings.columns_desktop }}">
3621
4659
  {%- for product in collection.products -%}
3622
4660
  {% render 'ds-product-card', product: product, show_vendor: section.settings.show_vendor, enable_quick_add: section.settings.enable_quick_add %}
3623
4661
  {%- else -%}
@@ -3642,9 +4680,10 @@ var init_shopify_kit = __esm({
3642
4680
  SECTION_CONTACT = `{% comment %}
3643
4681
  Contact form posting to Shopify's contact endpoint. No JavaScript.
3644
4682
  {% endcomment %}
3645
- <div class="ds ds-embed ds-container" style="max-width:36rem;padding:2rem 0">
4683
+ <section class="ds ds-embed ds-section">
4684
+ <div class="ds-container ds-page">
3646
4685
  <h2 class="ds-section-title">{{ section.settings.heading }}</h2>
3647
- {% form 'contact' %}
4686
+ {% form 'contact', class: 'ds-card ds-contact-form' %}
3648
4687
  {%- if form.posted_successfully? -%}
3649
4688
  <p role="status">Thanks for your message. We reply within one business day.</p>
3650
4689
  {%- else -%}
@@ -3665,6 +4704,7 @@ var init_shopify_kit = __esm({
3665
4704
  {%- endif -%}
3666
4705
  {% endform %}
3667
4706
  </div>
4707
+ </section>
3668
4708
  {% schema %}
3669
4709
  {
3670
4710
  "name": "DS Contact",
@@ -3677,19 +4717,22 @@ var init_shopify_kit = __esm({
3677
4717
  SECTION_MULTICOLUMN = `{% comment %}
3678
4718
  Row of text cards, for value props or feature callouts.
3679
4719
  {% endcomment %}
3680
- <div class="ds ds-embed ds-container" style="padding:2rem 0">
3681
- {%- if section.settings.heading != blank -%}<h2 class="ds-section-title">{{ section.settings.heading }}</h2>{%- endif -%}
3682
- <div class="ds-grid" style="--ds-cols: {{ section.settings.columns_desktop }}">
4720
+ <section class="ds ds-embed ds-section-compact">
4721
+ <div class="ds-container">
4722
+ {%- if section.settings.heading != blank -%}<div class="ds-section-head"><h2 class="ds-section-title">{{ section.settings.heading }}</h2></div>{%- endif -%}
4723
+ <div class="ds-card ds-value-panel" style="--ds-cols: {{ section.settings.columns_desktop }}">
3683
4724
  {%- for block in section.blocks -%}
3684
- <div class="ds-card" {{ block.shopify_attributes }}>
3685
- <div class="ds-card-body">
3686
- <h3 class="ds-card-title">{{ block.settings.heading }}</h3>
4725
+ <div class="ds-value-item" {{ block.shopify_attributes }}>
4726
+ <div class="ds-value-title-row">
4727
+ {%- if block.settings.icon != 'none' -%}<span class="ds-icon-tile {% cycle 'ds-icon-tile-blue', 'ds-icon-tile-orange', 'ds-icon-tile-green' %}">{% render 'ds-icon', icon: block.settings.icon %}</span>{%- endif -%}
4728
+ <h3 class="ds-card-title">{{ block.settings.heading }}</h3>
4729
+ </div>
3687
4730
  <p class="ds-card-desc">{{ block.settings.text }}</p>
3688
- </div>
3689
4731
  </div>
3690
4732
  {%- endfor -%}
3691
4733
  </div>
3692
4734
  </div>
4735
+ </section>
3693
4736
  {% schema %}
3694
4737
  {
3695
4738
  "name": "DS Multicolumn",
@@ -3702,6 +4745,7 @@ var init_shopify_kit = __esm({
3702
4745
  "type": "column",
3703
4746
  "name": "Column",
3704
4747
  "settings": [
4748
+ { "type": "select", "id": "icon", "label": "Icon", "default": "quality", "options": [{ "value": "quality", "label": "Quality" }, { "value": "delivery", "label": "Delivery" }, { "value": "support", "label": "Support" }, { "value": "none", "label": "None" }] },
3705
4749
  { "type": "text", "id": "heading", "label": "Heading" },
3706
4750
  { "type": "text", "id": "text", "label": "Text" }
3707
4751
  ]
@@ -3711,27 +4755,231 @@ var init_shopify_kit = __esm({
3711
4755
  "presets": [{ "name": "DS Multicolumn", "blocks": [{ "type": "column" }, { "type": "column" }, { "type": "column" }] }]
3712
4756
  }
3713
4757
  {% endschema %}`;
3714
- TEMPLATE_PRODUCT_JSON = `{
3715
- "sections": {
3716
- "main": {
3717
- "type": "ds-main-product",
3718
- "settings": {}
3719
- }
3720
- },
3721
- "order": ["main"]
3722
- }`;
3723
- TEMPLATE_COLLECTION_JSON = `{
3724
- "sections": {
3725
- "main": {
3726
- "type": "ds-main-collection",
3727
- "settings": {}
3728
- }
4758
+ SECTION_MAIN_PAGE = `{% comment %}
4759
+ Default content page, styled by the selected design system.
4760
+ {% endcomment %}
4761
+ <main class="ds ds-embed ds-container ds-page">
4762
+ <header class="ds-page-head"><h1 class="ds-display">{{ page.title }}</h1></header>
4763
+ <div class="ds-card ds-page-content ds-product-description">{{ page.content }}</div>
4764
+ </main>
4765
+ {% schema %}
4766
+ {
4767
+ "name": "DS Page",
4768
+ "enabled_on": { "templates": ["page"] }
4769
+ }
4770
+ {% endschema %}`;
4771
+ SECTION_MAIN_CART = `{% comment %}
4772
+ Native cart page. Quantity changes post through Shopify without JavaScript.
4773
+ {% endcomment %}
4774
+ <main class="ds ds-embed ds-container ds-cart-page">
4775
+ <header class="ds-page-head"><h1 class="ds-display">Your cart</h1></header>
4776
+ {%- if cart.item_count > 0 -%}
4777
+ <form action="{{ routes.cart_url }}" method="post">
4778
+ <ul class="ds-cart-list" role="list">
4779
+ {%- for item in cart.items -%}
4780
+ <li class="ds-card ds-cart-item">
4781
+ <a href="{{ item.url }}" aria-label="{{ item.product.title | escape }}">
4782
+ {%- if item.image -%}
4783
+ {%- assign ds_cart_alt = item.image.alt | default: item.product.title -%}
4784
+ {{ item.image | image_url: width: 280 | image_tag: widths: '140, 210, 280', alt: ds_cart_alt }}
4785
+ {%- else -%}
4786
+ <span class="ds-media-empty" role="img" aria-label="Image unavailable">{% render 'ds-icon', icon: 'image' %}</span>
4787
+ {%- endif -%}
4788
+ </a>
4789
+ <div>
4790
+ <h2 class="ds-card-title"><a href="{{ item.url }}">{{ item.product.title }}</a></h2>
4791
+ {%- unless item.product.has_only_default_variant -%}<p class="ds-supporting">{{ item.variant.title }}</p>{%- endunless -%}
4792
+ <label class="ds-label" for="ds-cart-quantity-{{ item.index | plus: 1 }}">Quantity</label>
4793
+ <input class="ds-input ds-qty" id="ds-cart-quantity-{{ item.index | plus: 1 }}" type="number" name="updates[]" value="{{ item.quantity }}" min="0">
4794
+ <a class="ds-header-link" href="{{ item.url_to_remove }}">Remove</a>
4795
+ </div>
4796
+ <strong class="ds-cart-item-total">{{ item.final_line_price | money }}</strong>
4797
+ </li>
4798
+ {%- endfor -%}
4799
+ </ul>
4800
+ <div class="ds-card ds-cart-summary">
4801
+ <p class="ds-supporting">Taxes and shipping calculated at checkout.</p>
4802
+ <div class="ds-price"><span>Subtotal</span><span class="ds-price-current">{{ cart.total_price | money }}</span></div>
4803
+ <div class="ds-hero-actions">
4804
+ <button class="ds-btn ds-btn-outline" type="submit" name="update">Update cart</button>
4805
+ <button class="ds-btn ds-btn-primary" type="submit" name="checkout">Checkout</button>
4806
+ </div>
4807
+ </div>
4808
+ </form>
4809
+ {%- else -%}
4810
+ <div class="ds-card ds-empty"><p class="ds-section-copy">Your cart is empty.</p><a class="ds-btn ds-btn-primary" href="{{ routes.all_products_collection_url }}">Continue shopping</a></div>
4811
+ {%- endif -%}
4812
+ </main>
4813
+ {% schema %}
4814
+ {
4815
+ "name": "DS Cart",
4816
+ "enabled_on": { "templates": ["cart"] }
4817
+ }
4818
+ {% endschema %}`;
4819
+ SECTION_MAIN_SEARCH = `{% comment %}
4820
+ Search page with native Shopify query handling and paginated results.
4821
+ {% endcomment %}
4822
+ <main class="ds ds-embed ds-container ds-search-page">
4823
+ <header class="ds-page-head"><h1 class="ds-display">Search</h1></header>
4824
+ <form class="ds-card ds-search-form" action="{{ routes.search_url }}" method="get" role="search">
4825
+ <div>
4826
+ <label class="ds-label" for="ds-search-query-{{ section.id }}">What are you looking for?</label>
4827
+ <input class="ds-input" id="ds-search-query-{{ section.id }}" type="search" name="q" value="{{ search.terms | escape }}" autocomplete="off">
4828
+ </div>
4829
+ <button class="ds-btn ds-btn-primary" type="submit">Search</button>
4830
+ </form>
4831
+ {%- if search.performed -%}
4832
+ {% paginate search.results by section.settings.results_per_page %}
4833
+ <div class="ds-section-head"><h2 class="ds-section-title">{{ search.results_count }} results</h2></div>
4834
+ <div class="ds-grid" style="--ds-cols: {{ section.settings.columns_desktop }}">
4835
+ {%- for item in search.results -%}
4836
+ {%- if item.object_type == 'product' -%}
4837
+ {% render 'ds-product-card', product: item %}
4838
+ {%- else -%}
4839
+ <article class="ds-card"><div class="ds-card-body"><h3 class="ds-card-title"><a href="{{ item.url }}">{{ item.title }}</a></h3><p class="ds-card-desc">{{ item.content | strip_html | truncatewords: 24 }}</p></div></article>
4840
+ {%- endif -%}
4841
+ {%- else -%}
4842
+ <div class="ds-card ds-empty ds-grid-empty"><p class="ds-section-copy">No results found. Try a shorter or more general search.</p><a class="ds-btn ds-btn-outline" href="{{ routes.all_products_collection_url }}">Browse all products</a></div>
4843
+ {%- endfor -%}
4844
+ </div>
4845
+ {% render 'ds-pagination', paginate: paginate %}
4846
+ {% endpaginate %}
4847
+ {%- endif -%}
4848
+ </main>
4849
+ {% schema %}
4850
+ {
4851
+ "name": "DS Search",
4852
+ "settings": [
4853
+ { "type": "range", "id": "results_per_page", "min": 4, "max": 24, "step": 4, "default": 12, "label": "Results per page" },
4854
+ { "type": "range", "id": "columns_desktop", "min": 2, "max": 4, "step": 1, "default": 4, "label": "Columns on desktop" }
4855
+ ],
4856
+ "enabled_on": { "templates": ["search"] }
4857
+ }
4858
+ {% endschema %}`;
4859
+ SECTION_MAIN_404 = `{% comment %}
4860
+ Compact not-found page with a clear recovery action.
4861
+ {% endcomment %}
4862
+ <main class="ds ds-embed ds-container ds-page">
4863
+ <div class="ds-card ds-empty">
4864
+ <h1 class="ds-display">Page not found</h1>
4865
+ <p class="ds-section-copy">The page may have moved or the link may be incorrect.</p>
4866
+ <a class="ds-btn ds-btn-primary" href="{{ routes.all_products_collection_url }}">Browse all products</a>
4867
+ </div>
4868
+ </main>
4869
+ {% schema %}
4870
+ {
4871
+ "name": "DS 404",
4872
+ "enabled_on": { "templates": ["404"] }
4873
+ }
4874
+ {% endschema %}`;
4875
+ TEMPLATE_INDEX_JSON = `{
4876
+ "sections": {
4877
+ "hero": {
4878
+ "type": "ds-hero",
4879
+ "settings": {
4880
+ "heading": "Made for every day",
4881
+ "text": "Useful products, thoughtful details, and clear service.",
4882
+ "button_label": "Shop the collection",
4883
+ "button_link": "shopify://collections/all",
4884
+ "trust_text": "Secure checkout. Easy returns. Human support."
4885
+ }
4886
+ },
4887
+ "featured": {
4888
+ "type": "ds-featured-collection",
4889
+ "settings": { "heading": "Featured products", "collection": "all", "products_to_show": 4, "columns_desktop": 4, "show_view_all": true }
4890
+ },
4891
+ "story": {
4892
+ "type": "ds-image-with-text",
4893
+ "settings": {
4894
+ "heading": "Useful by design",
4895
+ "text": "<p>Every product should earn its place. Use this section to explain the materials, choices, or details that make yours worth choosing.</p>",
4896
+ "button_label": "Explore the collection",
4897
+ "button_link": "shopify://collections/all"
4898
+ }
4899
+ },
4900
+ "values": {
4901
+ "type": "ds-multicolumn",
4902
+ "blocks": {
4903
+ "quality": { "type": "column", "settings": { "icon": "quality", "heading": "Made to last", "text": "Explain the quality customers can see and feel." } },
4904
+ "delivery": { "type": "column", "settings": { "icon": "delivery", "heading": "Clear delivery", "text": "Set useful expectations for shipping and returns." } },
4905
+ "support": { "type": "column", "settings": { "icon": "support", "heading": "Human support", "text": "Tell customers where to get a real answer." } }
4906
+ },
4907
+ "block_order": ["quality", "delivery", "support"],
4908
+ "settings": { "heading": "What you can expect", "columns_desktop": 3 }
4909
+ },
4910
+ "faq": {
4911
+ "type": "ds-faq",
4912
+ "blocks": {
4913
+ "shipping": { "type": "item", "settings": { "question": "When will my order arrive?", "answer": "<p>Add your delivery timing and tracking details here.</p>" } },
4914
+ "returns": { "type": "item", "settings": { "question": "What is your return policy?", "answer": "<p>Summarize the return window and the first step for starting a return.</p>" } },
4915
+ "support": { "type": "item", "settings": { "question": "How can I contact you?", "answer": "<p>Add the fastest support channel and expected reply time.</p>" } }
4916
+ },
4917
+ "block_order": ["shipping", "returns", "support"],
4918
+ "settings": { "heading": "Questions, answered" }
4919
+ }
4920
+ },
4921
+ "order": ["hero", "featured", "story", "values", "faq"]
4922
+ }`;
4923
+ TEMPLATE_PRODUCT_JSON = `{
4924
+ "sections": {
4925
+ "main": {
4926
+ "type": "ds-main-product",
4927
+ "settings": {}
4928
+ }
4929
+ },
4930
+ "order": ["main"]
4931
+ }`;
4932
+ TEMPLATE_PAGE_JSON = `{
4933
+ "sections": { "main": { "type": "ds-main-page", "settings": {} } },
4934
+ "order": ["main"]
4935
+ }`;
4936
+ TEMPLATE_CART_JSON = `{
4937
+ "sections": { "main": { "type": "ds-main-cart", "settings": {} } },
4938
+ "order": ["main"]
4939
+ }`;
4940
+ TEMPLATE_SEARCH_JSON = `{
4941
+ "sections": { "main": { "type": "ds-main-search", "settings": {} } },
4942
+ "order": ["main"]
4943
+ }`;
4944
+ TEMPLATE_404_JSON = `{
4945
+ "sections": { "main": { "type": "ds-main-404", "settings": {} } },
4946
+ "order": ["main"]
4947
+ }`;
4948
+ HEADER_GROUP_JSON = `{
4949
+ "name": "Header group",
4950
+ "type": "header",
4951
+ "sections": {
4952
+ "ds_header": {
4953
+ "type": "ds-header",
4954
+ "settings": { "menu": "main-menu", "announcement_text": "Free shipping and easy returns", "show_search": true, "show_account": true, "sticky": true }
4955
+ }
4956
+ },
4957
+ "order": ["ds_header"]
4958
+ }`;
4959
+ FOOTER_GROUP_JSON = `{
4960
+ "name": "Footer group",
4961
+ "type": "footer",
4962
+ "sections": {
4963
+ "ds_footer": {
4964
+ "type": "ds-footer",
4965
+ "settings": { "menu": "main-menu", "secondary_menu": "footer", "show_newsletter": true, "show_policies": true, "show_payment_icons": true }
4966
+ }
4967
+ },
4968
+ "order": ["ds_footer"]
4969
+ }`;
4970
+ TEMPLATE_COLLECTION_JSON = `{
4971
+ "sections": {
4972
+ "main": {
4973
+ "type": "ds-main-collection",
4974
+ "settings": {}
4975
+ }
3729
4976
  },
3730
4977
  "order": ["main"]
3731
4978
  }`;
3732
4979
  SNIPPETS = [
3733
4980
  { path: "snippets/ds-badge.liquid", content: SNIPPET_BADGE },
3734
4981
  { path: "snippets/ds-price.liquid", content: SNIPPET_PRICE },
4982
+ { path: "snippets/ds-icon.liquid", content: SNIPPET_ICON },
3735
4983
  { path: "snippets/ds-variant-select.liquid", content: SNIPPET_VARIANT_SELECT },
3736
4984
  { path: "snippets/ds-buy-button.liquid", content: SNIPPET_BUY_BUTTON },
3737
4985
  { path: "snippets/ds-product-card.liquid", content: SNIPPET_PRODUCT_CARD },
@@ -3749,15 +4997,32 @@ var init_shopify_kit = __esm({
3749
4997
  { path: "sections/ds-featured-collection.liquid", content: SECTION_FEATURED_COLLECTION },
3750
4998
  { path: "sections/ds-main-product.liquid", content: SECTION_MAIN_PRODUCT },
3751
4999
  { path: "sections/ds-main-collection.liquid", content: SECTION_MAIN_COLLECTION },
5000
+ { path: "sections/ds-main-page.liquid", content: SECTION_MAIN_PAGE },
5001
+ { path: "sections/ds-main-cart.liquid", content: SECTION_MAIN_CART },
5002
+ { path: "sections/ds-main-search.liquid", content: SECTION_MAIN_SEARCH },
5003
+ { path: "sections/ds-main-404.liquid", content: SECTION_MAIN_404 },
5004
+ { path: "sections/header-group.json", content: HEADER_GROUP_JSON },
5005
+ { path: "sections/footer-group.json", content: FOOTER_GROUP_JSON },
5006
+ { path: "templates/index.json", content: TEMPLATE_INDEX_JSON },
5007
+ { path: "templates/product.json", content: TEMPLATE_PRODUCT_JSON },
5008
+ { path: "templates/collection.json", content: TEMPLATE_COLLECTION_JSON },
5009
+ { path: "templates/page.json", content: TEMPLATE_PAGE_JSON },
5010
+ { path: "templates/cart.json", content: TEMPLATE_CART_JSON },
5011
+ { path: "templates/search.json", content: TEMPLATE_SEARCH_JSON },
5012
+ { path: "templates/404.json", content: TEMPLATE_404_JSON },
3752
5013
  { path: "templates/product.ds.json", content: TEMPLATE_PRODUCT_JSON },
3753
5014
  { path: "templates/collection.ds.json", content: TEMPLATE_COLLECTION_JSON }
3754
5015
  ];
3755
5016
  SHOPIFY_KIT_CONDUCT_MD = [
3756
5017
  "## Install into the merchant's theme (mandatory order)",
3757
- "1. Write `assets/ds-kit.css` into the theme's `assets/` directory, every `snippets/ds-*.liquid` file into `snippets/`, every `sections/ds-*.liquid` file into `sections/`, and the `templates/*.ds.json` files into `templates/`. Copy file contents VERBATIM; retyped Liquid silently drops this system's material.",
3758
- "2. Load the stylesheet once in `layout/theme.liquid`, before `</head>`: `{{ 'ds-kit.css' | asset_url | stylesheet_tag }}`.",
3759
- '3. Merchants place everything from the theme editor: "DS Header" and "DS Footer" go into the theme\'s header and footer section groups; "DS Hero", "DS Featured collection", "DS Multicolumn", "DS Image with text", "DS Rich text", "DS Contact", and "DS FAQ" appear in the Add section list on any page. Any snippet also works from the built-in Custom Liquid section with one `{% render %}` line.',
3760
- "4. For fully branded product and collection pages, assign the `product.ds` and `collection.ds` templates to products and collections in the admin. They render `ds-main-product` and `ds-main-collection`, which include a native no-JavaScript variant dropdown and pagination.",
5018
+ "1. Duplicate the merchant's theme and work only in the unpublished copy. This full-store kit replaces the default header/footer groups and primary storefront templates; never install it directly on the live theme.",
5019
+ "2. Write every returned file to its exact path. That includes the CSS asset, snippets, Liquid sections, `sections/header-group.json`, `sections/footer-group.json`, and the primary `templates/*.json` files. Copy contents VERBATIM; retyped Liquid silently drops this system's material.",
5020
+ "3. Load the stylesheet once in `layout/theme.liquid`, before `</head>`: `{{ 'ds-kit.css' | asset_url | stylesheet_tag }}`.",
5021
+ "4. In `layout/theme.liquid`, confirm the theme renders `{% sections 'header-group' %}` and `{% sections 'footer-group' %}`; the generated group files become global only through those calls.",
5022
+ " - If the theme uses different group names, rename the generated group files and call them with `{% sections '<group-name>' %}`.",
5023
+ " - If it renders header/footer sections directly, replace those calls with the generated group calls, or use `{% section 'ds-header' %}` and `{% section 'ds-footer' %}` without group-editor support.",
5024
+ "5. The generated index, product, collection, page, cart, search, and 404 templates make the selected system the storefront default; `product.ds` and `collection.ds` remain available as alternate templates for compatibility.",
5025
+ "6. Open the unpublished theme editor. Select a featured product or hero image, replace the starter copy, verify menus and policies, then review desktop and mobile before publishing.",
3761
5026
  "",
3762
5027
  "## Class contract (pass/fail)",
3763
5028
  "- Do not rename `ds-*` classes and do not strip the `ds ds-embed` wrapper a snippet renders on its root; the wrapper scopes this system's tokens and keeps the background transparent inside existing theme sections.",
@@ -3777,6 +5042,43 @@ var init_shopify_kit = __esm({
3777
5042
  }
3778
5043
  });
3779
5044
 
5045
+ // ../shared/src/registry-css.ts
5046
+ function parseCssVars(css) {
5047
+ const out = { theme: {}, light: {}, dark: {} };
5048
+ const matchBlock = (selector) => {
5049
+ const re = new RegExp(`${selector}\\s*\\{([^}]*)\\}`, "g");
5050
+ const blocks = [];
5051
+ let m;
5052
+ while (m = re.exec(css)) blocks.push(m[1]);
5053
+ return blocks.join("\n");
5054
+ };
5055
+ const extract = (body) => {
5056
+ const result = {};
5057
+ const clean = body.replace(/\/\*[\s\S]*?\*\//g, "");
5058
+ const declRe = /--([a-zA-Z0-9_-]+)\s*:\s*([^;]+);/g;
5059
+ let m;
5060
+ while (m = declRe.exec(clean)) {
5061
+ result[m[1]] = m[2].replace(/\s+/g, " ").trim();
5062
+ }
5063
+ return result;
5064
+ };
5065
+ out.light = extract(matchBlock(":root"));
5066
+ out.dark = extract(matchBlock("\\.dark"));
5067
+ return out;
5068
+ }
5069
+ function mirrorSingleTheme(vars) {
5070
+ const hasLight = Object.keys(vars.light).length > 0;
5071
+ const hasDark = Object.keys(vars.dark).length > 0;
5072
+ if (hasLight && !hasDark) return { ...vars, dark: { ...vars.light } };
5073
+ if (hasDark && !hasLight) return { ...vars, light: { ...vars.dark } };
5074
+ return vars;
5075
+ }
5076
+ var init_registry_css = __esm({
5077
+ "../shared/src/registry-css.ts"() {
5078
+ "use strict";
5079
+ }
5080
+ });
5081
+
3780
5082
  // ../shared/src/mcp/preview-widget-sdk.generated.ts
3781
5083
  var PREVIEW_WIDGET_SDK_JS;
3782
5084
  var init_preview_widget_sdk_generated = __esm({
@@ -4441,6 +5743,26 @@ function decodeArbitrary(raw) {
4441
5743
  const v = raw.replace(/(?<!\\)_/g, " ").replace(/\\_/g, "_").trim();
4442
5744
  return v && !/[{}<>;]/.test(v) ? v : null;
4443
5745
  }
5746
+ function ringWidthRule(width) {
5747
+ return `--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(${width} + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:${RING_BOX_SHADOW}`;
5748
+ }
5749
+ function inferArbitraryKind(value) {
5750
+ if (IMAGE_FUNCTIONS.test(value)) return "image";
5751
+ if (COLOR_FUNCTIONS.test(value)) return "color";
5752
+ if (LENGTH_VALUE.test(value) || MATH_VALUE.test(value)) return "length";
5753
+ if (/^-?(?:\d*\.)?\d+$/.test(value)) return "number";
5754
+ return null;
5755
+ }
5756
+ function resolveArbitraryDeclaration(prefix, rawValue) {
5757
+ const spec = ARBITRARY_KINDS[prefix];
5758
+ if (!spec) return null;
5759
+ const hinted = rawValue.match(/^([a-z][a-z-]*):([\s\S]+)$/i);
5760
+ const hintKind = hinted ? HINT_KIND[hinted[1].toLowerCase()] : void 0;
5761
+ const value = hintKind ? hinted[2].trim() : rawValue;
5762
+ const kind = hintKind ?? inferArbitraryKind(value);
5763
+ const property = kind ? spec.props[kind] : void 0;
5764
+ return property ? { property, value, kind } : { property: spec.fallback, value, kind: spec.fallbackKind };
5765
+ }
4444
5766
  function animateModifier(base) {
4445
5767
  const spin = {
4446
5768
  "animate-spin": "animation:spin 1s linear infinite",
@@ -4506,13 +5828,23 @@ function translateBase(base) {
4506
5828
  if (arb) {
4507
5829
  const val = decodeArbitrary(arb[2]);
4508
5830
  if (val) {
5831
+ const withAlpha = (v) => arb[3] ? `color-mix(in oklab, ${v} ${arb[3]}%, transparent)` : v;
5832
+ const resolved = resolveArbitraryDeclaration(arb[1], val);
5833
+ if (resolved?.property === "--tw-ring-width") return ringWidthRule(resolved.value);
5834
+ if (resolved) {
5835
+ const value = resolved.kind === "color" ? withAlpha(resolved.value) : resolved.value;
5836
+ return `${resolved.property}:${value}`;
5837
+ }
5838
+ const composed = ARBITRARY_COMPOSED[arb[1]];
5839
+ if (composed) return composed(val);
5840
+ const multi = ARBITRARY_PROPS_MULTI[arb[1]];
5841
+ if (multi) return multi.map((p) => `${p}:${val}`).join(";");
4509
5842
  const prop = ARBITRARY_PROP[arb[1]];
4510
5843
  if (prop) return `${prop}:${val}`;
4511
5844
  const colorProp = COLOR_PROP[arb[1]];
4512
- if (colorProp) {
4513
- const v = arb[3] ? `color-mix(in oklab, ${val} ${arb[3]}%, transparent)` : val;
4514
- return `${colorProp}:${v}`;
4515
- }
5845
+ if (colorProp) return `${colorProp}:${withAlpha(val)}`;
5846
+ const negated = arb[1].startsWith("-") ? ARBITRARY_PROP[arb[1].slice(1)] : void 0;
5847
+ if (negated) return `${negated}:calc(${val} * -1)`;
4516
5848
  }
4517
5849
  }
4518
5850
  const rside = base.match(/^rounded-(t|r|b|l|tl|tr|br|bl)(?:-(.+))?$/);
@@ -4614,7 +5946,7 @@ function splitVariants(cls) {
4614
5946
  out.push(cur);
4615
5947
  return out;
4616
5948
  }
4617
- var BREAKPOINTS, KNOWN_EMPTY, PALETTE, FONT_FAMILY, FLEX_EXTRA, TW_DEFAULTS, ESCAPE_RE, STATE_VARIANTS, GROUP_PEER, PSEUDO_ELEMENTS, SPACING_PROP, SIZE_PROP, COLOR_PROP, SEMANTIC_TOKENS, RADIUS, RADIUS_SIDE, FONT_SIZE, FONT_WEIGHT, STATIC, ARBITRARY_PROP, MAX_W_SCALE;
5949
+ var BREAKPOINTS, KNOWN_EMPTY, PALETTE, FONT_FAMILY, FLEX_EXTRA, TW_DEFAULTS, ESCAPE_RE, STATE_VARIANTS, GROUP_PEER, PSEUDO_ELEMENTS, SPACING_PROP, SIZE_PROP, COLOR_PROP, SEMANTIC_TOKENS, RADIUS, RADIUS_SIDE, FONT_SIZE, FONT_WEIGHT, STATIC, ARBITRARY_PROP, ARBITRARY_PROPS_MULTI, FILTER_CHAIN, BACKDROP_CHAIN, RING_BOX_SHADOW, TRANSITION_TAIL, ARBITRARY_COMPOSED, HINT_KIND, ARBITRARY_KINDS, COLOR_FUNCTIONS, IMAGE_FUNCTIONS, LENGTH_VALUE, MATH_VALUE, MAX_W_SCALE;
4618
5950
  var init_tw_translate = __esm({
4619
5951
  "../shared/src/mcp/tw-translate.ts"() {
4620
5952
  "use strict";
@@ -5042,15 +6374,12 @@ var init_tw_translate = __esm({
5042
6374
  "rounded-xs": "border-radius:var(--radius-xs, 0.125rem)"
5043
6375
  };
5044
6376
  ARBITRARY_PROP = {
5045
- bg: "background",
5046
- text: "color",
5047
6377
  w: "width",
5048
6378
  h: "height",
5049
6379
  "min-w": "min-width",
5050
6380
  "max-w": "max-width",
5051
6381
  "min-h": "min-height",
5052
6382
  "max-h": "max-height",
5053
- size: "width",
5054
6383
  p: "padding",
5055
6384
  px: "padding-inline",
5056
6385
  py: "padding-block",
@@ -5067,8 +6396,6 @@ var init_tw_translate = __esm({
5067
6396
  ml: "margin-left",
5068
6397
  gap: "gap",
5069
6398
  rounded: "border-radius",
5070
- shadow: "box-shadow",
5071
- border: "border-width",
5072
6399
  top: "top",
5073
6400
  left: "left",
5074
6401
  right: "right",
@@ -5079,14 +6406,93 @@ var init_tw_translate = __esm({
5079
6406
  opacity: "opacity",
5080
6407
  z: "z-index",
5081
6408
  grid: "grid-template-columns",
5082
- "font": "font-size",
5083
6409
  "aspect": "aspect-ratio",
5084
- // transition props (transition-[background-color,box-shadow]) + timing
5085
- transition: "transition-property",
5086
6410
  duration: "transition-duration",
5087
6411
  delay: "transition-delay",
5088
6412
  ease: "transition-timing-function"
5089
6413
  };
6414
+ ARBITRARY_PROPS_MULTI = {
6415
+ size: ["width", "height"]
6416
+ };
6417
+ FILTER_CHAIN = "var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)";
6418
+ BACKDROP_CHAIN = "var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)";
6419
+ RING_BOX_SHADOW = "var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)";
6420
+ TRANSITION_TAIL = "transition-timing-function:cubic-bezier(0.4,0,0.2,1);transition-duration:150ms";
6421
+ ARBITRARY_COMPOSED = {
6422
+ blur: (v) => `--tw-blur:blur(${v});filter:${FILTER_CHAIN}`,
6423
+ "backdrop-blur": (v) => `--tw-backdrop-blur:blur(${v});-webkit-backdrop-filter:${BACKDROP_CHAIN};backdrop-filter:${BACKDROP_CHAIN}`,
6424
+ "drop-shadow": (v) => `--tw-drop-shadow:drop-shadow(${v});filter:${FILTER_CHAIN}`,
6425
+ scale: (v) => `--tw-scale-x:${v};--tw-scale-y:${v};--tw-scale-z:${v};scale:var(--tw-scale-x) var(--tw-scale-y)`,
6426
+ origin: (v) => `transform-origin:${v}`,
6427
+ "underline-offset": (v) => `text-underline-offset:${v}`,
6428
+ // Tailwind's `transition-[a,b]` still applies the default duration and easing;
6429
+ // emitting only transition-property leaves every transition at 0s.
6430
+ transition: (v) => `transition-property:${v};${TRANSITION_TAIL}`
6431
+ };
6432
+ HINT_KIND = {
6433
+ color: "color",
6434
+ image: "image",
6435
+ url: "image",
6436
+ length: "length",
6437
+ "line-width": "length",
6438
+ percentage: "length",
6439
+ size: "length",
6440
+ "bg-size": "length",
6441
+ "absolute-size": "length",
6442
+ "relative-size": "length",
6443
+ "family-name": "family",
6444
+ "generic-name": "family",
6445
+ number: "number",
6446
+ integer: "number",
6447
+ ratio: "number",
6448
+ angle: "number",
6449
+ position: "position"
6450
+ };
6451
+ ARBITRARY_KINDS = {
6452
+ bg: {
6453
+ fallback: "background-color",
6454
+ fallbackKind: "color",
6455
+ props: {
6456
+ color: "background-color",
6457
+ image: "background-image",
6458
+ length: "background-size",
6459
+ position: "background-position"
6460
+ }
6461
+ },
6462
+ text: { fallback: "color", fallbackKind: "color", props: { color: "color", length: "font-size" } },
6463
+ font: {
6464
+ fallback: "font-weight",
6465
+ fallbackKind: "number",
6466
+ props: { family: "font-family", number: "font-weight", length: "font-size" }
6467
+ },
6468
+ border: { fallback: "border-color", fallbackKind: "color", props: { color: "border-color", length: "border-width" } },
6469
+ "border-x": { fallback: "border-inline-color", fallbackKind: "color", props: { color: "border-inline-color", length: "border-inline-width" } },
6470
+ "border-y": { fallback: "border-block-color", fallbackKind: "color", props: { color: "border-block-color", length: "border-block-width" } },
6471
+ "border-t": { fallback: "border-top-color", fallbackKind: "color", props: { color: "border-top-color", length: "border-top-width" } },
6472
+ "border-r": { fallback: "border-right-color", fallbackKind: "color", props: { color: "border-right-color", length: "border-right-width" } },
6473
+ "border-b": { fallback: "border-bottom-color", fallbackKind: "color", props: { color: "border-bottom-color", length: "border-bottom-width" } },
6474
+ "border-l": { fallback: "border-left-color", fallbackKind: "color", props: { color: "border-left-color", length: "border-left-width" } },
6475
+ divide: { fallback: "border-color", fallbackKind: "color", props: { color: "border-color", length: "border-width" } },
6476
+ outline: { fallback: "outline-color", fallbackKind: "color", props: { color: "outline-color", length: "outline-width" } },
6477
+ // `--tw-ring-width` is not a real Tailwind variable: it marks the ring-WIDTH
6478
+ // branch so the caller can emit the composed ring box-shadow instead of a
6479
+ // declaration. `ring-[<color>]` still sets the ring color.
6480
+ ring: { fallback: "--tw-ring-color", fallbackKind: "color", props: { color: "--tw-ring-color", length: "--tw-ring-width" } },
6481
+ // The kit has no Tailwind shadow-var machinery, so a shadow value goes straight
6482
+ // onto box-shadow; only the color hint targets the tint var.
6483
+ shadow: { fallback: "box-shadow", fallbackKind: "length", props: { color: "--tw-shadow-color" } },
6484
+ stroke: { fallback: "stroke", fallbackKind: "color", props: { color: "stroke", length: "stroke-width", number: "stroke-width" } },
6485
+ fill: { fallback: "fill", fallbackKind: "color", props: { color: "fill" } },
6486
+ decoration: {
6487
+ fallback: "text-decoration-color",
6488
+ fallbackKind: "color",
6489
+ props: { color: "text-decoration-color", length: "text-decoration-thickness" }
6490
+ }
6491
+ };
6492
+ COLOR_FUNCTIONS = /^(?:#[0-9a-f]{3,8}$|(?:rgba?|hsla?|hwb|lab|lch|oklab|oklch|color|color-mix)\()/i;
6493
+ IMAGE_FUNCTIONS = /^(?:url\(|(?:repeating-)?(?:linear|radial|conic)-gradient\(|image-set\(|cross-fade\()/i;
6494
+ LENGTH_VALUE = /^-?(?:\d*\.)?\d+(?:px|rem|em|%|vh|vw|vmin|vmax|svh|lvh|dvh|svw|lvw|dvw|ch|ex|cap|ic|lh|pt|pc|cm|mm|q|in)$/i;
6495
+ MATH_VALUE = /^(?:calc|clamp|min|max)\(/i;
5090
6496
  MAX_W_SCALE = {
5091
6497
  xs: "20rem",
5092
6498
  sm: "24rem",
@@ -5163,47 +6569,73 @@ function decodeArbitrary2(raw) {
5163
6569
  const value = raw.replace(/(?<!\\)_/g, " ").replace(/\\_/g, "_").trim();
5164
6570
  return value && !/[<>{};]/.test(value) ? value : null;
5165
6571
  }
5166
- function restingValue(scope, cssProp, utilPrefixes) {
6572
+ function restingDeclaration(scope, cssProp, utilPrefixes) {
5167
6573
  for (const prefix of utilPrefixes) {
5168
6574
  const m = scope.match(new RegExp(`(?<![\\w:.\\-])${prefix}-\\[([^\\]]+)\\]`));
5169
6575
  if (m) {
5170
6576
  const v = decodeArbitrary2(m[1]);
5171
- if (v) return v;
6577
+ if (v) return resolveArbitraryDeclaration(prefix, v) ?? { property: cssProp, value: v };
5172
6578
  }
5173
6579
  }
5174
6580
  const pm = scope.match(new RegExp(`(?<!:)\\[${cssProp}:([^\\]]+)\\]`));
5175
- return pm ? decodeArbitrary2(pm[1]) : null;
6581
+ const decoded = pm ? decodeArbitrary2(pm[1]) : null;
6582
+ return decoded ? { property: cssProp, value: decoded } : null;
6583
+ }
6584
+ function restingValue(scope, cssProp, utilPrefixes) {
6585
+ const declaration = restingDeclaration(scope, cssProp, utilPrefixes);
6586
+ return declaration?.property === cssProp ? declaration.value : null;
5176
6587
  }
5177
6588
  function variantBody(content, name) {
5178
6589
  const vi = content.search(/variant\s*:\s*\{/);
5179
6590
  const hay = vi !== -1 ? content.slice(vi) : content;
6591
+ const key = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
5180
6592
  const m = hay.match(
5181
- new RegExp(`[\\s{,]${name}\\s*:\\s*([\\s\\S]*?)(?:\\n\\s*[A-Za-z_][\\w-]*\\s*:|\\n\\s*\\},)`)
6593
+ new RegExp(
6594
+ `[\\s{,](?:${key}|["']${key}["'])\\s*:\\s*([\\s\\S]*?)(?:\\n\\s*(?:[A-Za-z_][\\w-]*|["'][^"']+["'])\\s*:|\\n\\s*\\},)`
6595
+ )
5182
6596
  );
5183
6597
  return m ? m[1] : null;
5184
6598
  }
6599
+ function declaredDefaultVariantBody(content) {
6600
+ const defaults = content.match(/defaultVariants\s*:\s*\{([\s\S]*?)\}/)?.[1];
6601
+ const declaredDefault = defaults?.match(/\bvariant\s*:\s*["']([^"']+)["']/)?.[1];
6602
+ return (declaredDefault ? variantBody(content, declaredDefault) : null) ?? variantBody(content, "default");
6603
+ }
6604
+ function canonicalButtonVariantBody(content) {
6605
+ return declaredDefaultVariantBody(content) ?? variantBody(content, "primary");
6606
+ }
5185
6607
  function componentDepthCss(files) {
5186
6608
  const btn = files.find((f) => f.path && /(^|\/)button\.(tsx|jsx)$/.test(f.path));
5187
6609
  if (!btn?.content) return "";
5188
6610
  const content = btn.content;
5189
- const scope = variantBody(content, "primary") ?? variantBody(content, "default") ?? content;
6611
+ const scope = canonicalButtonVariantBody(content) ?? content;
5190
6612
  const shadow = /(?:^|\s|"|')shadow-none(?=\s|"|'|$)/.test(scope) ? "none" : restingValue(scope, "box-shadow", ["shadow"]);
5191
- const background = restingValue(scope, "background", ["bg"]) ?? restingValue(scope, "background-image", []);
6613
+ const background = restingDeclaration(scope, "background", ["bg"]) ?? restingDeclaration(scope, "background-image", []);
5192
6614
  const sel = ".bd-btn-primary, .bd-view, .ds-btn-primary";
5193
6615
  const rules = [];
5194
6616
  if (shadow) rules.push(`${sel} { box-shadow: ${shadow}; }`);
5195
- if (background) rules.push(`${sel} { background: ${background}; }`);
6617
+ if (background) rules.push(`${sel} { ${background.property}: ${background.value}; }`);
5196
6618
  return rules.join("\n");
5197
6619
  }
6620
+ function arbitrary(scope, re) {
6621
+ const m = scope.match(re);
6622
+ if (!m) return null;
6623
+ const value = m[1].replace(/(?<!\\)_/g, " ").replace(/\\_/g, "_").trim();
6624
+ return value && !/[<>{};]/.test(value) ? value : null;
6625
+ }
6626
+ function checkedTrackBackground(source) {
6627
+ const utility = arbitrary(source, /data-\[state=checked\]:bg-\[([^\]]+)\]/);
6628
+ if (utility) {
6629
+ return resolveArbitraryDeclaration("bg", utility) ?? { property: "background", value: utility };
6630
+ }
6631
+ const image = arbitrary(source, /data-\[state=checked\]:\[background-image:([^\]]+)\]/);
6632
+ if (image) return { property: "background-image", value: image };
6633
+ const shorthand = arbitrary(source, /data-\[state=checked\]:\[background:([^\]]+)\]/);
6634
+ return shorthand ? { property: "background", value: shorthand } : null;
6635
+ }
5198
6636
  function buildDsComponentCss(files) {
5199
6637
  const contentOf = (re) => stripComments2(files.find((f) => f.path && re.test(f.path))?.content ?? "");
5200
6638
  const has = (name) => Boolean(contentOf(new RegExp(`(^|/)${name}\\.(tsx|jsx)$`)));
5201
- const arbitrary = (scope, re) => {
5202
- const m = scope.match(re);
5203
- if (!m) return null;
5204
- const value = m[1].replace(/(?<!\\)_/g, " ").replace(/\\_/g, "_").trim();
5205
- return value && !/[<>{};]/.test(value) ? value : null;
5206
- };
5207
6639
  const rules = [componentRadiusCss(files), componentDepthCss(files)];
5208
6640
  const covered = /* @__PURE__ */ new Set();
5209
6641
  for (const name of ["button", "input", "alert", "avatar", "table", "tabs", "progress"]) {
@@ -5218,9 +6650,9 @@ function buildDsComponentCss(files) {
5218
6650
  const sw = contentOf(/(^|\/)switch\.(tsx|jsx)$/);
5219
6651
  if (sw) {
5220
6652
  covered.add("switch");
5221
- const bg = arbitrary(sw, /data-\[state=checked\]:bg-\[([^\]]+)\]/) ?? arbitrary(sw, /data-\[state=checked\]:\[background(?:-image)?:([^\]]+)\]/);
6653
+ const bg = checkedTrackBackground(sw);
5222
6654
  const shadow = arbitrary(sw, /data-\[state=checked\]:shadow-\[([^\]]+)\]/) ?? arbitrary(sw, /data-\[state=checked\]:\[box-shadow:([^\]]+)\]/);
5223
- if (bg) rules.push(`.ds-switch-track.on { background: ${bg}; }`);
6655
+ if (bg) rules.push(`.ds-switch-track.on { ${bg.property}: ${bg.value}; }`);
5224
6656
  if (shadow) rules.push(`.ds-switch-track.on { box-shadow: ${shadow}; }`);
5225
6657
  }
5226
6658
  const badge = contentOf(/(^|\/)badge\.(tsx|jsx)$/);
@@ -5306,10 +6738,11 @@ function translatedKitComponentCss(contentOf, covered) {
5306
6738
  const button = contentOf(/(^|\/)button\.(tsx|jsx)$/);
5307
6739
  if (button) {
5308
6740
  emit(".ds-btn", cvaBase(button), "button");
5309
- emit(".ds-btn-primary", variantBody(button, "primary") ? quotedStrings(variantBody(button, "primary")) : variantBody(button, "default") ? quotedStrings(variantBody(button, "default")) : null);
6741
+ const canonical = canonicalButtonVariantBody(button);
6742
+ emit(".ds .ds-btn-primary", canonical ? quotedStrings(canonical) : null);
5310
6743
  for (const v of ["secondary", "outline", "ghost"]) {
5311
6744
  const body = variantBody(button, v);
5312
- if (body) emit(`.ds-btn-${v}`, quotedStrings(body));
6745
+ if (body) emit(`.ds .ds-btn-${v}`, quotedStrings(body));
5313
6746
  }
5314
6747
  }
5315
6748
  const shared = contentOf(/(^|\/)_shared\.(ts|tsx)$/);
@@ -5454,15 +6887,13 @@ ${PREVIEW_BASE_CSS}
5454
6887
  .bd-app { padding: 16px; }
5455
6888
  .bd-cta-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 12px 16px; background: var(--card, var(--background, oklch(1 0 0))); border-top: 1px solid var(--border, oklch(0.92 0 0)); }
5456
6889
  .bd-cta-name { font-weight: 600; font-size: 14px; color: var(--foreground, oklch(0.2 0 0)); font-family: var(--font-serif, var(--font-sans, ui-sans-serif)); }
5457
- .bd-cta { font: inherit; font-size: 14px; font-weight: 700; text-decoration: none; color: var(--primary, oklch(0.45 0 0)); display: inline-flex; align-items: center; gap: 6px; }
5458
- .bd-cta:hover { opacity: 0.7; }
6890
+ .bd-cta { min-height: 44px; padding-inline: 4px; font: inherit; font-size: 14px; font-weight: 700; text-decoration: none; color: var(--primary, oklch(0.45 0 0)); display: inline-flex; align-items: center; gap: 6px; }
5459
6891
  .bd-cta-actions { display: inline-flex; align-items: center; gap: 14px; }
5460
- .bd-expand { font: inherit; font-size: 13px; font-weight: 600; cursor: pointer; background: none; border: 0; padding: 0; color: var(--muted-foreground, oklch(0.5 0 0)); display: inline-flex; align-items: center; gap: 5px; }
5461
- .bd-expand:hover { color: var(--foreground, oklch(0.2 0 0)); }
6892
+ .bd-expand { min-height: 44px; font: inherit; font-size: 13px; font-weight: 600; cursor: pointer; background: none; border: 0; padding: 0 4px; color: var(--muted-foreground, oklch(0.5 0 0)); display: inline-flex; align-items: center; gap: 5px; }
6893
+ .bd-cta:focus-visible, .bd-expand:focus-visible, .bd-nav-item:focus-visible { outline: 2px solid var(--ring, var(--primary, oklch(0.55 0.15 264))); outline-offset: 2px; }
5462
6894
  .bd-progress { padding: 6px 16px 12px; }
5463
- /* Fullscreen: the DS preview rendered IN-WIDGET (token-driven), full-bleed. No
5464
- iframe \u2014 claude.ai's sandbox blocks nested cross-origin frames and the
5465
- ext-apps model is in-widget rendering. requestDisplayMode just grants room. */
6895
+ /* Fullscreen: the DS preview rendered in the widget, full-bleed. The host's
6896
+ requestDisplayMode capability only grants more room. */
5466
6897
  .bd-fs { position: fixed; inset: 0; display: flex; flex-direction: column; background: var(--background, oklch(1 0 0)); }
5467
6898
  .bd-fs-bar { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 16px; border-bottom: 1px solid var(--border, oklch(0.92 0 0)); background: var(--card, var(--background, oklch(1 0 0))); flex: none; }
5468
6899
  .bd-fs-body { flex: 1; overflow-y: auto; padding: 20px; max-width: 860px; width: 100%; margin: 0 auto; }
@@ -5509,14 +6940,14 @@ ${DS_KIT_CSS}
5509
6940
  .bd-nav-card, .bd-pal-card { border: 1px solid var(--border, oklch(0.9 0 0)); border-radius: 8px; background: var(--card, var(--background, oklch(1 0 0))); overflow: clip; }
5510
6941
  .bd-nav-h, .bd-pal-h { padding: 10px 12px; border-bottom: 1px solid var(--border, oklch(0.9 0 0)); font-size: 10px; font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase; color: var(--muted-foreground, oklch(0.5 0 0)); opacity: 0.6; }
5511
6942
  .bd-nav-list { display: flex; flex-direction: column; gap: 1px; padding: 6px 6px 8px; }
5512
- .bd-nav-item { display: flex; align-items: center; gap: 8px; width: 100%; text-align: left; padding: 5px 8px; font: inherit; font-size: 12px; font-weight: 400; color: var(--muted-foreground, oklch(0.5 0 0)); background: transparent; border-radius: 5px; border: none; cursor: pointer; line-height: 1.4; }
5513
- .bd-nav-item:hover, .bd-nav-item.active { font-weight: 500; color: var(--foreground, oklch(0.2 0 0)); background: var(--muted, oklch(0.95 0 0)); }
6943
+ .bd-nav-item { min-height: 44px; display: flex; align-items: center; gap: 8px; width: 100%; text-align: left; padding: 8px; font: inherit; font-size: 12px; font-weight: 400; color: var(--muted-foreground, oklch(0.5 0 0)); background: transparent; border-radius: 5px; border: none; cursor: pointer; line-height: 1.4; }
6944
+ .bd-nav-item.active { font-weight: 500; color: var(--foreground, oklch(0.2 0 0)); background: var(--muted, oklch(0.95 0 0)); }
5514
6945
  .bd-nav-dot { width: 5px; height: 5px; border-radius: 50%; flex-shrink: 0; background: var(--border, oklch(0.85 0 0)); }
5515
6946
  .bd-nav-item.active .bd-nav-dot { background: var(--primary, oklch(0.45 0.15 264)); }
5516
6947
  /* SectionHeader (component-showcase.tsx:1659-1680). */
5517
6948
  .bd-sh { margin-bottom: 20px; margin-top: 64px; padding-top: 48px; border-top: 1px solid var(--border, oklch(0.9 0 0)); }
5518
6949
  .bd-sh.first { margin-top: 28px; padding-top: 0; border-top: none; }
5519
- .bd-sh-t { font-size: 11px; font-weight: 600; letter-spacing: 0.08em; text-transform: uppercase; color: var(--muted-foreground, oklch(0.5 0 0)); }
6950
+ .bd-sh-t { margin: 0; font-size: 11px; font-weight: 600; letter-spacing: 0.08em; text-transform: uppercase; color: var(--muted-foreground, oklch(0.5 0 0)); }
5520
6951
  /* Grid (component-showcase.tsx:1565-1571 \u2014 cols-1 / sm:2 / xl:3, gap-3 = 12px).
5521
6952
  Own class (not the vignette's .bd-grid) + explicit responsive columns capped
5522
6953
  at 3, so the fullscreen main never renders a 4th column. [BD-117] */
@@ -5536,26 +6967,18 @@ ${DS_KIT_CSS}
5536
6967
  .bd-show-scroll { max-height: 420px; overflow: hidden; position: relative; -webkit-mask-image: linear-gradient(to bottom, #000 86%, transparent); mask-image: linear-gradient(to bottom, #000 86%, transparent); }
5537
6968
  .bd-fs .bd-show-scroll { max-height: none; overflow: visible; -webkit-mask-image: none; mask-image: none; }
5538
6969
  .bd-fs .bd-main { max-width: 920px; }
5539
- /* Real-page iframe (ChatGPT). Inline = capped height + footer; fullscreen =
5540
- full viewport + a Back bar. The iframe shows the actual /design-systems/<id>
5541
- page (?embed=1, no app nav), so the components are real, not approximated. */
5542
- .bd-frame-wrap { display: flex; flex-direction: column; background: var(--background, oklch(1 0 0)); }
5543
- .bd-frame { width: 100%; border: 0; display: block; background: var(--background, oklch(1 0 0)); height: 460px; }
5544
- .bd-frame-wrap.bd-fs { position: fixed; inset: 0; height: 100vh; }
5545
- .bd-frame-wrap.bd-fs .bd-frame { flex: 1; height: auto; }
5546
- /* Real-page screenshot (claude.ai, which can't iframe). Inline = capped + fade;
5547
- fullscreen = scrollable. [BD-136] */
6970
+ /* Real-page screenshot. Inline = capped + fade; fullscreen = scrollable. */
5548
6971
  .bd-shot-wrap { min-height: 240px; max-height: 540px; overflow: hidden; position: relative; background: var(--card, var(--background, oklch(0.97 0 0))); -webkit-mask-image: linear-gradient(to bottom, #000 90%, transparent); mask-image: linear-gradient(to bottom, #000 90%, transparent); }
5549
- .bd-shot-wrap.bd-fs { min-height: 0; max-height: 100vh; overflow: auto; -webkit-mask-image: none; mask-image: none; }
6972
+ .bd-fs > .bd-shot-wrap { min-height: 0; max-height: none; flex: 1; overflow: auto; -webkit-mask-image: none; mask-image: none; }
5550
6973
  .bd-shot { display: block; width: 100%; height: auto; }
5551
- .bd-frame-foot { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 9px 14px; border-top: 1px solid var(--border, oklch(0.92 0 0)); background: var(--card, var(--background, oklch(1 0 0))); }
5552
- .bd-cta-name { font-weight: 600; font-size: 13px; color: var(--foreground, oklch(0.2 0 0)); }
6974
+ .bd-preview-foot { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 9px 14px; border-top: 1px solid var(--border, oklch(0.92 0 0)); background: var(--card, var(--background, oklch(1 0 0))); }
6975
+ .bd-cta-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 600; font-size: 13px; color: var(--foreground, oklch(0.2 0 0)); }
5553
6976
  .bd-cta-actions { display: inline-flex; align-items: center; gap: 14px; }
5554
- /* claude.ai link-out card (can't iframe). */
5555
- .bd-link-card { padding: 22px 20px; display: flex; flex-direction: column; gap: 6px; align-items: flex-start; }
5556
- .bd-link-title { font-size: 17px; font-weight: 600; color: var(--foreground, oklch(0.2 0 0)); }
5557
- .bd-link-desc { font-size: 13px; color: var(--muted-foreground, oklch(0.5 0 0)); margin: 0 0 6px; }
5558
- .bd-cta-lg { font-size: 14px; padding: 9px 16px; border-radius: var(--radius-md, 8px); background: var(--primary, oklch(0.45 0.15 264)); color: var(--primary-foreground, oklch(1 0 0)); }
6977
+ @media (hover: hover) and (pointer: fine) {
6978
+ .bd-cta:hover { opacity: 0.7; }
6979
+ .bd-expand:hover { color: var(--foreground, oklch(0.2 0 0)); }
6980
+ .bd-nav-item:hover { font-weight: 500; color: var(--foreground, oklch(0.2 0 0)); background: var(--muted, oklch(0.95 0 0)); }
6981
+ }
5559
6982
  </style>
5560
6983
  <style id="bd-tokens"></style>
5561
6984
  </head>
@@ -5577,7 +7000,7 @@ ${DS_KIT_CSS}
5577
7000
  return String(s == null ? "" : s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
5578
7001
  }
5579
7002
 
5580
- // Only http(s) URLs are safe to use as an iframe src / link href; reject
7003
+ // Only http(s) URLs are safe to use as image or link targets; reject
5581
7004
  // javascript:/data:/etc. so a hostile MCP payload can't inject a scheme that
5582
7005
  // executes in the widget sandbox. Returns "" for anything else.
5583
7006
  function safeUrl(u) {
@@ -5626,12 +7049,12 @@ ${DS_KIT_CSS}
5626
7049
  return showcaseSections().map(function (s, i) {
5627
7050
  var cards = s[2].map(function (it) {
5628
7051
  return '<div class="bd-dc" data-name="' + esc(it[0].toLowerCase()) + '">' +
5629
- '<div class="bd-dc-demo">' + it[1] + '</div>' +
7052
+ '<div class="bd-dc-demo" inert aria-hidden="true">' + it[1] + '</div>' +
5630
7053
  '<div class="bd-dc-foot">' + esc(it[0]) + '</div>' +
5631
7054
  '</div>';
5632
7055
  }).join("");
5633
7056
  return '<section class="bd-sec" data-sec="' + esc(s[0]) + '">' +
5634
- '<div class="bd-sh' + (i === 0 ? " first" : "") + '"><div class="bd-sh-t">' + esc(s[1]) + '</div></div>' +
7057
+ '<div class="bd-sh' + (i === 0 ? " first" : "") + '"><h2 class="bd-sh-t">' + esc(s[1]) + '</h2></div>' +
5635
7058
  '<div class="bd-show-grid">' + cards + '</div>' +
5636
7059
  '</section>';
5637
7060
  }).join("");
@@ -5650,7 +7073,7 @@ ${DS_KIT_CSS}
5650
7073
  // for each semantic color token. Fullscreen only (CSS). [BD-117]
5651
7074
  function paletteHtml() {
5652
7075
  var TOK = [["Background", "background"], ["Foreground", "foreground"], ["Primary", "primary"], ["Secondary", "secondary"], ["Accent", "accent"], ["Muted", "muted"], ["Card", "card"], ["Border", "border"], ["Destructive", "destructive"], ["Ring", "ring"]];
5653
- return '<aside class="bd-pal"><div class="bd-pal-card"><div class="bd-pal-h">Tokens</div><div class="bd-pal-list">' +
7076
+ return '<aside class="bd-pal"><div class="bd-pal-card"><div class="bd-pal-h">Colors</div><div class="bd-pal-list">' +
5654
7077
  TOK.map(function (t) {
5655
7078
  return '<div class="bd-tok"><span class="bd-tok-sw" style="background: var(--' + t[1] + ', transparent)"></span>' + esc(t[0]) + '</div>';
5656
7079
  }).join("") + '</div></div></aside>';
@@ -5658,7 +7081,7 @@ ${DS_KIT_CSS}
5658
7081
 
5659
7082
  // The full showcase. Mirrors the real page: a header (h1 + description), then a
5660
7083
  // 3-column layout (left nav + main sections + right palette). Same render on
5661
- // BOTH hosts (no iframe). Inline caps the height + hides asides via CSS exactly
7084
+ // every host. Inline caps the height + hides asides via CSS exactly
5662
7085
  // like the real page below lg/xl; fullscreen shows everything. [BD-117]
5663
7086
  function showcaseBody(out, full) {
5664
7087
  var nm = esc(out.name || "Design system");
@@ -5716,7 +7139,7 @@ ${DS_KIT_CSS}
5716
7139
  de.removeProperty("--font-serif");
5717
7140
  de.removeProperty("--font-mono");
5718
7141
  } else if (hostFontsPayload && applyHostFontsFn) {
5719
- try { applyHostFontsFn(hostFontsPayload); } catch (e) {}
7142
+ try { applyHostFontsFn(hostFontsPayload); } catch (e) { /* best-effort: host font apply; fallback fonts stand */ }
5720
7143
  }
5721
7144
  }
5722
7145
 
@@ -5727,15 +7150,15 @@ ${DS_KIT_CSS}
5727
7150
  // mode field = declined); only switch to what it actually granted.
5728
7151
  var apply = function (m) { displayMode = m || displayMode; if (lastOut) render(lastOut); };
5729
7152
  try {
5730
- if (bdApp && bdApp.requestDisplayMode) {
5731
- bdApp.requestDisplayMode({ mode: mode }).then(function (r) { apply(r && r.mode); }, function () {});
5732
- return;
5733
- }
5734
7153
  if (window.openai && window.openai.requestDisplayMode) {
5735
7154
  Promise.resolve(window.openai.requestDisplayMode({ mode: mode })).then(function (r) { apply(r && r.mode); }, function () {});
5736
7155
  return;
5737
7156
  }
5738
- } catch (e) {}
7157
+ if (bdApp && bdApp.requestDisplayMode) {
7158
+ bdApp.requestDisplayMode({ mode: mode }).then(function (r) { apply(r && r.mode); }, function () {});
7159
+ return;
7160
+ }
7161
+ } catch (e) { /* best-effort: bridge threw; apply the mode locally below */ }
5739
7162
  apply(mode);
5740
7163
  }
5741
7164
 
@@ -5766,91 +7189,32 @@ ${DS_KIT_CSS}
5766
7189
  return !!(modes && modes.indexOf && modes.indexOf("fullscreen") !== -1);
5767
7190
  }
5768
7191
 
5769
- // ChatGPT (OpenAI Apps) honors frameDomains, so we can iframe our own origin;
5770
- // claude.ai hardcodes frame-src and ignores frameDomains (anthropics/claude-ai
5771
- // -mcp#40), so it can never iframe \u2014 there we link out instead. [BD-117]
5772
- //
5773
- // Identify ChatGPT by window.openai ALONE. Do NOT also test !bdApp: bdApp is
5774
- // our OWN inlined MCP-Apps client (window.__bdMcp), instantiated
5775
- // unconditionally on every host, so it is always set after first paint. The
5776
- // old check (window.openai AND not bdApp) therefore flipped to false on every
5777
- // re-render (Fullscreen, set_globals), wrongly showing the claude.ai link card
5778
- // on ChatGPT. window.openai is absent on claude.ai, so it is the right signal.
5779
- function canIframe() {
5780
- return !!window.openai;
5781
- }
5782
-
5783
- // The real page, with ?embed=1 so it renders the showcase WITHOUT the app nav
5784
- // (clean component showcase to embed). [BD-117]
5785
- function embedUrl(view) {
5786
- if (!view) return "";
5787
- return view + (view.indexOf("?") === -1 ? "?embed=1" : "&embed=1");
5788
- }
5789
-
5790
- // Embed the REAL /design-systems/<id> (or /projects/<id>) page \u2014 the actual
5791
- // React components, not a token approximation. Inline = capped height + a
5792
- // footer (Fullscreen + Open). Fullscreen = full-bleed iframe with NO header of
5793
- // our own: both hosts render a native close button in fullscreen (ChatGPT's
5794
- // top bar, claude.ai's native header), so our own Back bar was redundant and
5795
- // visually broken (misaligned, disliked chevron). [BD-136]
5796
- // embed = the PUBLIC, cookieless URL we iframe (the sandbox carries no
5797
- // session cookie, so an owner-scoped page would 404 inside it). open = the
5798
- // owner-facing page for the human "Open" link (they have the cookie). [BD-164]
5799
- function iframeBody(out, embed, open, full) {
5800
- var nm = esc(out.name || "Design system");
5801
- var frame = '<iframe class="bd-frame" src="' + esc(embedUrl(embed)) + '" title="' + nm + ' showcase" referrerpolicy="no-referrer-when-downgrade"></iframe>';
5802
- if (full) {
5803
- return '<div class="bd-frame-wrap bd-fs">' + frame + '</div>';
5804
- }
5805
- var openHref = open || embed;
5806
- return '<div class="bd-frame-wrap">' + frame +
5807
- '<div class="bd-frame-foot"><span class="bd-cta-name">' + nm + '</span><span class="bd-cta-actions">' +
5808
- (canFullscreen() ? '<button class="bd-expand" data-bd-fullscreen type="button">Fullscreen' + ICON_EXPAND + '</button>' : "") +
5809
- '<a class="bd-cta" data-bd-open href="' + esc(openHref) + '" target="_blank" rel="noopener">Open' + ICON_EXTERNAL + '</a>' +
5810
- '</span></div>' +
5811
- '</div>';
5812
- }
5813
-
5814
- // claude.ai can't iframe \u2014 surface the REAL page as a browser link (no
5815
- // approximation, just not embedded). [BD-117]
5816
- function linkBody(out, view) {
5817
- var nm = esc(out.name || "Design system");
5818
- return '<div class="bd-link-card">' +
5819
- '<div class="bd-link-title">' + nm + '</div>' +
5820
- '<p class="bd-link-desc">Open the live showcase to see every component, styled by this system.</p>' +
5821
- '<a class="bd-cta bd-cta-lg" data-bd-open href="' + esc(view) + '" target="_blank" rel="noopener">Open live showcase' + ICON_EXTERNAL + '</a>' +
5822
- '</div>';
5823
- }
5824
-
5825
- // The name + Fullscreen + Open footer, shared by the iframe / screenshot /
5826
- // token previews. Name left, actions right (CSS handles the split). [BD-136]
5827
- function footHtml(out) {
7192
+ // The name + Fullscreen + Open footer shared by screenshot and token previews.
7193
+ function footHtml(out, full) {
5828
7194
  var view = safeUrl(out.viewUrl);
5829
- return '<div class="bd-frame-foot"><span class="bd-cta-name">' + esc(out.name || "Design system") + '</span><span class="bd-cta-actions">' +
5830
- (canFullscreen() ? '<button class="bd-expand" data-bd-fullscreen type="button">Fullscreen' + ICON_EXPAND + '</button>' : "") +
5831
- (view ? '<a class="bd-cta" data-bd-open href="' + esc(view) + '" target="_blank" rel="noopener">Open' + ICON_EXTERNAL + '</a>' : "") +
7195
+ return '<div class="bd-preview-foot"><span class="bd-cta-name">' + esc(out.name || "Design system") + '</span><span class="bd-cta-actions">' +
7196
+ (!full && canFullscreen() ? '<button class="bd-expand" data-bd-fullscreen type="button">Fullscreen' + ICON_EXPAND + '</button>' : "") +
7197
+ (view ? '<a class="bd-cta" data-bd-open href="' + esc(view) + '" target="_blank" rel="noopener">Open showcase' + ICON_EXTERNAL + '</a>' : "") +
5832
7198
  '</span></div>';
5833
7199
  }
5834
7200
 
5835
- // A REAL screenshot of the showcase page (server-rendered via Cloudflare
5836
- // Browser Rendering), shown where the page can't be iframed (claude.ai). Real
5837
- // components + real CSS, just a flat image. inline = capped + fade; fullscreen
5838
- // = scrollable. Falls back to the token render if the image fails. [BD-136]
5839
- function shotBody(out, full) {
5840
- return '<div class="bd-shot-wrap' + (full ? " bd-fs" : "") + '">' +
7201
+ // A real screenshot of the showcase page, server-rendered via Cloudflare
7202
+ // Browser Rendering. Inline is capped; fullscreen is scrollable. It falls
7203
+ // back to the token render if the image fails. [BD-316]
7204
+ function shotBody(out) {
7205
+ return '<div class="bd-shot-wrap">' +
5841
7206
  '<img class="bd-shot" data-bd-shot src="' + esc(safeUrl(out.shotUrl)) + '" alt="' + esc(out.name || "Design system") + ' showcase" />' +
5842
7207
  '</div>';
5843
7208
  }
5844
7209
 
5845
- // If the screenshot 404s / errors, drop to the token render so claude.ai still
5846
- // shows a preview rather than a broken image. [BD-136]
7210
+ // If the screenshot 404s or errors, show the token render instead.
5847
7211
  function wireShotFallback() {
5848
7212
  var img = root.querySelector("[data-bd-shot]");
5849
7213
  if (!img) return;
5850
7214
  img.addEventListener("error", function () { shotFailed = true; if (lastOut) render(lastOut); });
5851
7215
  // The screenshot loads async; its real height only exists after it decodes.
5852
- // claude.ai reads the widget's documentElement height from the DOM, so re-pin
5853
- // the iframe height once the image has dimensions (a cached image may already
7216
+ // Some hosts read documentElement height from the DOM, so re-pin the widget
7217
+ // height once the image has dimensions (a cached image may already
5854
7218
  // be complete before this runs). [BD-136]
5855
7219
  img.addEventListener("load", scheduleResize);
5856
7220
  if (img.complete && img.naturalHeight) scheduleResize();
@@ -5863,43 +7227,30 @@ ${DS_KIT_CSS}
5863
7227
  syncHostFonts();
5864
7228
  // Only an http(s) URL is usable; reject any other scheme up front.
5865
7229
  var view = safeUrl(out.viewUrl);
5866
- // The iframe must load a PUBLIC, cookieless URL (the sandbox has no session
5867
- // cookie). previewUrl points at /preview/<fragmentId>; fall back to view if
5868
- // the host didn't supply one (e.g. catalog DSes, whose view is public). [BD-164]
5869
- var embed = safeUrl(out.previewUrl) || view;
5870
7230
  var isError = out.status === "error";
5871
7231
  var generating = out.status && out.status !== "completed" && !isError;
5872
7232
  var completed = !isError && !generating;
5873
- // claude.ai (can't iframe): prefer a REAL screenshot; fall back to the token
5874
- // render if there's no shotUrl or the image failed to load.
5875
- var useShot = !shotFailed && !!safeUrl(out.shotUrl);
5876
-
5877
- // ChatGPT's fullscreen "Open in <app>" button defaults to the iframe's
5878
- // current path, which is now the PUBLIC /preview page. Repoint it at the
5879
- // owner's editable /projects/<id> (they have the session cookie on the web,
5880
- // so it loads for them) so fullscreen Open still reaches their project. [BD-164]
7233
+ // Prefer the real screenshot on wide hosts. On compact hosts a desktop
7234
+ // screenshot scales into unreadable text, so use the responsive token UI.
7235
+ var useShot = window.innerWidth >= 640 && !shotFailed && !!safeUrl(out.shotUrl);
7236
+
7237
+ // Keep the host-provided "Open in <app>" action pointed at the owner-facing
7238
+ // project, where the user's browser session is available. [BD-164]
5881
7239
  if (completed && view && window.openai && window.openai.setOpenInAppUrl) {
5882
- try { window.openai.setOpenInAppUrl({ href: view }); } catch (e) {}
7240
+ try { window.openai.setOpenInAppUrl({ href: view }); } catch (e) { /* best-effort: optional host chrome; the in-card link still works */ }
5883
7241
  }
5884
7242
 
5885
- // Fullscreen: ChatGPT iframes the real page; claude.ai shows the full
5886
- // screenshot (scrollable) or the token showcase. [BD-136]
7243
+ // Fullscreen remains self-contained: show the full screenshot or token UI.
5887
7244
  if (displayMode === "fullscreen" && completed) {
5888
- if (canIframe() && embed) { root.innerHTML = iframeBody(out, embed, view, true); return; }
5889
- if (useShot) { root.innerHTML = '<div class="bd-card-shell bd-fs">' + shotBody(out, true) + footHtml(out) + '</div>'; wireShotFallback(); }
7245
+ if (useShot) { root.innerHTML = '<div class="bd-card-shell bd-fs">' + shotBody(out) + footHtml(out, true) + '</div>'; wireShotFallback(); }
5890
7246
  else { root.innerHTML = showcaseBody(out, true); wireShowcase(); }
5891
7247
  return;
5892
7248
  }
5893
7249
 
5894
- // Inline completed: ChatGPT = real-page iframe; claude.ai = real screenshot
5895
- // (capped) or token showcase, + a footer (Fullscreen + Open). [BD-136]
7250
+ // Inline completed: capped screenshot or token showcase, plus clear actions.
5896
7251
  if (completed) {
5897
- if (canIframe() && embed) {
5898
- root.innerHTML = '<div class="bd-card-shell">' + iframeBody(out, embed, view, false) + '</div>';
5899
- return;
5900
- }
5901
- var body = useShot ? shotBody(out, false) : showcaseBody(out, false);
5902
- root.innerHTML = '<div class="bd-card-shell">' + body + footHtml(out) + '</div>';
7252
+ var body = useShot ? shotBody(out) : showcaseBody(out, false);
7253
+ root.innerHTML = '<div class="bd-card-shell">' + body + footHtml(out, false) + '</div>';
5903
7254
  if (useShot) wireShotFallback();
5904
7255
  return;
5905
7256
  }
@@ -5911,7 +7262,7 @@ ${DS_KIT_CSS}
5911
7262
  var hero = errorBody(out);
5912
7263
  var cta = view
5913
7264
  ? '<div class="bd-cta-row"><span class="bd-cta-name">' + esc(out.name || "Design system") + '</span>' +
5914
- '<a class="bd-cta" data-bd-open href="' + esc(view) + '" target="_blank" rel="noopener">Open' + ICON_EXTERNAL + '</a></div>'
7265
+ '<a class="bd-cta" data-bd-open href="' + esc(view) + '" target="_blank" rel="noopener">Open showcase' + ICON_EXTERNAL + '</a></div>'
5915
7266
  : "";
5916
7267
  root.innerHTML = '<div class="bd-card-shell">' + hero + cta + "</div>";
5917
7268
  }
@@ -5920,9 +7271,9 @@ ${DS_KIT_CSS}
5920
7271
  // The MCP Apps client emits ui/notifications/size-changed (ResizeObserver on
5921
7272
  // documentElement + body); ChatGPT honors that and auto-sizes its widget.
5922
7273
  // claude.ai web does NOT honor size-changed \u2014 it reads the widget's
5923
- // documentElement height straight from the DOM and pins the iframe to whatever
5924
- // inline height the document carries. With no explicit height the iframe stays
5925
- // at 0 (verified live: a single size-changed{0,0} then the iframe sits at 0px;
7274
+ // documentElement height straight from the DOM and pins the widget to whatever
7275
+ // inline height the document carries. With no explicit height the widget stays
7276
+ // at 0 (verified live: a single size-changed{0,0} then the host clips it;
5926
7277
  // claude-ai-mcp#69). So on the DOM-reading hosts we set
5927
7278
  // document.documentElement.style.height ourselves, AFTER content + the async
5928
7279
  // screenshot have real height. [BD-136]
@@ -5960,14 +7311,14 @@ ${DS_KIT_CSS}
5960
7311
  if (typeof MutationObserver === "function") {
5961
7312
  new MutationObserver(scheduleResize).observe(root, { childList: true, subtree: true });
5962
7313
  }
5963
- } catch (e) {}
7314
+ } catch (e) { /* best-effort: sandbox without MutationObserver; the height pin just updates less often */ }
5964
7315
  try {
5965
7316
  if (typeof ResizeObserver === "function") {
5966
7317
  new ResizeObserver(scheduleResize).observe(root);
5967
7318
  }
5968
- } catch (e) {}
7319
+ } catch (e) { /* best-effort: sandbox without ResizeObserver; the height pin just updates less often */ }
5969
7320
 
5970
- // The widget runs in a sandboxed iframe (claude.ai); a bare <a target=_blank>
7321
+ // The widget runs in a sandboxed host; a bare <a target=_blank>
5971
7322
  // cannot open a tab or navigate the top frame, so the CTA looks dead. Route the
5972
7323
  // click through the host: MCP Apps app.openLink({url}) or OpenAI Apps
5973
7324
  // openai.openExternal({href}). Falls through to the native anchor for hosts
@@ -5977,19 +7328,18 @@ ${DS_KIT_CSS}
5977
7328
  var t = e.target;
5978
7329
  if (!t || !t.closest) return;
5979
7330
  if (t.closest("[data-bd-fullscreen]")) { e.preventDefault(); setDisplayMode("fullscreen"); return; }
5980
- if (t.closest("[data-bd-collapse]")) { e.preventDefault(); setDisplayMode("inline"); return; }
5981
7331
  var a = t.closest("a[data-bd-open]");
5982
7332
  if (!a) return;
5983
7333
  var url = a.getAttribute("href");
5984
7334
  if (!url) return;
5985
- if (bdApp && bdApp.openLink) {
7335
+ if (window.openai && window.openai.openExternal) {
5986
7336
  e.preventDefault();
5987
- try { bdApp.openLink({ url: url }); } catch (err) {}
7337
+ try { window.openai.openExternal({ href: url }); } catch (err) { /* best-effort: host declined openExternal; nothing else can open the link */ }
5988
7338
  return;
5989
7339
  }
5990
- if (window.openai && window.openai.openExternal) {
7340
+ if (bdApp && bdApp.openLink) {
5991
7341
  e.preventDefault();
5992
- try { window.openai.openExternal({ href: url }); } catch (err) {}
7342
+ try { bdApp.openLink({ url: url }); } catch (err) { /* best-effort: host declined openLink; nothing else can open the link */ }
5993
7343
  }
5994
7344
  });
5995
7345
 
@@ -6036,9 +7386,9 @@ ${DS_KIT_CSS}
6036
7386
  // this every later host-context-changed (theme/font) would rebuild the
6037
7387
  // DOM and lose scroll + active-section state in fullscreen. [BD-117]
6038
7388
  else if (ctx && ctx.availableDisplayModes && !sawDisplayModes && lastOut) { sawDisplayModes = true; render(lastOut); }
6039
- } catch (e) {}
7389
+ } catch (e) { /* best-effort: a malformed host context must not kill the widget; keep the last rendered state */ }
6040
7390
  };
6041
- if (app.connect) app.connect().catch(function () {});
7391
+ if (app.connect) app.connect().catch(function () { /* best-effort: a host that refuses the connect handshake still shows the static fallback */ });
6042
7392
  }
6043
7393
  } catch (e) { /* host without MCP Apps support; OpenAI bridge or static text covers it */ }
6044
7394
  })();
@@ -6048,8 +7398,132 @@ ${DS_KIT_CSS}
6048
7398
  }
6049
7399
  });
6050
7400
 
6051
- // ../shared/src/mcp/tools/design-system-tools.ts
7401
+ // ../shared/src/mcp/shopify-primitives.ts
6052
7402
  import { z as z5 } from "zod";
7403
+ function exportedStringConstants(files) {
7404
+ const constants = /* @__PURE__ */ new Map();
7405
+ const declaration = /export\s+const\s+([A-Za-z_$][\w$]*)(?:\s*:[^=;]+)?\s*=\s*([\s\S]*?);/g;
7406
+ const literal = /"((?:\\.|[^"\\])*)"|'((?:\\.|[^'\\])*)'/g;
7407
+ for (const { content } of files) {
7408
+ for (const match of content.matchAll(declaration)) {
7409
+ const expression = match[2] ?? "";
7410
+ const values = [...expression.matchAll(literal)].map(
7411
+ (part) => (part[1] ?? part[2] ?? "").replace(/\\([\\"'])/g, "$1")
7412
+ );
7413
+ const residue = expression.replace(literal, "").replace(/\+/g, "").trim();
7414
+ if (values.length && !residue) constants.set(match[1], values.join(""));
7415
+ }
7416
+ }
7417
+ return constants;
7418
+ }
7419
+ function importedMaterialNames(content, constants) {
7420
+ const names = /* @__PURE__ */ new Set();
7421
+ for (const match of content.matchAll(localImport)) {
7422
+ for (const item of (match[3] ?? "").split(",")) {
7423
+ const imported = item.trim().split(/\s+as\s+/)[0];
7424
+ if (imported && constants.has(imported)) names.add(imported);
7425
+ }
7426
+ }
7427
+ return names;
7428
+ }
7429
+ function replaceMaterialNames(source, names, constants) {
7430
+ let result = source;
7431
+ for (const name of names) {
7432
+ result = result.replace(
7433
+ new RegExp(`\\b${name.replace(/[$]/g, "\\$")}\\b`, "g"),
7434
+ JSON.stringify(constants.get(name))
7435
+ );
7436
+ }
7437
+ return result;
7438
+ }
7439
+ function materializeFile(file, constants) {
7440
+ const names = importedMaterialNames(file.content, constants);
7441
+ if (!names.size) return file;
7442
+ let cursor = 0;
7443
+ let content = "";
7444
+ for (const match of file.content.matchAll(localImport)) {
7445
+ content += replaceMaterialNames(file.content.slice(cursor, match.index), names, constants);
7446
+ content += match[0];
7447
+ cursor = (match.index ?? 0) + match[0].length;
7448
+ }
7449
+ content += replaceMaterialNames(file.content.slice(cursor), names, constants);
7450
+ return { ...file, content };
7451
+ }
7452
+ function materializeImportedMaterial(files) {
7453
+ const constants = exportedStringConstants(files);
7454
+ if (!constants.size) return files;
7455
+ return files.map((file) => materializeFile(file, constants));
7456
+ }
7457
+ function globalsSource(files) {
7458
+ return files.find(({ path: path5 }) => /(^|\/)globals\.css$/.test(path5))?.content ?? "";
7459
+ }
7460
+ function staticKitSource(files) {
7461
+ return files.find(({ path: path5 }) => /(^|\/)static-kit\.css$/.test(path5))?.content.trim() ?? "";
7462
+ }
7463
+ function buildShopifyTokensCss(files) {
7464
+ const globals = globalsSource(files);
7465
+ if (!globals.trim()) {
7466
+ throw new Error("Shopify primitives require a globals.css token source");
7467
+ }
7468
+ const vars = mirrorSingleTheme(parseCssVars(globals));
7469
+ const { fontVars, fontImport } = extractFontCss(globals);
7470
+ return [
7471
+ fontImport,
7472
+ cssFromRegistryVars(vars),
7473
+ fontVars,
7474
+ extractRadiusScaleCss(globals),
7475
+ extractShadowScaleCss(globals),
7476
+ componentRadiusCss(files),
7477
+ componentDepthCss(files)
7478
+ ].filter(Boolean).join("\n");
7479
+ }
7480
+ function buildShopifyPrimitiveSet(files) {
7481
+ const materializedFiles = materializeImportedMaterial(files);
7482
+ const tokensCss = buildShopifyTokensCss(materializedFiles);
7483
+ const componentKit = buildDsComponentCss(materializedFiles);
7484
+ const staticKitCss = staticKitSource(materializedFiles);
7485
+ const material = staticKitCss ? { ...componentKit, css: `${componentKit.css}
7486
+ ${staticKitCss}` } : componentKit;
7487
+ const themeFiles = buildDsShopifyFiles({ tokensCss, componentKit: material });
7488
+ return shopifyPrimitiveSetSchema.parse({
7489
+ contractVersion: SHOPIFY_PRIMITIVE_CONTRACT_VERSION,
7490
+ tokensCss,
7491
+ componentMaterialCss: material.css,
7492
+ coveredSourcePrimitives: componentKit.covered,
7493
+ sourcePrimitivesWithShopifyAdapters: componentKit.excluded,
7494
+ themeFiles
7495
+ });
7496
+ }
7497
+ function parseShopifyPrimitiveSet(value) {
7498
+ const parsed = shopifyPrimitiveSetSchema.safeParse(value);
7499
+ return parsed.success ? parsed.data : null;
7500
+ }
7501
+ var SHOPIFY_PRIMITIVE_CONTRACT_VERSION, shopifyThemeFileSchema, shopifyPrimitiveSetSchema, localImport;
7502
+ var init_shopify_primitives = __esm({
7503
+ "../shared/src/mcp/shopify-primitives.ts"() {
7504
+ "use strict";
7505
+ init_registry_css();
7506
+ init_preview_html();
7507
+ init_shopify_kit();
7508
+ SHOPIFY_PRIMITIVE_CONTRACT_VERSION = 1;
7509
+ shopifyThemeFileSchema = z5.object({
7510
+ path: z5.string().min(1),
7511
+ content: z5.string().min(1)
7512
+ });
7513
+ shopifyPrimitiveSetSchema = z5.object({
7514
+ contractVersion: z5.literal(SHOPIFY_PRIMITIVE_CONTRACT_VERSION),
7515
+ tokensCss: z5.string().min(1),
7516
+ componentMaterialCss: z5.string(),
7517
+ coveredSourcePrimitives: z5.array(z5.string()),
7518
+ sourcePrimitivesWithShopifyAdapters: z5.array(z5.string()),
7519
+ themeFiles: z5.array(shopifyThemeFileSchema).min(1)
7520
+ });
7521
+ localImport = /(^|\n)(\s*import\s*\{([^}]*)\}\s*from\s*["']\.\.?\/[^"']+["'];?)/g;
7522
+ }
7523
+ });
7524
+
7525
+ // ../shared/src/mcp/tools/design-system-tools.ts
7526
+ import { z as z6 } from "zod";
6053
7527
  function installCommandWithInit(installCommand) {
6054
7528
  return `${SHADCN_INIT_IF_MISSING}
6055
7529
 
@@ -6079,15 +7553,42 @@ function inlineBuildMd(projectId) {
6079
7553
  return `
6080
7554
  **Building inline (ChatGPT canvas / Claude artifact)?** There is no npm or Tailwind there, so the install above can't run. Call \`get-design-system-kit\` with \`target: "html"\` and projectId \`${projectId}\` to get this system's real tokens + a framework-free CSS kit you paste into one \`<style>\` and build with immediately.`;
6081
7555
  }
6082
- async function fetchProjectTokensCss(projectId, authContext) {
7556
+ async function fetchProjectTokensCss(projectId, apiFetch, authContext) {
6083
7557
  try {
6084
- const res = await fetch(`${APP_BASE_URL}/api/projects/${projectId}/registry/all.json`, {
7558
+ const res = await apiFetch(`${APP_BASE_URL}/api/projects/${projectId}/registry/all.json`, {
6085
7559
  headers: authHeaders(authContext)
6086
7560
  });
6087
7561
  if (!res.ok) return null;
6088
7562
  const json = await res.json();
6089
7563
  return { tokensCss: tokensCssFromRegistry(json), name: json.name, files: json.files ?? [] };
6090
- } catch {
7564
+ } catch (err) {
7565
+ console.warn(`fetchProjectTokensCss(${projectId}) failed`, err);
7566
+ return null;
7567
+ }
7568
+ }
7569
+ function parseProjectShopifyPrimitives(projectId, name, value) {
7570
+ const primitives = parseShopifyPrimitiveSet(value);
7571
+ if (!primitives) return null;
7572
+ return {
7573
+ name: typeof name === "string" ? name : `Project ${projectId.slice(0, 8)}`,
7574
+ primitives
7575
+ };
7576
+ }
7577
+ async function fetchProjectShopifyPrimitives(projectId, apiFetch, authContext) {
7578
+ try {
7579
+ const res = await apiFetch(
7580
+ `${APP_BASE_URL}/api/projects/${projectId}/shopify/all.json`,
7581
+ { headers: authHeaders(authContext) }
7582
+ );
7583
+ if (!res.ok) return null;
7584
+ const json = await res.json();
7585
+ return parseProjectShopifyPrimitives(
7586
+ projectId,
7587
+ json.name,
7588
+ json.shopifyPrimitives
7589
+ );
7590
+ } catch (err) {
7591
+ console.warn(`fetchProjectShopifyPrimitives(${projectId}) failed`, err);
6091
7592
  return null;
6092
7593
  }
6093
7594
  }
@@ -6128,25 +7629,50 @@ function completedShopifyKitStructuredContent(name, viewUrl, tokensCss, themeFil
6128
7629
  tokensCss
6129
7630
  };
6130
7631
  }
7632
+ function completedShopifyKitResult(name, viewUrl, source, primitives) {
7633
+ const { tokensCss, themeFiles } = primitives;
7634
+ return {
7635
+ content: [{
7636
+ type: "text",
7637
+ text: buildDsShopifyKit({ name, tokensCss, viewUrl, source, themeFiles })
7638
+ }],
7639
+ structuredContent: completedShopifyKitStructuredContent(
7640
+ name,
7641
+ viewUrl,
7642
+ tokensCss,
7643
+ themeFiles
7644
+ )
7645
+ };
7646
+ }
6131
7647
  async function fetchCatalog(designSystemId) {
6132
7648
  try {
6133
7649
  const [globalsRes, ...componentResults] = await Promise.all([
6134
7650
  fetch(`${REGISTRY_BASE_URL}/${designSystemId}/globals.json`),
6135
7651
  ...CATALOG_KIT_COMPONENTS.map(
6136
- (c) => fetch(`${REGISTRY_BASE_URL}/${designSystemId}/${c}.json`).catch(() => null)
7652
+ (c) => fetch(`${REGISTRY_BASE_URL}/${designSystemId}/${c}.json`).catch((err) => {
7653
+ console.warn(`catalog component fetch failed: ${designSystemId}/${c}`, err);
7654
+ return null;
7655
+ })
6137
7656
  )
6138
7657
  ]);
6139
7658
  if (!globalsRes.ok) return { tokensCss: "", files: [] };
6140
7659
  const globals = REGISTRY_ITEM_SCHEMA.safeParse(await globalsRes.json());
6141
7660
  if (!globals.success) return { tokensCss: "", files: [] };
6142
7661
  const files = [...globals.data.files ?? []];
6143
- for (const res of componentResults) {
6144
- if (!res?.ok) continue;
7662
+ for (const [i, res] of componentResults.entries()) {
7663
+ if (!res) continue;
7664
+ if (!res.ok) {
7665
+ console.warn(
7666
+ `catalog component unavailable: ${designSystemId}/${CATALOG_KIT_COMPONENTS[i]} (HTTP ${res.status})`
7667
+ );
7668
+ continue;
7669
+ }
6145
7670
  const item = REGISTRY_ITEM_SCHEMA.safeParse(await res.json().catch(() => null));
6146
7671
  if (item.success && item.data.files) files.push(...item.data.files);
6147
7672
  }
6148
7673
  return { tokensCss: tokensCssFromRegistry({ cssVars: globals.data.cssVars, files }), files };
6149
- } catch {
7674
+ } catch (error) {
7675
+ console.warn(`catalog registry fetch failed for ${designSystemId}`, error);
6150
7676
  return { tokensCss: "", files: [] };
6151
7677
  }
6152
7678
  }
@@ -6171,10 +7697,11 @@ function registerDesignSystemTools({
6171
7697
  provider,
6172
7698
  tracked,
6173
7699
  track,
6174
- authContext
7700
+ authContext,
7701
+ apiFetch = globalThis.fetch
6175
7702
  }, { enableShopifyKit = true } = {}) {
6176
- const searchInputSchema = z5.object({
6177
- query: z5.string().describe(
7703
+ const searchInputSchema = z6.object({
7704
+ query: z6.string().describe(
6178
7705
  "The user's app, UI, industry, or visual direction. Examples: 'World Cup betting app for friends', 'warm editorial ecommerce', or 'professional fintech dashboard'."
6179
7706
  )
6180
7707
  });
@@ -6269,7 +7796,7 @@ ${BUILD_TARGET_RULE}` : ""}`
6269
7796
  title: "Find a Design System for a UI Build",
6270
7797
  description: searchDescription(),
6271
7798
  inputSchema: searchInputSchema,
6272
- annotations: { readOnlyHint: true }
7799
+ annotations: READ_ONLY_TOOL_ANNOTATIONS
6273
7800
  },
6274
7801
  makeSearchHandler("find-design-system", true)
6275
7802
  );
@@ -6290,7 +7817,7 @@ ${BUILD_TARGET_RULE}` : ""}`
6290
7817
  structuredContent: { status: "error", name: `Project ${projectId.slice(0, 8)}`, viewUrl: viewUrl2, tokensCss: "" }
6291
7818
  };
6292
7819
  }
6293
- const result = await fetchProjectTokensCss(projectId, authContext);
7820
+ const result = await fetchProjectTokensCss(projectId, apiFetch, authContext);
6294
7821
  if (!result?.tokensCss) {
6295
7822
  return {
6296
7823
  content: [{ type: "text", text: `Could not load tokens for project "${projectId}". It may still be generating, or the id is wrong. Check ${viewUrl2}.` }],
@@ -6393,31 +7920,24 @@ ${fence3}` }],
6393
7920
  }) => tracked("get-design-system-kit", { target: "shopify", designSystemId, projectId, context }, async () => {
6394
7921
  if (projectId) {
6395
7922
  const viewUrl2 = `${APP_BASE_URL}/projects/${projectId}`;
6396
- const result = await fetchProjectTokensCss(projectId, authContext);
6397
- if (!result?.tokensCss) {
7923
+ const providerResult = await provider.getProjectShopifyPrimitives?.(projectId);
7924
+ const result = providerResult ? parseProjectShopifyPrimitives(
7925
+ projectId,
7926
+ providerResult.name,
7927
+ providerResult.shopifyPrimitives
7928
+ ) : await fetchProjectShopifyPrimitives(projectId, apiFetch, authContext);
7929
+ if (!result) {
6398
7930
  return {
6399
- content: [{ type: "text", text: `Could not load tokens for project "${projectId}". It may still be generating, or the id is wrong. Check ${viewUrl2}.` }],
7931
+ content: [{ type: "text", text: `Shopify version unavailable. Project "${projectId}" has no maintained Shopify primitive set yet. It may still be generating, belong to another account, or predate Shopify persistence. Check ${viewUrl2}.` }],
6400
7932
  structuredContent: { status: "error", name: `Project ${projectId.slice(0, 8)}`, viewUrl: viewUrl2, tokensCss: "" }
6401
7933
  };
6402
7934
  }
6403
- const componentKit2 = buildDsComponentCss(result.files);
6404
- const name = result.name ?? `Project ${projectId.slice(0, 8)}`;
6405
- const text2 = buildDsShopifyKit({
6406
- name,
6407
- tokensCss: result.tokensCss,
6408
- viewUrl: viewUrl2,
6409
- source: { kind: "project", projectId },
6410
- componentKit: componentKit2
6411
- });
6412
- return {
6413
- content: [{ type: "text", text: text2 }],
6414
- structuredContent: completedShopifyKitStructuredContent(
6415
- name,
6416
- viewUrl2,
6417
- result.tokensCss,
6418
- buildDsShopifyFiles({ tokensCss: result.tokensCss, componentKit: componentKit2 })
6419
- )
6420
- };
7935
+ return completedShopifyKitResult(
7936
+ result.name,
7937
+ viewUrl2,
7938
+ { kind: "project", projectId },
7939
+ result.primitives
7940
+ );
6421
7941
  }
6422
7942
  if (!designSystemId) {
6423
7943
  return {
@@ -6425,7 +7945,10 @@ ${fence3}` }],
6425
7945
  structuredContent: { status: "error", name: "Missing design system", tokensCss: "" }
6426
7946
  };
6427
7947
  }
6428
- const ds = await provider.getDesignSystemById(designSystemId, { includeComponents: false });
7948
+ const ds = await provider.getDesignSystemById(designSystemId, {
7949
+ includeComponents: false,
7950
+ includeShopifyPrimitives: true
7951
+ });
6429
7952
  if (!ds) {
6430
7953
  return {
6431
7954
  content: [{ type: "text", text: `Design system "${designSystemId}" not found. Call find-design-system first to get a valid id, or pass a projectId for a generated system.` }],
@@ -6433,30 +7956,20 @@ ${fence3}` }],
6433
7956
  };
6434
7957
  }
6435
7958
  const viewUrl = `${APP_BASE_URL}/design-systems/${designSystemId}`;
6436
- const { tokensCss, files } = await fetchCatalog(designSystemId);
6437
- if (!tokensCss) {
7959
+ const primitives = parseShopifyPrimitiveSet(ds.metadata.shopifyPrimitives);
7960
+ if (!primitives) {
7961
+ const text = ds.metadata.shopifyAccess === "denied" ? `Shopify target unavailable. The Shopify kit is a private pilot and this API key is not in it, so the stored theme for "${designSystemId}" was withheld. Nothing is wrong with the design system. Ask for pilot access, or use target "react" or "html". See ${viewUrl}.` : `Shopify version unavailable. Design system "${designSystemId}" has no maintained Shopify primitive set. Shopify output is unavailable until that system is seeded and certified; no generic fallback was returned. See ${viewUrl}.`;
6438
7962
  return {
6439
- content: [{ type: "text", text: `Could not load tokens for "${designSystemId}". The full live showcase is at ${viewUrl}.` }],
7963
+ content: [{ type: "text", text }],
6440
7964
  structuredContent: { status: "error", name: ds.metadata.title, viewUrl, tokensCss: "" }
6441
7965
  };
6442
7966
  }
6443
- const componentKit = buildDsComponentCss(files);
6444
- const text = buildDsShopifyKit({
6445
- name: ds.metadata.title,
6446
- tokensCss,
7967
+ return completedShopifyKitResult(
7968
+ ds.metadata.title,
6447
7969
  viewUrl,
6448
- source: { kind: "catalog", designSystemId },
6449
- componentKit
6450
- });
6451
- return {
6452
- content: [{ type: "text", text }],
6453
- structuredContent: completedShopifyKitStructuredContent(
6454
- ds.metadata.title,
6455
- viewUrl,
6456
- tokensCss,
6457
- buildDsShopifyFiles({ tokensCss, componentKit })
6458
- )
6459
- };
7970
+ { kind: "catalog", designSystemId },
7971
+ primitives
7972
+ );
6460
7973
  });
6461
7974
  server.registerTool(
6462
7975
  "get-design-system-kit",
@@ -6488,22 +8001,22 @@ existing setup; otherwise use the default Base UI. Both flavors install to the
6488
8001
  same components/ui/ paths.
6489
8002
 
6490
8003
  ${BUILD_TARGET_RULE}`,
6491
- inputSchema: z5.object({
8004
+ inputSchema: z6.object({
6492
8005
  // Off-pilot callers must not see the target at all, not merely be refused it.
6493
- target: z5.enum(
8006
+ target: z6.enum(
6494
8007
  enableShopifyKit ? ["react", "html", "shopify"] : ["react", "html"]
6495
8008
  ).describe(`Required build target. Use "react" for any host with filesystem + terminal; use "html" only for inline chat canvases/artifacts.${enableShopifyKit ? ' Use "shopify" for Shopify stores and themes.' : ""}`),
6496
- designSystemId: z5.string().min(1).optional().describe(`Catalog design system ID from find-design-system. Required for target react; for target${enableShopifyKit ? "s html and shopify" : " html"} pass this or projectId.`),
6497
- projectId: z5.string().min(1).optional().describe(`Generated project ID from create-design-system / get-design-system-status. Supported with target${enableShopifyKit ? 's "html" and "shopify"' : ' "html"'}.`),
6498
- component: z5.union([z5.string(), z5.array(z5.string())]).optional().describe("React target only. Pass only when the user asks to inspect specific component source code. Omit to scaffold the entire design system."),
6499
- library: z5.enum(["radix", "base-ui"]).optional().describe('React target only, default "base-ui". Pass "radix" when the project already uses Radix primitives.'),
8009
+ designSystemId: z6.string().min(1).optional().describe(`Catalog design system ID from find-design-system. Required for target react; for target${enableShopifyKit ? "s html and shopify" : " html"} pass this or projectId.`),
8010
+ projectId: z6.string().min(1).optional().describe(`Generated project ID from create-design-system / get-design-system-status. Supported with target${enableShopifyKit ? 's "html" and "shopify"' : ' "html"'}.`),
8011
+ component: z6.union([z6.string(), z6.array(z6.string())]).optional().describe("React target only. Pass only when the user asks to inspect specific component source code. Omit to scaffold the entire design system."),
8012
+ library: z6.enum(["radix", "base-ui"]).optional().describe('React target only, default "base-ui". Pass "radix" when the project already uses Radix primitives.'),
6500
8013
  context: CONTEXT_ARG
6501
8014
  }),
6502
8015
  outputSchema: buildKitOutputSchema(enableShopifyKit),
6503
- annotations: { readOnlyHint: true }
8016
+ annotations: READ_ONLY_TOOL_ANNOTATIONS
6504
8017
  // This tool returns build instructions, not a visual preview. Attaching
6505
- // the preview template makes ChatGPT render a nested showcase iframe;
6506
- // when that iframe is unavailable the user sees a large blank card even
8018
+ // the preview template makes ChatGPT render a large visual card;
8019
+ // when that preview is unavailable the user sees a large blank card even
6507
8020
  // though the kit succeeded. preview-design-system remains the dedicated
6508
8021
  // interactive preview path. [BD-240]
6509
8022
  },
@@ -6717,15 +8230,18 @@ _${installNote}_`,
6717
8230
 
6718
8231
  **Use this on any "build/make me <app>" request, even when the user never mentions design or branding** (a design system IS the app's brand). But DON'T jump straight here: first call \`find-design-system\` with the app description, show the user the top ~4 matches, and ask which to start from (passed here as \`baseDesignSystemId\`) or whether to go fully custom. Then derive the \`prompt\` from the app (name, domain, vibe), generate, show it with \`get-design-system-status\`, and build with that brand. Also use it when the user explicitly wants a **custom** design system instead of installing an existing one via \`get-design-system-kit\`.
6719
8232
 
8233
+ **When the user has an existing brand** (their site, Figma file, or stylesheet): run \`extract-design-system\` or \`extract-from-url\` first and pass the resulting token sheet (the fenced \`\`\`css block, verbatim: the \`:root { ... }\` and optional \`.dark { ... }\` blocks) as \`brandCss\`. The generation is then pinned to that brand: no catalog aesthetic is retrieved, the sheet's colors, fonts and radii override everything, and later design-system switches are refused. Copy the block exactly; do not retype or trim it.
8234
+
6720
8235
  How it works:
6721
8236
  1. Sends the user's prompt to Better Design's generation pipeline (same flow the web chat input uses) and returns IMMEDIATELY with the project id + project URL.
6722
8237
  2. Generation runs in the background and typically takes 1-3 minutes.
6723
8238
  3. Call \`get-design-system-status\` with the returned project id (wait ~60 seconds first) to get the live preview URL and the final \`npx shadcn add <registry-url>\` install command once it completes.
6724
8239
 
6725
8240
  Share the project URL with the user right away so they can watch the generation live.`,
6726
- inputSchema: z5.object({
6727
- prompt: z5.string().min(1).max(1e4).describe("Describe the design system the user wants. E.g. 'A warm, minimal design system for a personal finance app, soft pastels, rounded corners' or 'A brutalist developer tool aesthetic with sharp edges and monospace type'."),
6728
- baseDesignSystemId: z5.string().optional().describe("Optional. The catalog id the USER picked from the `find-design-system` matches you showed them, used to bias generation toward that aesthetic (e.g. a theme tweak on top of Linear). Leave empty only when the user chose fully custom.")
8241
+ inputSchema: z6.object({
8242
+ prompt: z6.string().min(1).max(1e4).describe("Describe the design system the user wants. E.g. 'A warm, minimal design system for a personal finance app, soft pastels, rounded corners' or 'A brutalist developer tool aesthetic with sharp edges and monospace type'."),
8243
+ baseDesignSystemId: z6.string().optional().describe("Optional. The catalog id the USER picked from the `find-design-system` matches you showed them, used to bias generation toward that aesthetic (e.g. a theme tweak on top of Linear). Leave empty only when the user chose fully custom."),
8244
+ brandCss: z6.string().max(5e4).optional().describe("Optional. The user's extracted brand token sheet, verbatim: the fenced css block from extract-design-system / extract-from-url (`:root { --background: ...; }` plus optional `.dark { ... }`). When set, generation builds FROM this brand instead of a catalog look-alike, and baseDesignSystemId is ignored (a catalog base would leak its identity past the brand tokens).")
6729
8245
  }),
6730
8246
  annotations: {
6731
8247
  readOnlyHint: false,
@@ -6733,7 +8249,7 @@ Share the project URL with the user right away so they can watch the generation
6733
8249
  openWorldHint: false
6734
8250
  }
6735
8251
  },
6736
- async ({ prompt, baseDesignSystemId }) => tracked("create-design-system", { query: prompt, hasBase: Boolean(baseDesignSystemId) }, async () => {
8252
+ async ({ prompt, baseDesignSystemId, brandCss }) => tracked("create-design-system", { query: prompt, hasBase: Boolean(baseDesignSystemId), hasBrand: Boolean(brandCss) }, async () => {
6737
8253
  const startUrl = `${APP_BASE_URL}/api/projects/anonymous`;
6738
8254
  let projectId;
6739
8255
  const requestHeaders = { "Content-Type": "application/json" };
@@ -6742,12 +8258,13 @@ Share the project URL with the user right away so they can watch the generation
6742
8258
  requestHeaders.Authorization = `Bearer ${bearer2}`;
6743
8259
  }
6744
8260
  try {
6745
- const startRes = await fetch(startUrl, {
8261
+ const startRes = await apiFetch(startUrl, {
6746
8262
  method: "POST",
6747
8263
  headers: requestHeaders,
6748
8264
  body: JSON.stringify({
6749
8265
  value: prompt,
6750
- ...baseDesignSystemId ? { designSystemId: baseDesignSystemId } : {}
8266
+ ...baseDesignSystemId ? { designSystemId: baseDesignSystemId } : {},
8267
+ ...brandCss ? { brandCss } : {}
6751
8268
  })
6752
8269
  });
6753
8270
  if (!startRes.ok) {
@@ -6768,10 +8285,10 @@ Share the project URL with the user right away so they can watch the generation
6768
8285
  "",
6769
8286
  `Project id: \`${projectId}\``,
6770
8287
  "",
6771
- ...authContext ? [`Saved to your Better Design account. Watch it generate live:`] : [`Watch it generate live:`],
8288
+ ...authContext ? [`Saved to your Better Design account. Watch it generate live (sign in with the account that created it, this link is private to you):`] : [`Watch it generate live:`],
6772
8289
  projectUrl,
6773
8290
  "",
6774
- `Generation takes 1-3 minutes. Share the project link with the user now, wait ~60 seconds, then call \`get-design-system-status\` with the project id to get the live preview URL and the final install command.`,
8291
+ `Generation takes 1-3 minutes. Wait ~60 seconds, then call \`get-design-system-status\` with the project id: it returns a shareable preview link that opens without signing in, plus the final install command. Give the user that link, not this one.`,
6775
8292
  inlineBuildMd(projectId)
6776
8293
  ];
6777
8294
  return { content: [{ type: "text", text: lines.join("\n") }] };
@@ -6786,7 +8303,7 @@ Share the project URL with the user right away so they can watch the generation
6786
8303
  const statusUrl = `${APP_BASE_URL}/api/projects/${projectId}`;
6787
8304
  let project;
6788
8305
  try {
6789
- const res = await fetch(statusUrl, { headers });
8306
+ const res = await apiFetch(statusUrl, { headers });
6790
8307
  if (!res.ok) {
6791
8308
  return { content: [{ type: "text", text: `Could not read project ${projectId} (HTTP ${res.status}). Check the id, or view it at ${APP_BASE_URL}/projects/${projectId}.` }] };
6792
8309
  }
@@ -6803,7 +8320,7 @@ npx shadcn@latest add ${registryUrl}
6803
8320
  let tokensCss = "";
6804
8321
  let dsName = `Project ${projectId.slice(0, 8)}`;
6805
8322
  try {
6806
- const res = await fetch(registryUrl);
8323
+ const res = await apiFetch(registryUrl);
6807
8324
  if (res.ok) {
6808
8325
  const json = await res.json();
6809
8326
  if (json.meta?.installInstructions) body = json.meta.installInstructions;
@@ -6813,10 +8330,17 @@ npx shadcn@latest add ${registryUrl}
6813
8330
  }
6814
8331
  } catch {
6815
8332
  }
8333
+ const previewUrl = project.previewFragmentId ? `${APP_BASE_URL}/preview/${project.previewFragmentId}` : projectUrl;
8334
+ const shareLines = project.previewFragmentId ? [
8335
+ `Shareable preview, opens without signing in:`,
8336
+ previewUrl,
8337
+ ""
8338
+ ] : [];
6816
8339
  const lines = [
6817
8340
  `# Design system ready`,
6818
8341
  "",
6819
- ...authContext ? [`Saved to your Better Design account. View, preview, and re-grab the install command anytime here:`] : [`View and preview your design system anytime here:`],
8342
+ ...shareLines,
8343
+ ...authContext ? [`Saved to your Better Design account. View, edit, and re-grab the install command here (sign in with the account that created it, this link is private to you):`] : [`View and preview your design system anytime here:`],
6820
8344
  projectUrl,
6821
8345
  "",
6822
8346
  `Project id: \`${projectId}\``,
@@ -6832,7 +8356,6 @@ npx shadcn@latest add ${registryUrl}
6832
8356
  "",
6833
8357
  `Now help the user finish, don't stop here. No icon library has been picked yet: call \`find-icon-library\` (query from the brand's personality), SHOW them the closest matches and let them pick one, then \`install-icons\`. After that, ask if they want to start building components.`
6834
8358
  ];
6835
- const previewUrl = project.previewFragmentId ? `${APP_BASE_URL}/preview/${project.previewFragmentId}` : projectUrl;
6836
8359
  const shotUrl = project.previewFragmentId ? `${APP_BASE_URL}/api/og/preview?fragment=${project.previewFragmentId}` : void 0;
6837
8360
  return {
6838
8361
  content: [{ type: "text", text: lines.join("\n") }],
@@ -6870,8 +8393,8 @@ Check again in ~30 seconds. Watch it live on the project page: ${projectUrl}` }]
6870
8393
  const statusConfig = {
6871
8394
  title: "Get Design System Generation Status",
6872
8395
  description: `Check on a design system generation started with 'create-design-system'. Returns progress while generating, and the live preview URL + the \`npx shadcn add\` install command once complete. Generation typically takes 1-3 minutes; poll every ~30 seconds until it reports ready. Once ready, ask the user what's next (icon library, install, or build) instead of stopping.`,
6873
- inputSchema: z5.object({
6874
- projectId: z5.string().min(1).describe("The project id returned by create-design-system."),
8396
+ inputSchema: z6.object({
8397
+ projectId: z6.string().min(1).describe("The project id returned by create-design-system."),
6875
8398
  context: CONTEXT_ARG
6876
8399
  }),
6877
8400
  outputSchema: PREVIEW_OUTPUT_SCHEMA,
@@ -6894,8 +8417,8 @@ Check again in ~30 seconds. Watch it live on the project page: ${projectUrl}` }]
6894
8417
  {
6895
8418
  title: "Preview a Design System",
6896
8419
  description: `Show the user a visual preview of a design system from the catalog (use the id from 'find-design-system'). Returns a token-true HTML preview: palette, typography, buttons, card, input, badge, plus a link to the full live showcase. Use this when the user wants to SEE a design system before installing it.`,
6897
- inputSchema: z5.object({
6898
- designSystemId: z5.string().min(1).describe("Design system id from find-design-system, e.g. 'linear', 'editorial-warm'."),
8420
+ inputSchema: z6.object({
8421
+ designSystemId: z6.string().min(1).describe("Design system id from find-design-system, e.g. 'linear', 'editorial-warm'."),
6899
8422
  context: CONTEXT_ARG
6900
8423
  }),
6901
8424
  outputSchema: PREVIEW_OUTPUT_SCHEMA,
@@ -6945,8 +8468,7 @@ Check again in ~30 seconds. Watch it live on the project page: ${projectUrl}` }]
6945
8468
  status: "completed",
6946
8469
  name: ds.metadata.title,
6947
8470
  viewUrl,
6948
- // Real screenshot of the showcase, for hosts that can't iframe it
6949
- // (claude.ai). The widget shows this as the inline image. [BD-136]
8471
+ // Real screenshot of the showcase for the self-contained widget.
6950
8472
  shotUrl: `${APP_BASE_URL}/api/og/preview?id=${encodeURIComponent(designSystemId)}`,
6951
8473
  tokensCss
6952
8474
  }
@@ -6961,55 +8483,26 @@ var init_design_system_tools = __esm({
6961
8483
  init_guardrails();
6962
8484
  init_html_kit();
6963
8485
  init_shopify_kit();
8486
+ init_shopify_primitives();
6964
8487
  init_preview_html();
6965
8488
  init_shared();
6966
- REGISTRY_ITEM_SCHEMA = z5.object({
6967
- cssVars: z5.object({
6968
- light: z5.record(z5.string(), z5.string()).optional(),
6969
- dark: z5.record(z5.string(), z5.string()).optional(),
6970
- theme: z5.record(z5.string(), z5.string()).optional()
8489
+ REGISTRY_ITEM_SCHEMA = z6.object({
8490
+ cssVars: z6.object({
8491
+ light: z6.record(z6.string(), z6.string()).optional(),
8492
+ dark: z6.record(z6.string(), z6.string()).optional(),
8493
+ theme: z6.record(z6.string(), z6.string()).optional()
6971
8494
  }).optional(),
6972
- files: z5.array(z5.object({ path: z5.string().optional(), content: z5.string().optional() })).optional()
8495
+ files: z6.array(z6.object({ path: z6.string().optional(), content: z6.string().optional() })).optional()
6973
8496
  });
6974
8497
  SHADCN_INIT_IF_MISSING = "[ -f components.json ] || npx shadcn@latest init -d -y";
6975
8498
  SAFE_COMPONENT_NAME_RE = /^[a-z0-9][a-z0-9-]*$/i;
6976
8499
  CATALOG_KIT_COMPONENTS = ["button", "input", "card", "badge", "switch"];
6977
- DS_INDEX_SCHEMA = z5.object({
6978
- baseComponents: z5.array(z5.object({ name: z5.string() })).optional()
8500
+ DS_INDEX_SCHEMA = z6.object({
8501
+ baseComponents: z6.array(z6.object({ name: z6.string() })).optional()
6979
8502
  });
6980
8503
  }
6981
8504
  });
6982
8505
 
6983
- // ../shared/src/registry-css.ts
6984
- function parseCssVars(css) {
6985
- const out = { theme: {}, light: {}, dark: {} };
6986
- const matchBlock = (selector) => {
6987
- const re = new RegExp(`${selector}\\s*\\{([^}]*)\\}`, "g");
6988
- const blocks = [];
6989
- let m;
6990
- while (m = re.exec(css)) blocks.push(m[1]);
6991
- return blocks.join("\n");
6992
- };
6993
- const extract = (body) => {
6994
- const result = {};
6995
- const clean = body.replace(/\/\*[\s\S]*?\*\//g, "");
6996
- const declRe = /--([a-zA-Z0-9_-]+)\s*:\s*([^;]+);/g;
6997
- let m;
6998
- while (m = declRe.exec(clean)) {
6999
- result[m[1]] = m[2].replace(/\s+/g, " ").trim();
7000
- }
7001
- return result;
7002
- };
7003
- out.light = extract(matchBlock(":root"));
7004
- out.dark = extract(matchBlock("\\.dark"));
7005
- return out;
7006
- }
7007
- var init_registry_css = __esm({
7008
- "../shared/src/registry-css.ts"() {
7009
- "use strict";
7010
- }
7011
- });
7012
-
7013
8506
  // ../shared/src/mcp/color-to-oklch.ts
7014
8507
  function clamp01(n) {
7015
8508
  return Math.min(1, Math.max(0, n));
@@ -7071,6 +8564,16 @@ function parseRgb(value) {
7071
8564
  a: parseAlpha(alpha)
7072
8565
  });
7073
8566
  }
8567
+ function parseBareTriplet(value) {
8568
+ const parts = value.split(",").map((p) => p.trim());
8569
+ if (parts.length !== 3 && parts.length !== 4) return null;
8570
+ if (!parts.every((p) => /^\d{1,3}(\.\d+)?$/.test(p))) return null;
8571
+ const [r, g, b] = parts.map(Number);
8572
+ if (![r, g, b].every((n) => n >= 0 && n <= 255)) return null;
8573
+ const a = parts.length === 4 ? Number(parts[3]) : 1;
8574
+ if (a < 0 || a > 1) return null;
8575
+ return finiteRgba({ r: r / 255, g: g / 255, b: b / 255, a });
8576
+ }
7074
8577
  function hueToRgb(p, q, t) {
7075
8578
  let tt = t;
7076
8579
  if (tt < 0) tt += 1;
@@ -7157,6 +8660,7 @@ function toOklch(value) {
7157
8660
  if (v.startsWith("#")) rgba = parseHex(v);
7158
8661
  else if (/^rgba?\(/i.test(v)) rgba = parseRgb(v);
7159
8662
  else if (/^hsla?\(/i.test(v)) rgba = parseHsl(v);
8663
+ else if (v.includes(",")) rgba = parseBareTriplet(v);
7160
8664
  return rgba ? rgbaToOklch(rgba) : null;
7161
8665
  }
7162
8666
  var NAMED_HEX, PASSTHROUGH_KEYWORDS, NON_COLOR_KEYWORDS;
@@ -7355,7 +8859,7 @@ function normalizeMap(raw) {
7355
8859
  }
7356
8860
  return { tokens, kinds };
7357
8861
  }
7358
- function extractDesignSystem(css, name) {
8862
+ function collectCssVars(css) {
7359
8863
  const { light: rootLight, dark: rootDark } = parseCssVars(css);
7360
8864
  const themeVars = extractThemeBlock(css);
7361
8865
  const mergedLight = {};
@@ -7363,8 +8867,11 @@ function extractDesignSystem(css, name) {
7363
8867
  mergedLight[denamespace(k)] = v;
7364
8868
  }
7365
8869
  Object.assign(mergedLight, rootLight);
7366
- const light = normalizeMap(mergedLight);
7367
- const dark = normalizeMap(rootDark);
8870
+ return { light: mergedLight, dark: rootDark };
8871
+ }
8872
+ function buildExtractionResult(raw, name) {
8873
+ const light = normalizeMap(raw.light);
8874
+ const dark = normalizeMap(raw.dark);
7368
8875
  const hasLight = Object.keys(light.tokens).length > 0;
7369
8876
  const hasDark = Object.keys(dark.tokens).length > 0;
7370
8877
  const themeMode = hasLight && hasDark ? "dual" : hasDark ? "dark" : hasLight ? "light" : "none";
@@ -7429,6 +8936,8 @@ function formatExtraction(result) {
7429
8936
  };
7430
8937
  const meta = [
7431
8938
  result.name ? `**Source:** ${result.name}` : null,
8939
+ result.sources && result.sources.length > 1 ? `**Merged from:** ${result.sources.join(", ")}` : null,
8940
+ result.aliased && result.aliased.length ? `**Vendor names mapped:** ${result.aliased.map(([f, t]) => `--${f} \u2192 --${t}`).join(", ")}` : null,
7432
8941
  `**Theme:** ${modeLabel[result.themeMode]}`,
7433
8942
  `**Tokens extracted:** ${result.tokenCount}`,
7434
8943
  `**Colors normalized to oklch.**`
@@ -7455,7 +8964,7 @@ function formatExtraction(result) {
7455
8964
  }
7456
8965
  sections.push(
7457
8966
  "## Token sheet (oklch-normalized, paste into globals.css)\n\n```css\n" + blocks.join("\n\n") + "\n```",
7458
- "## How to use\n- Style new components against these tokens (`bg-background`, `text-foreground`, `bg-primary`, `border-border`, `ring-ring`) so they match the existing app.\n- To turn this into a full Better Design system, pass the palette to `create-design-system` as the brand description."
8967
+ "## How to use\n- Style new components against these tokens (`bg-background`, `text-foreground`, `bg-primary`, `border-border`, `ring-ring`) so they match the existing app.\n- To turn this into a full Better Design system, pass the token sheet above (the fenced css block, verbatim) to `create-design-system` as `brandCss`. Generation then builds FROM this brand instead of a catalog look-alike."
7459
8968
  );
7460
8969
  return sections.join("\n\n");
7461
8970
  }
@@ -7506,9 +9015,198 @@ var init_extract_design_system = __esm({
7506
9015
  }
7507
9016
  });
7508
9017
 
9018
+ // ../shared/src/mcp/brand-sources.ts
9019
+ function inferSourceKind(content) {
9020
+ const t = content.trim();
9021
+ if (!t.startsWith("{") && !t.startsWith("[")) return "css";
9022
+ let parsed;
9023
+ try {
9024
+ parsed = JSON.parse(t);
9025
+ } catch {
9026
+ return "css";
9027
+ }
9028
+ if (t.includes('"$value"')) return "tokens";
9029
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
9030
+ const keys = Object.keys(parsed);
9031
+ if (keys.some((k) => COMPUTED_ROLES.has(k))) return "computed";
9032
+ }
9033
+ return "tokens";
9034
+ }
9035
+ function buildRoleSampleScript() {
9036
+ const entries = Object.keys(ROLE_MAP).map((role) => {
9037
+ const selector = ROLE_SELECTORS[role];
9038
+ const propList = JSON.stringify(Object.keys(ROLE_MAP[role]));
9039
+ return ` ${role}: pick(document.querySelector(${JSON.stringify(selector)}), ${propList}),`;
9040
+ }).join("\n");
9041
+ return `(() => {
9042
+ const pick = (el, props) => {
9043
+ if (!el) return null;
9044
+ const cs = getComputedStyle(el);
9045
+ return Object.fromEntries(props.map((p) => [p, cs[p]]));
9046
+ };
9047
+ return {
9048
+ ${entries}
9049
+ };
9050
+ })()`;
9051
+ }
9052
+ function parseJsonOrNull(content) {
9053
+ try {
9054
+ return JSON.parse(content);
9055
+ } catch {
9056
+ return null;
9057
+ }
9058
+ }
9059
+ function computedToVars(content) {
9060
+ const out = {};
9061
+ const roles = parseJsonOrNull(content);
9062
+ if (!roles || typeof roles !== "object") return out;
9063
+ for (const [role, styles] of Object.entries(roles)) {
9064
+ if (!COMPUTED_ROLES.has(role) || !styles || typeof styles !== "object") continue;
9065
+ const map = ROLE_MAP[role];
9066
+ for (const [prop, token] of Object.entries(map)) {
9067
+ const value = styles[prop];
9068
+ if (typeof value === "string" && value.trim()) out[token] = value.trim();
9069
+ }
9070
+ }
9071
+ if (out.border) out.input = out.border;
9072
+ return out;
9073
+ }
9074
+ function figmaColorToRgbString(v) {
9075
+ const { r, g, b, a } = v;
9076
+ if (typeof r !== "number" || typeof g !== "number" || typeof b !== "number") return null;
9077
+ const to255 = (n) => Math.round(Math.min(1, Math.max(0, n)) * 255);
9078
+ const alpha = typeof a === "number" && a < 1 ? `, ${a}` : "";
9079
+ return `rgb(${to255(r)}, ${to255(g)}, ${to255(b)}${alpha})`;
9080
+ }
9081
+ function kebab(part) {
9082
+ return part.replace(/([a-z0-9])([A-Z])/g, "$1-$2").replace(/[\s_]+/g, "-").toLowerCase();
9083
+ }
9084
+ function tokensToVars(content) {
9085
+ const out = {};
9086
+ const walk = (node, path5) => {
9087
+ if (!node || typeof node !== "object" || Array.isArray(node)) return;
9088
+ const obj = node;
9089
+ const value = "$value" in obj ? obj.$value : "value" in obj && "type" in obj ? obj.value : void 0;
9090
+ if (value !== void 0) {
9091
+ const name = path5.map(kebab).join("-");
9092
+ if (!name) return;
9093
+ if (typeof value === "string" || typeof value === "number") {
9094
+ out[name] = String(value);
9095
+ } else if (Array.isArray(value) && value.every((x) => typeof x === "string")) {
9096
+ out[name] = value.join(", ");
9097
+ } else if (value && typeof value === "object") {
9098
+ const rgb = figmaColorToRgbString(value);
9099
+ if (rgb) out[name] = rgb;
9100
+ }
9101
+ return;
9102
+ }
9103
+ for (const [k, v] of Object.entries(obj)) {
9104
+ if (k.startsWith("$")) continue;
9105
+ walk(v, [...path5, k]);
9106
+ }
9107
+ };
9108
+ walk(parseJsonOrNull(content), []);
9109
+ return out;
9110
+ }
9111
+ function applyAliases(vars) {
9112
+ const applied = [];
9113
+ for (const [from, to] of SEMANTIC_ALIASES) {
9114
+ if (vars[from] !== void 0 && vars[to] === void 0) {
9115
+ vars[to] = vars[from];
9116
+ applied.push([from, to]);
9117
+ }
9118
+ }
9119
+ return applied;
9120
+ }
9121
+ function extractFromSources(sources, name) {
9122
+ const light = {};
9123
+ const dark = {};
9124
+ for (const source of sources) {
9125
+ const kind = source.kind ?? inferSourceKind(source.content);
9126
+ if (kind === "css") {
9127
+ const parsed = collectCssVars(source.content);
9128
+ Object.assign(light, parsed.light);
9129
+ Object.assign(dark, parsed.dark);
9130
+ } else if (kind === "computed") {
9131
+ Object.assign(light, computedToVars(source.content));
9132
+ } else {
9133
+ Object.assign(light, tokensToVars(source.content));
9134
+ }
9135
+ }
9136
+ const aliased = applyAliases(light);
9137
+ for (const pair of applyAliases(dark)) {
9138
+ if (!aliased.some(([f, t]) => f === pair[0] && t === pair[1])) aliased.push(pair);
9139
+ }
9140
+ const result = buildExtractionResult({ light, dark }, name);
9141
+ result.sources = sources.map((s) => s.name);
9142
+ if (aliased.length) result.aliased = aliased;
9143
+ return result;
9144
+ }
9145
+ var ROLE_MAP, COMPUTED_ROLES, ROLE_SELECTORS, SEMANTIC_ALIASES;
9146
+ var init_brand_sources = __esm({
9147
+ "../shared/src/mcp/brand-sources.ts"() {
9148
+ "use strict";
9149
+ init_extract_design_system();
9150
+ ROLE_MAP = {
9151
+ body: { backgroundColor: "background", color: "foreground", fontFamily: "font-sans" },
9152
+ heading: { fontFamily: "font-heading" },
9153
+ // The link color is the closest rendered stand-in for an accent. On sites
9154
+ // where links reuse the primary, the alias fill leaves primary untouched, so
9155
+ // this can only add information, never fight the button's own sample.
9156
+ link: { color: "accent" },
9157
+ button: {
9158
+ backgroundColor: "primary",
9159
+ color: "primary-foreground",
9160
+ borderRadius: "radius-button"
9161
+ },
9162
+ input: { borderColor: "border", borderRadius: "radius-input" },
9163
+ card: {
9164
+ backgroundColor: "card",
9165
+ color: "card-foreground",
9166
+ borderRadius: "radius-card",
9167
+ boxShadow: "shadow-card"
9168
+ }
9169
+ };
9170
+ COMPUTED_ROLES = new Set(Object.keys(ROLE_MAP));
9171
+ ROLE_SELECTORS = {
9172
+ body: "body",
9173
+ heading: "h1,h2",
9174
+ link: "a[href]",
9175
+ button: "button,[role=button],.btn,input[type=submit]",
9176
+ input: "input:not([type=hidden]),textarea,select",
9177
+ card: "[class*=card]"
9178
+ };
9179
+ SEMANTIC_ALIASES = [
9180
+ ["color-background", "background"],
9181
+ ["color-foreground", "foreground"],
9182
+ ["color-button", "primary"],
9183
+ ["color-button-text", "primary-foreground"],
9184
+ ["color-secondary-button", "secondary"],
9185
+ ["color-secondary-button-text", "secondary-foreground"],
9186
+ ["color-link", "accent"],
9187
+ ["font-body-family", "font-sans"],
9188
+ ["button", "primary"],
9189
+ ["button-text", "primary-foreground"]
9190
+ ];
9191
+ }
9192
+ });
9193
+
7509
9194
  // ../shared/src/mcp/tools/extract-tools.ts
7510
- import { z as z6 } from "zod";
7511
- function registerExtractTools({ server, tracked }) {
9195
+ import { z as z7 } from "zod";
9196
+ function authHeaders2(authContext) {
9197
+ if (!authContext) return {};
9198
+ const bearer2 = authContext.kind === "api-key" ? authContext.apiKey : authContext.accessToken;
9199
+ return { Authorization: `Bearer ${bearer2}` };
9200
+ }
9201
+ function redactUrl(raw) {
9202
+ try {
9203
+ const u = new URL(raw);
9204
+ return `${u.origin}${u.pathname}`;
9205
+ } catch {
9206
+ return "[redacted]";
9207
+ }
9208
+ }
9209
+ function registerExtractTools({ server, tracked, authContext, apiFetch = globalThis.fetch }) {
7512
9210
  server.registerTool(
7513
9211
  "extract-design-system",
7514
9212
  {
@@ -7519,32 +9217,100 @@ Use this when the user says "match our existing style", "use our design system",
7519
9217
 
7520
9218
  How to use it: read the project's \`globals.css\` (or wherever the design tokens / CSS custom properties live, e.g. a Tailwind v4 \`@theme\` block) and pass its full contents as \`css\`. The MCP server cannot read your filesystem, so you must paste the text.
7521
9219
 
7522
- Returns: the semantic palette (primary, background, foreground, accent, border, ring, etc.), radius, fonts, and shadow scale, plus a pasteable token sheet with every color normalized to \`oklch()\`. Handles hex, rgb()/rgba(), hsl()/hsla(), named colors, and existing oklch values, across \`:root\`, \`.dark\`, and \`@theme\` blocks.`,
7523
- inputSchema: z6.object({
7524
- css: z6.string().trim().min(1).max(2e5).describe("Full contents of the project's globals.css (or any CSS holding the design tokens / CSS custom properties)."),
7525
- name: z6.string().trim().min(1).max(120).optional().describe("Optional label for the extracted system, e.g. the app or repo name."),
7526
- maxTokens: z6.number().min(500).max(5e4).optional().describe("Maximum tokens to return. Truncates the token sheet to fit.")
9220
+ Returns: the semantic palette (primary, background, foreground, accent, border, ring, etc.), radius, fonts, and shadow scale, plus a pasteable token sheet with every color normalized to \`oklch()\`. Handles hex, rgb()/rgba(), hsl()/hsla(), named colors, bare Shopify "r,g,b" triplets, and existing oklch values, across \`:root\`, \`.dark\`, and \`@theme\` blocks.
9221
+
9222
+ Beyond pasted CSS, \`sources\` accepts any mix of inputs and merges them (later sources win):
9223
+ - kind "css": any stylesheet text, same as the \`css\` param.
9224
+ - kind "computed": a JSON sample of rendered styles read off the LIVE site, which beats variable-name guessing because it records what elements actually look like. Gather it by running this in the site's browser console and passing the printed JSON:
9225
+ \`\`\`js
9226
+ JSON.stringify(${buildRoleSampleScript()})
9227
+ \`\`\`
9228
+ - kind "tokens": a design-tokens JSON export ($value entries, W3C format or the Figma variables API shape with 0..1 rgb floats).
9229
+
9230
+ Common vendor names (Shopify's --color-button family) are mapped into empty semantic slots automatically and the mapping is reported in the output.`,
9231
+ inputSchema: z7.object({
9232
+ css: z7.string().trim().min(1).max(2e5).optional().describe("Full contents of the project's globals.css (or any CSS holding the design tokens / CSS custom properties). Optional when `sources` is given."),
9233
+ sources: z7.array(z7.object({
9234
+ name: z7.string().trim().min(1).max(120).describe("Label for this source, e.g. 'globals.css', 'live site sample', 'figma variables'."),
9235
+ kind: z7.enum(["css", "computed", "tokens"]).optional().describe("Omit to infer: JSON with $value entries is tokens, JSON keyed by UI roles is computed, anything else is CSS."),
9236
+ content: z7.string().trim().min(1).max(2e5)
9237
+ })).max(10).optional().describe("Any mix of brand sources, merged in order (later sources win). See the tool description for how to gather each kind."),
9238
+ name: z7.string().trim().min(1).max(120).optional().describe("Optional label for the extracted system, e.g. the app or repo name."),
9239
+ maxTokens: z7.number().min(500).max(5e4).optional().describe("Maximum tokens to return. Truncates the token sheet to fit.")
7527
9240
  }),
7528
- annotations: { readOnlyHint: true }
9241
+ annotations: READ_ONLY_TOOL_ANNOTATIONS
7529
9242
  },
7530
- async ({ css, name, maxTokens }) => tracked("extract-design-system", { name: name ?? null }, async () => {
7531
- const result = extractDesignSystem(css, name);
9243
+ async ({ css, sources, name, maxTokens }) => tracked("extract-design-system", { name: name ?? null, sourceCount: (sources?.length ?? 0) + (css ? 1 : 0) }, async () => {
9244
+ const all = [
9245
+ ...css ? [{ name: "pasted css", kind: "css", content: css }] : [],
9246
+ ...sources ?? []
9247
+ ];
9248
+ if (all.length === 0) {
9249
+ return {
9250
+ content: [{
9251
+ type: "text",
9252
+ text: "Nothing to extract: pass `css`, or one or more `sources`."
9253
+ }]
9254
+ };
9255
+ }
9256
+ const result = extractFromSources(all, name);
7532
9257
  let text = formatExtraction(result);
7533
9258
  if (maxTokens) text = truncateToTokens(text, maxTokens);
7534
9259
  return { content: [{ type: "text", text }] };
7535
9260
  })
7536
9261
  );
9262
+ server.registerTool(
9263
+ "extract-from-url",
9264
+ {
9265
+ title: "Extract Design System From a Live Site",
9266
+ description: `Point Better Design at a URL and get the site's brand back as an oklch token sheet, without pasting anything.
9267
+
9268
+ Use this when the user names their site ("match uclips.eu", "use our website's style") or hands you any public URL whose look should be reused. Server side, Better Design fetches the page's stylesheets AND renders the page in a headless browser to sample what role elements actually look like (body, heading, link, button, input, card), then merges both, rendered truth last. That reads the brand even when the site's variable names are unrecognizable.
9269
+
9270
+ Slow by nature (a real browser loads the page): expect 10 to 45 seconds. For CSS the user already has locally, prefer extract-design-system, which is instant.`,
9271
+ inputSchema: z7.object({
9272
+ url: z7.string().trim().min(8).max(2048).describe("Public http(s) URL of the page to read, usually the homepage."),
9273
+ name: z7.string().trim().min(1).max(120).optional().describe("Optional label for the extracted system. Defaults to the site's hostname."),
9274
+ maxTokens: z7.number().min(500).max(5e4).optional().describe("Maximum tokens to return. Truncates the token sheet to fit.")
9275
+ }),
9276
+ annotations: READ_ONLY_TOOL_ANNOTATIONS
9277
+ },
9278
+ async ({ url, name, maxTokens }) => tracked("extract-from-url", { query: redactUrl(url) }, async () => {
9279
+ const params = new URLSearchParams({ url });
9280
+ if (name) params.set("name", name);
9281
+ const res = await apiFetch(`${APP_BASE_URL}/api/v1/brand-extract/from-url?${params}`, {
9282
+ headers: authHeaders2(authContext),
9283
+ signal: AbortSignal.timeout(9e4)
9284
+ });
9285
+ if (!res.ok) {
9286
+ const body2 = await res.json().catch(() => ({}));
9287
+ return {
9288
+ content: [{
9289
+ type: "text",
9290
+ text: `Could not extract from that URL (HTTP ${res.status}). ${body2.message ?? ""}`.trim()
9291
+ }]
9292
+ };
9293
+ }
9294
+ const body = await res.json();
9295
+ let text = body.notes?.length ? `> ${body.notes.join("\n> ")}
9296
+
9297
+ ${body.markdown}` : body.markdown;
9298
+ if (maxTokens) text = truncateToTokens(text, maxTokens);
9299
+ return { content: [{ type: "text", text }] };
9300
+ })
9301
+ );
7537
9302
  }
7538
9303
  var init_extract_tools = __esm({
7539
9304
  "../shared/src/mcp/tools/extract-tools.ts"() {
7540
9305
  "use strict";
7541
9306
  init_shared();
7542
9307
  init_extract_design_system();
9308
+ init_brand_sources();
7543
9309
  }
7544
9310
  });
7545
9311
 
7546
9312
  // ../shared/src/mcp/tools/illustration-tools.ts
7547
- import { z as z7 } from "zod";
9313
+ import { z as z8 } from "zod";
7548
9314
  function fail(message) {
7549
9315
  return {
7550
9316
  content: [{ type: "text", text: message }],
@@ -7558,8 +9324,14 @@ function fail(message) {
7558
9324
  }
7559
9325
  };
7560
9326
  }
7561
- function registerIllustrationTools({ server, track, tracked, authContext }) {
7562
- const authHeaders2 = () => {
9327
+ function registerIllustrationTools({
9328
+ server,
9329
+ track,
9330
+ tracked,
9331
+ authContext,
9332
+ apiFetch = globalThis.fetch
9333
+ }) {
9334
+ const authHeaders3 = () => {
7563
9335
  const headers = { "Content-Type": "application/json" };
7564
9336
  if (authContext) {
7565
9337
  const bearer2 = authContext.kind === "api-key" ? authContext.apiKey : authContext.accessToken;
@@ -7584,14 +9356,14 @@ How to call it:
7584
9356
  4. Stop when \`faults\` is empty.
7585
9357
 
7586
9358
  Markup carrying script, event handlers, remote references, or styles that reach outside the drawing is refused rather than cleaned, so the drawing you fix is the drawing you sent.`,
7587
- inputSchema: z7.object({
7588
- svg: z7.string().min(1).describe("The complete svg element you drew."),
7589
- intent: z7.string().min(1).describe('What it is meant to be, in plain words. For example "an open inbox tray with letters".'),
7590
- animated: z7.boolean().optional().describe("Set when the drawing was meant to move, so the motion checks apply."),
7591
- reviewToken: z7.string().optional().describe("The token from a previous review of this drawing. Reviews it again free.")
9359
+ inputSchema: z8.object({
9360
+ svg: z8.string().min(1).describe("The complete svg element you drew."),
9361
+ intent: z8.string().min(1).describe('What it is meant to be, in plain words. For example "an open inbox tray with letters".'),
9362
+ animated: z8.boolean().optional().describe("Set when the drawing was meant to move, so the motion checks apply."),
9363
+ reviewToken: z8.string().optional().describe("The token from a previous review of this drawing. Reviews it again free.")
7592
9364
  }),
7593
9365
  outputSchema: REVIEW_OUTPUT_SCHEMA,
7594
- annotations: { readOnlyHint: true }
9366
+ annotations: READ_ONLY_TOOL_ANNOTATIONS
7595
9367
  },
7596
9368
  async ({
7597
9369
  svg,
@@ -7603,9 +9375,9 @@ Markup carrying script, event handlers, remote references, or styles that reach
7603
9375
  { query: intent, animated, repeat: Boolean(reviewToken) },
7604
9376
  async () => {
7605
9377
  try {
7606
- const res = await fetch(`${APP_BASE_URL}/api/illustrations/review`, {
9378
+ const res = await apiFetch(`${APP_BASE_URL}/api/illustrations/review`, {
7607
9379
  method: "POST",
7608
- headers: authHeaders2(),
9380
+ headers: authHeaders3(),
7609
9381
  signal: AbortSignal.timeout(6e4),
7610
9382
  body: JSON.stringify({ svg, intent, animated: animated ?? false, reviewToken })
7611
9383
  });
@@ -7657,16 +9429,16 @@ var init_illustration_tools = __esm({
7657
9429
  init_shared();
7658
9430
  REVIEW_OUTPUT_SCHEMA = {
7659
9431
  /** Empty when nothing is visibly wrong. */
7660
- faults: z7.array(z7.string()),
9432
+ faults: z8.array(z8.string()),
7661
9433
  /** Group ids in paint order, the parts an animation can target. */
7662
- layers: z7.array(z7.string()),
9434
+ layers: z8.array(z8.string()),
7663
9435
  /** False when the markup was refused; faults says why. */
7664
- reviewed: z7.boolean(),
9436
+ reviewed: z8.boolean(),
7665
9437
  /** True when the drawing could not be rendered, so only structure was checked. */
7666
- unseen: z7.boolean(),
9438
+ unseen: z8.boolean(),
7667
9439
  /** Pass this back to review the same drawing again without spending another credit. */
7668
- reviewToken: z7.string(),
7669
- message: z7.string()
9440
+ reviewToken: z8.string(),
9441
+ message: z8.string()
7670
9442
  };
7671
9443
  }
7672
9444
  });
@@ -7703,15 +9475,13 @@ var init_preview_resource = __esm({
7703
9475
  prefersBorder: true,
7704
9476
  csp: {
7705
9477
  connectDomains: [APP_BASE_URL, ...WIDGET_RESOURCE_DOMAINS],
7706
- resourceDomains: [APP_BASE_URL, ...WIDGET_RESOURCE_DOMAINS],
7707
- frameDomains: WIDGET_FRAME_DOMAINS
9478
+ resourceDomains: [APP_BASE_URL, ...WIDGET_RESOURCE_DOMAINS]
7708
9479
  },
7709
9480
  domain: WIDGET_APP_DOMAIN
7710
9481
  },
7711
9482
  "openai/widgetCSP": {
7712
9483
  connect_domains: [APP_BASE_URL, ...WIDGET_RESOURCE_DOMAINS],
7713
9484
  resource_domains: [APP_BASE_URL, ...WIDGET_RESOURCE_DOMAINS],
7714
- frame_domains: WIDGET_FRAME_DOMAINS,
7715
9485
  redirect_domains: [APP_BASE_URL]
7716
9486
  }
7717
9487
  };
@@ -7719,9 +9489,9 @@ var init_preview_resource = __esm({
7719
9489
  });
7720
9490
 
7721
9491
  // ../shared/src/mcp/tools.ts
7722
- function registerAllTools(server, provider, onToolUsage, authContext, options = {}) {
9492
+ function registerAllTools(server, provider, onToolUsage, authContext, options = {}, apiFetch = globalThis.fetch, userId) {
7723
9493
  const { track, tracked } = createTracking(server, onToolUsage);
7724
- const deps = { server, provider, track, tracked, authContext };
9494
+ const deps = { server, provider, track, tracked, authContext, apiFetch, userId };
7725
9495
  registerPrincipleTools(deps, options);
7726
9496
  registerComprehensionTools(deps);
7727
9497
  registerIconTools(deps);
@@ -7749,9 +9519,22 @@ var init_tools = __esm({
7749
9519
  var mcp_exports = {};
7750
9520
  __export(mcp_exports, {
7751
9521
  BUILD_TARGET_RULE: () => BUILD_TARGET_RULE,
9522
+ GUIDANCE_TOOLS: () => GUIDANCE_TOOLS,
7752
9523
  INSTRUCTIONS: () => INSTRUCTIONS,
9524
+ MCP_SESSION_WINDOW_MS: () => MCP_SESSION_WINDOW_MS,
9525
+ REVIEW_TOOLS: () => REVIEW_TOOLS,
7753
9526
  SEARCH_TOOLS: () => SEARCH_TOOLS,
7754
- createMcpServer: () => createMcpServer
9527
+ SESSION_LOOP_TOOLS: () => SESSION_LOOP_TOOLS,
9528
+ SHOPIFY_PRIMITIVE_CONTRACT_VERSION: () => SHOPIFY_PRIMITIVE_CONTRACT_VERSION,
9529
+ buildShopifyPrimitiveSet: () => buildShopifyPrimitiveSet,
9530
+ createMcpServer: () => createMcpServer,
9531
+ formatTopicList: () => formatTopicList,
9532
+ guidancePreamble: () => guidancePreamble,
9533
+ parseShopifyPrimitiveSet: () => parseShopifyPrimitiveSet,
9534
+ reviewNudge: () => reviewNudge,
9535
+ reviewSectionForTopic: () => reviewSectionForTopic,
9536
+ summarizeSessionGuidance: () => summarizeSessionGuidance,
9537
+ weightReviewRules: () => weightReviewRules
7755
9538
  });
7756
9539
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
7757
9540
  function createMcpServer(options) {
@@ -7763,17 +9546,27 @@ function createMcpServer(options) {
7763
9546
  authContext,
7764
9547
  enableReactNativeGuide = true,
7765
9548
  enableThreeJsGuide = true,
7766
- enableShopifyKit = true
9549
+ enableShopifyKit = true,
9550
+ apiFetch = globalThis.fetch,
9551
+ userId
7767
9552
  } = options;
7768
9553
  const server = new McpServer(
7769
9554
  { name, version },
7770
9555
  { instructions: getMcpInstructions({ enableReactNativeGuide, enableThreeJsGuide }) }
7771
9556
  );
7772
- registerAllTools(server, provider, onToolUsage, authContext, {
7773
- enableReactNativeGuide,
7774
- enableThreeJsGuide,
7775
- enableShopifyKit
7776
- });
9557
+ registerAllTools(
9558
+ server,
9559
+ provider,
9560
+ onToolUsage,
9561
+ authContext,
9562
+ {
9563
+ enableReactNativeGuide,
9564
+ enableThreeJsGuide,
9565
+ enableShopifyKit
9566
+ },
9567
+ apiFetch,
9568
+ userId
9569
+ );
7777
9570
  return server;
7778
9571
  }
7779
9572
  var init_mcp = __esm({
@@ -7783,7 +9576,9 @@ var init_mcp = __esm({
7783
9576
  init_tools();
7784
9577
  init_tools();
7785
9578
  init_instructions();
9579
+ init_session_guidance();
7786
9580
  init_shared();
9581
+ init_shopify_primitives();
7787
9582
  }
7788
9583
  });
7789
9584
 
@@ -7793,7 +9588,9 @@ __export(api_client_exports, {
7793
9588
  getDesignSystem: () => getDesignSystem,
7794
9589
  getDesignSystemComponents: () => getDesignSystemComponents,
7795
9590
  getPrinciple: () => getPrinciple,
9591
+ getProjectShopifyPrimitives: () => getProjectShopifyPrimitives,
7796
9592
  getReviewRules: () => getReviewRules,
9593
+ getSessionGuidance: () => getSessionGuidance,
7797
9594
  listPrinciples: () => listPrinciples,
7798
9595
  searchDesignSystems: () => searchDesignSystems,
7799
9596
  searchIconLibraries: () => searchIconLibraries,
@@ -7828,11 +9625,14 @@ async function searchDesignSystems(query, limit = 5) {
7828
9625
  `/v1/design-systems/search?q=${encodeURIComponent(query)}&limit=${limit}`
7829
9626
  );
7830
9627
  }
7831
- async function getDesignSystem(id, includeHtml = false) {
7832
- const params = includeHtml ? "?includeHtml=true" : "";
7833
- return request(
7834
- `/v1/design-systems/${encodeURIComponent(id)}${params}`
7835
- );
9628
+ async function getDesignSystem(id, options = {}) {
9629
+ const params = new URLSearchParams();
9630
+ if (options.includeHtml) params.set("includeHtml", "true");
9631
+ if (options.includeShopifyPrimitives) {
9632
+ params.set("includeShopifyPrimitives", "true");
9633
+ }
9634
+ const query = params.size ? `?${params}` : "";
9635
+ return request(`/v1/design-systems/${encodeURIComponent(id)}${query}`);
7836
9636
  }
7837
9637
  async function getDesignSystemComponents(id, names, includeCode = true) {
7838
9638
  const params = new URLSearchParams();
@@ -7843,6 +9643,9 @@ async function getDesignSystemComponents(id, names, includeCode = true) {
7843
9643
  `/v1/design-systems/${encodeURIComponent(id)}/components${query}`
7844
9644
  );
7845
9645
  }
9646
+ async function getProjectShopifyPrimitives(projectId) {
9647
+ return request(`/projects/${encodeURIComponent(projectId)}/shopify/all.json`);
9648
+ }
7846
9649
  async function listPrinciples(area) {
7847
9650
  const params = area ? `?area=${area}` : "";
7848
9651
  return request(`/v1/principles${params}`);
@@ -7874,13 +9677,20 @@ async function getReviewRules(category = "all") {
7874
9677
  const params = category !== "all" ? `?category=${encodeURIComponent(category)}` : "";
7875
9678
  return request(`/v1/review-rules${params}`);
7876
9679
  }
9680
+ async function getSessionGuidance(windowMs) {
9681
+ const params = new URLSearchParams({ windowMs: String(Math.round(windowMs)) });
9682
+ return request(`/v1/session-guidance?${params}`);
9683
+ }
7877
9684
  function postAlert(body) {
7878
9685
  fetch(`${API_URL2}/api/internal/alert`, {
7879
9686
  method: "POST",
7880
9687
  headers: { "Content-Type": "application/json" },
7881
9688
  body: JSON.stringify(body),
7882
9689
  signal: AbortSignal.timeout(5e3)
7883
- }).catch(() => {
9690
+ }).then((res) => {
9691
+ if (!res.ok) console.warn(`postAlert failed: HTTP ${res.status}`);
9692
+ }).catch((err) => {
9693
+ console.warn("postAlert failed", err);
7884
9694
  });
7885
9695
  }
7886
9696
  function trackToolUsage(tool, params, context = {}) {
@@ -7937,17 +9747,36 @@ function createHttpDataProvider() {
7937
9747
  return results;
7938
9748
  },
7939
9749
  async getDesignSystemById(id, options = {}) {
7940
- const systemRes = await getDesignSystem(id, options.includeHtml);
9750
+ const systemRes = await getDesignSystem(id, {
9751
+ includeHtml: options.includeHtml,
9752
+ includeShopifyPrimitives: options.includeShopifyPrimitives
9753
+ });
7941
9754
  const componentsRes = options.includeComponents === false ? { components: [] } : await getDesignSystemComponents(
7942
9755
  id,
7943
9756
  options.componentNames,
7944
9757
  options.includeComponentCode
7945
9758
  );
7946
9759
  return {
7947
- metadata: systemRes.metadata,
9760
+ // Spread the field only when the API sent it. Assigning it
9761
+ // unconditionally would put `shopifyAccess: undefined` on every
9762
+ // response, so `"shopifyAccess" in metadata` reads true for callers who
9763
+ // never asked about Shopify, breaking the absent-unless-requested
9764
+ // contract the field documents. [BD-421]
9765
+ metadata: {
9766
+ ...systemRes.metadata,
9767
+ ...systemRes.shopifyAccess === void 0 ? {} : { shopifyAccess: systemRes.shopifyAccess }
9768
+ },
7948
9769
  components: componentsRes.components
7949
9770
  };
7950
9771
  },
9772
+ async getProjectShopifyPrimitives(projectId) {
9773
+ try {
9774
+ return await getProjectShopifyPrimitives(projectId);
9775
+ } catch (err) {
9776
+ console.warn(`getProjectShopifyPrimitives(${projectId}) failed`, err);
9777
+ return null;
9778
+ }
9779
+ },
7951
9780
  async listPrinciples(area) {
7952
9781
  const { principles } = await listPrinciples(area);
7953
9782
  return principles;
@@ -7955,7 +9784,8 @@ function createHttpDataProvider() {
7955
9784
  async getPrincipleByTopic(topic, area) {
7956
9785
  try {
7957
9786
  return await getPrinciple(topic, area);
7958
- } catch {
9787
+ } catch (err) {
9788
+ console.warn(`getPrincipleByTopic(${topic}) failed`, err);
7959
9789
  return null;
7960
9790
  }
7961
9791
  },
@@ -7967,6 +9797,23 @@ function createHttpDataProvider() {
7967
9797
  const { rules } = await getReviewRules(category);
7968
9798
  return rules;
7969
9799
  },
9800
+ /**
9801
+ * Session-aware review [BD-495]. The stdio client never learns its own user
9802
+ * id, so the argument is ignored and the API resolves the caller from the
9803
+ * API key. Any failure, including an older server without the endpoint,
9804
+ * resolves to an empty session so the review tools answer as they did
9805
+ * before.
9806
+ */
9807
+ async getRecentGuidance(_userId, sinceMs) {
9808
+ try {
9809
+ const requested = Date.now() - sinceMs;
9810
+ const windowMs = Math.min(Math.max(requested, 1), MCP_SESSION_WINDOW_MS);
9811
+ const { calls } = await getSessionGuidance(windowMs);
9812
+ return calls;
9813
+ } catch {
9814
+ return [];
9815
+ }
9816
+ },
7970
9817
  async searchIconLibraries(query, limit = 5) {
7971
9818
  const { results } = await searchIconLibraries(query, limit);
7972
9819
  return results;
@@ -7993,6 +9840,7 @@ function createHttpDataProvider() {
7993
9840
  var init_http_data_provider = __esm({
7994
9841
  "src/lib/http-data-provider.ts"() {
7995
9842
  "use strict";
9843
+ init_mcp();
7996
9844
  init_api_client();
7997
9845
  }
7998
9846
  });
@@ -8040,11 +9888,7 @@ __export(mcp_debug_log_exports, {
8040
9888
  mcpDebugLogPath: () => mcpDebugLogPath
8041
9889
  });
8042
9890
  function mcpDebugLogPath() {
8043
- try {
8044
- return typeof process !== "undefined" ? process.env?.MCP_DEBUG_LOG : void 0;
8045
- } catch {
8046
- return void 0;
8047
- }
9891
+ return typeof process !== "undefined" ? process.env?.MCP_DEBUG_LOG : void 0;
8048
9892
  }
8049
9893
  function mcpDebugLog(entry) {
8050
9894
  const path5 = mcpDebugLogPath();