better-design 0.15.1 → 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, READ_ONLY_TOOL_ANNOTATIONS, 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."),
@@ -1091,6 +1219,7 @@ The UI copy inside the screen follows the same restraint: short headlines (about
1091
1219
  destructiveHint: false,
1092
1220
  openWorldHint: false
1093
1221
  };
1222
+ SESSION_LOOKUP_TIMEOUT_MS = 2e3;
1094
1223
  SEARCH_TOOLS = /* @__PURE__ */ new Set([
1095
1224
  "find-design-system",
1096
1225
  "get-ui-principle",
@@ -1106,6 +1235,541 @@ The UI copy inside the screen follows the same restraint: short headlines (about
1106
1235
  }
1107
1236
  });
1108
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
+
1109
1773
  // ../shared/src/foundational-docs/topic-utils.ts
1110
1774
  function normalizePrincipleTopic(value) {
1111
1775
  return value.trim().toLowerCase().replace(/^principle-/, "").replace(/&/g, "and").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
@@ -1185,6 +1849,18 @@ function formatTopicNotFound(topic, principles, toolName, resourceLabel) {
1185
1849
  `Call \`${toolName}({})\` for the full index.`
1186
1850
  ].filter(Boolean).join("\n");
1187
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
+ }
1188
1864
  async function runPrincipleTool(provider, area, label, toolName, args) {
1189
1865
  const { topic, query, maxTokens } = args;
1190
1866
  const resourceLabel = area === "react-native" || area === "three-js" || area === "motion" ? "Guides" : "Principles";
@@ -1262,14 +1938,22 @@ ${formatted}`;
1262
1938
  capture: { topResults: [] }
1263
1939
  };
1264
1940
  }
1265
- 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);
1266
1950
  server.registerTool(
1267
1951
  "get-ui-principle",
1268
1952
  {
1269
1953
  title: "Get UI Design Principle",
1270
1954
  description: `Load visual UI design principles into context on-demand.
1271
1955
 
1272
- 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.
1273
1957
 
1274
1958
  You can either:
1275
1959
  1. Request a specific topic or alias: get-ui-principle({ topic: "spacing" })
@@ -1278,7 +1962,7 @@ You can either:
1278
1962
 
1279
1963
  Use maxTokens to keep responses focused (recommended: 5000\u201310000).`,
1280
1964
  inputSchema: z2.object({
1281
- 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."),
1282
1966
  query: z2.string().trim().min(1).max(500).optional().describe("Natural language search query to find relevant principles."),
1283
1967
  maxTokens: z2.number().min(500).max(5e4).optional().describe("Maximum tokens to return. Truncates content to fit. Recommended: 5000-10000."),
1284
1968
  context: CONTEXT_ARG
@@ -1288,7 +1972,7 @@ Use maxTokens to keep responses focused (recommended: 5000\u201310000).`,
1288
1972
  async ({ topic, query, maxTokens, context }) => {
1289
1973
  const start = Date.now();
1290
1974
  try {
1291
- 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 });
1292
1976
  return trackSearch(track, "get-ui-principle", { ...capture, context }, start, response);
1293
1977
  } catch (error) {
1294
1978
  return trackSearch(
@@ -1309,7 +1993,7 @@ Use maxTokens to keep responses focused (recommended: 5000\u201310000).`,
1309
1993
  title: "Get UX Design Principle",
1310
1994
  description: `Load UX behavior and product-flow principles into context on-demand.
1311
1995
 
1312
- 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.
1313
1997
 
1314
1998
  You can either:
1315
1999
  1. Request a specific topic: get-ux-principle({ topic: "behavior-change" })
@@ -1319,7 +2003,7 @@ You can either:
1319
2003
 
1320
2004
  Use maxTokens to keep responses focused (recommended: 5000\u201310000).`,
1321
2005
  inputSchema: z2.object({
1322
- 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."),
1323
2007
  query: z2.string().trim().min(1).max(500).optional().describe("Natural language search query to find relevant UX principles."),
1324
2008
  maxTokens: z2.number().min(500).max(5e4).optional().describe("Maximum tokens to return. Truncates content to fit. Recommended: 5000-10000."),
1325
2009
  context: CONTEXT_ARG
@@ -1329,7 +2013,7 @@ Use maxTokens to keep responses focused (recommended: 5000\u201310000).`,
1329
2013
  async ({ topic, query, maxTokens, context }) => {
1330
2014
  const start = Date.now();
1331
2015
  try {
1332
- 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 });
1333
2017
  return trackSearch(track, "get-ux-principle", { ...capture, context }, start, response);
1334
2018
  } catch (error) {
1335
2019
  return trackSearch(
@@ -1375,8 +2059,10 @@ Prefer query search when you do not know the topic name. Use maxTokens to keep r
1375
2059
  async ({ topic, query, maxTokens, context }) => {
1376
2060
  const start = Date.now();
1377
2061
  try {
1378
- const { response, capture } = await runPrincipleTool(
2062
+ const { response, capture } = await runGuidanceTool(
1379
2063
  provider,
2064
+ userId,
2065
+ clientName(),
1380
2066
  "react-native",
1381
2067
  "React Native",
1382
2068
  "get-react-native-guide",
@@ -1432,8 +2118,10 @@ Prefer query search when you do not know the topic name. Use maxTokens to keep r
1432
2118
  async ({ topic, query, maxTokens, context }) => {
1433
2119
  const start = Date.now();
1434
2120
  try {
1435
- const { response, capture } = await runPrincipleTool(
2121
+ const { response, capture } = await runGuidanceTool(
1436
2122
  provider,
2123
+ userId,
2124
+ clientName(),
1437
2125
  "three-js",
1438
2126
  "Three.js",
1439
2127
  "get-three-js-guide",
@@ -1470,30 +2158,60 @@ Use this before writing a Remotion composition, and while debugging one. It cove
1470
2158
 
1471
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.
1472
2160
 
1473
- 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.
1474
2164
 
1475
2165
  This complements the design tools: use get-ui-principle for visual decisions and get-ux-principle for product behavior.
1476
2166
 
1477
2167
  You can either:
1478
- 1. Request a known topic: get-motion-guide({ topic: "fundamentals" })
1479
- 2. Use an alias: get-motion-guide({ topic: "cut on the beat" })
1480
- 3. Search for the current task or problem: get-motion-guide({ query: "my animated word swap clips the longer word" })
1481
- 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({})
1482
2173
 
1483
2174
  Prefer query search when you do not know the topic name. Use maxTokens to keep responses focused (recommended: 5000-10000).`,
1484
2175
  inputSchema: z2.object({
1485
- 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."),
1486
2178
  query: z2.string().trim().min(1).max(500).optional().describe("Natural language description of the video or animation task or problem."),
1487
2179
  maxTokens: z2.number().min(500).max(5e4).optional().describe("Maximum tokens to return. Truncates content to fit. Recommended: 5000-10000."),
1488
2180
  context: CONTEXT_ARG
1489
2181
  }),
1490
2182
  annotations: READ_ONLY_TOOL_ANNOTATIONS
1491
2183
  },
1492
- async ({ topic, query, maxTokens, context }) => {
2184
+ async ({ scaffold, topic, query, maxTokens, context }) => {
1493
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
+ }
1494
2210
  try {
1495
- const { response, capture } = await runPrincipleTool(
2211
+ const { response, capture } = await runGuidanceTool(
1496
2212
  provider,
2213
+ userId,
2214
+ clientName(),
1497
2215
  "motion",
1498
2216
  "Motion",
1499
2217
  "get-motion-guide",
@@ -1542,6 +2260,7 @@ You MUST call this after writing UI code to self-review your output, then fix ev
1542
2260
  - Microcopy details: pluralization, improvised unit glyphs, empty states without a next action
1543
2261
  - Template layouts: gradient hero + eyebrow pill, left-border callout boxes
1544
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
1545
2264
 
1546
2265
  **Comprehension (would someone outside the team understand it?):**
1547
2266
  - Mom test: insider vocabulary, unexplained acronyms, sentences too long to follow
@@ -1560,7 +2279,11 @@ Use maxTokens to keep responses focused (recommended: 5000\u201310000).`,
1560
2279
  async ({ category = "all", maxTokens, context }) => {
1561
2280
  const start = Date.now();
1562
2281
  try {
1563
- 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);
1564
2287
  const topResults = rules.map((rule) => ({ id: rule.id }));
1565
2288
  if (rules.length === 0) {
1566
2289
  return trackSearch(
@@ -1574,9 +2297,12 @@ Use maxTokens to keep responses focused (recommended: 5000\u201310000).`,
1574
2297
  const rulesOutput = rules.map((rule) => `# ${rule.title}
1575
2298
 
1576
2299
  ${rule.content}`).join("\n\n---\n\n");
2300
+ const preamble = guidancePreamble(session.topics, "the build", sections);
1577
2301
  let text = `# Code Review Rules
1578
2302
 
1579
- ${rulesOutput}`;
2303
+ ${preamble ? `${preamble}
2304
+
2305
+ ` : ""}${rulesOutput}`;
1580
2306
  if (maxTokens) text = truncateToTokens(text, maxTokens);
1581
2307
  return trackSearch(
1582
2308
  track,
@@ -1603,8 +2329,10 @@ var SEARCH_RESULT_PREVIEW_CHARS;
1603
2329
  var init_principle_tools = __esm({
1604
2330
  "../shared/src/mcp/tools/principle-tools.ts"() {
1605
2331
  "use strict";
2332
+ init_motion_scaffold();
1606
2333
  init_topic_utils();
1607
2334
  init_shared();
2335
+ init_session_guidance();
1608
2336
  SEARCH_RESULT_PREVIEW_CHARS = 4e3;
1609
2337
  }
1610
2338
  });
@@ -2084,7 +2812,7 @@ var init_comprehension = __esm({
2084
2812
 
2085
2813
  // ../shared/src/mcp/tools/comprehension-tools.ts
2086
2814
  import { z as z3 } from "zod";
2087
- function registerComprehensionTools({ server, track }) {
2815
+ function registerComprehensionTools({ server, provider, track, userId }) {
2088
2816
  server.registerTool(
2089
2817
  "check-comprehension",
2090
2818
  {
@@ -2118,7 +2846,12 @@ Use knownTerms for words your audience genuinely knows, so the check stops flagg
2118
2846
  const start = Date.now();
2119
2847
  const report = analyzeComprehension(args);
2120
2848
  const hasCopy = Boolean(report.copy.headline) || report.copy.body.length > 0 || report.copy.actions.length > 0;
2121
- 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;
2122
2855
  track(
2123
2856
  "check-comprehension",
2124
2857
  {
@@ -2139,6 +2872,7 @@ var init_comprehension_tools = __esm({
2139
2872
  "use strict";
2140
2873
  init_comprehension();
2141
2874
  init_shared();
2875
+ init_session_guidance();
2142
2876
  NOTHING_TO_CHECK = `# Comprehension check
2143
2877
 
2144
2878
  No user-facing copy was found. Pass the screen's copy directly:
@@ -2367,7 +3101,7 @@ Examples:
2367
3101
  - find-icons({ query: "home", libraryId: "phosphor" })
2368
3102
  - find-icons({ query: "settings", libraryId: "tabler" })
2369
3103
 
2370
- 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.`,
2371
3105
  inputSchema: z4.object({
2372
3106
  query: z4.string().describe("What icon you need (e.g., 'home', 'settings', 'user')"),
2373
3107
  libraryId: z4.string().describe("The icon library ID from find-icon-library"),
@@ -2740,6 +3474,21 @@ context in the supporting line or after the title. Skip the generated-template s
2740
3474
  gradient hero with an uppercase eyebrow pill, centered three-card feature grid,
2741
3475
  left-border callout boxes, walls of text.
2742
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
+
2743
3492
  **Hierarchy is pass/fail.** Give the first view one P0 task and at most one P1
2744
3493
  summary; move P2 history, standings, detailed metadata, and empty-state chrome
2745
3494
  below it or behind a tab/disclosure. Unless the user explicitly asks for a dense
@@ -2763,7 +3512,8 @@ After writing UI, self-review with
2763
3512
  `;
2764
3513
  DS_ESLINT_CONFIG = `// Better Design \u2014 design-system guardrail (flat ESLint config fragment).
2765
3514
  // Flags raw HTML primitives in feature code when the installed DS ships an
2766
- // 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:
2767
3517
  //
2768
3518
  // import dsGuardrail from "./.better-design/eslint-design-system.mjs"; // better-design-guardrail
2769
3519
  // export default [ ...dsGuardrail, /* ...your config */ ];
@@ -2776,6 +3526,7 @@ const banned = [
2776
3526
  { selector: "JSXOpeningElement[name.name='input']", message: "Use <Input> from @/components/ui/input instead of a raw <input>." },
2777
3527
  { selector: "JSXOpeningElement[name.name='select']", message: "Use <Select> from @/components/ui/select instead of a raw <select>." },
2778
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." },
2779
3530
  ];
2780
3531
 
2781
3532
  export default [
@@ -2927,12 +3678,14 @@ function buildDsHtmlKit({ name, tokensCss, viewUrl, source, componentKit }) {
2927
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.',
2928
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.",
2929
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.',
2930
- "- 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.",
2931
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.",
2932
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.',
2933
3684
  "",
2934
3685
  "## Keep it tight",
2935
- "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.",
2936
3689
  ...componentKit?.excluded?.length ? [
2937
3690
  "",
2938
3691
  "## Not in this kit (use the real components)",
@@ -2942,7 +3695,7 @@ function buildDsHtmlKit({ name, tokensCss, viewUrl, source, componentKit }) {
2942
3695
  Full live showcase: ${viewUrl}` : ""
2943
3696
  ].join("\n");
2944
3697
  }
2945
- 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;
2946
3699
  var init_html_kit = __esm({
2947
3700
  "../shared/src/mcp/html-kit.ts"() {
2948
3701
  "use strict";
@@ -2955,6 +3708,13 @@ var init_html_kit = __esm({
2955
3708
  ].join(" ");
2956
3709
  HOST_ISOLATION_CSS_TEMPLATE = `APP_ROOT{color:var(--foreground,oklch(0.2 0 0));background:var(--background,oklch(1 0 0))}
2957
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(" ");
2958
3718
  INLINE_COMPOSITION_POLICY = [
2959
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.",
2960
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.",
@@ -2987,6 +3747,11 @@ APP_ROOT :where(h1,h2,h3,h4,h5,h6,p,strong,label,legend,li,dt,dd,small):not(.ds-
2987
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))}
2988
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}
2989
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}
2990
3755
  .ds p{margin:0 0 1em}
2991
3756
  .ds-muted{color:var(--muted-foreground,oklch(0.55 0 0))}
2992
3757
 
@@ -3127,6 +3892,9 @@ APP_ROOT :where(h1,h2,h3,h4,h5,h6,p,strong,label,legend,li,dt,dd,small):not(.ds-
3127
3892
  "## Hierarchy and information budget (pass/fail)",
3128
3893
  INLINE_HIERARCHY_POLICY,
3129
3894
  "",
3895
+ "## Draw one asset that only this product could carry",
3896
+ BESPOKE_ASSET_POLICY,
3897
+ "",
3130
3898
  "## Compose the screen, do not decorate the canvas",
3131
3899
  INLINE_COMPOSITION_POLICY,
3132
3900
  "",
@@ -3149,18 +3917,40 @@ function fence2(lang, body) {
3149
3917
  ${body}
3150
3918
  \`\`\``;
3151
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
+ }
3152
3940
  function buildDsShopifyFiles(input) {
3153
- const materialCss = input.componentKit?.css?.trim();
3941
+ const materialCss = pointerSafeMaterialCss(input.componentKit?.css);
3942
+ const primaryTextCss = primaryButtonTextCss(materialCss);
3154
3943
  const assetCss = [
3155
3944
  compactTokensCss(input.tokensCss),
3156
3945
  DS_KIT_CSS,
3157
3946
  DS_SHOPIFY_CSS,
3158
- ...materialCss ? [materialCss] : []
3947
+ ...materialCss ? [materialCss] : [],
3948
+ ...primaryTextCss ? [primaryTextCss] : []
3159
3949
  ].join("\n\n");
3160
3950
  return [{ path: "assets/ds-kit.css", content: assetCss }, ...SNIPPETS];
3161
3951
  }
3162
- function buildDsShopifyKit({ name, tokensCss, viewUrl, source, componentKit }) {
3163
- const files = buildDsShopifyFiles({ tokensCss, componentKit });
3952
+ function buildDsShopifyKit({ name, tokensCss, viewUrl, source, componentKit, themeFiles }) {
3953
+ const files = themeFiles ?? buildDsShopifyFiles({ tokensCss, componentKit });
3164
3954
  const sourceLine = source?.kind === "catalog" ? `Source designSystemId: \`${source.designSystemId}\`` : source?.kind === "project" ? `Source projectId: \`${source.projectId}\`` : null;
3165
3955
  const fileBlocks = files.flatMap((f) => [
3166
3956
  `### \`${f.path}\``,
@@ -3171,7 +3961,7 @@ function buildDsShopifyKit({ name, tokensCss, viewUrl, source, componentKit }) {
3171
3961
  return [
3172
3962
  `# ${name}: Shopify theme kit`,
3173
3963
  "",
3174
- "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.",
3175
3965
  ...sourceLine ? ["", sourceLine] : [],
3176
3966
  "",
3177
3967
  SHOPIFY_KIT_CONDUCT_MD,
@@ -3181,57 +3971,150 @@ function buildDsShopifyKit({ name, tokensCss, viewUrl, source, componentKit }) {
3181
3971
  ...fileBlocks,
3182
3972
  "## After installing",
3183
3973
  "- Preview an unpublished copy of the theme first; never edit the live theme directly.",
3184
- "- 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.",
3185
3975
  ...viewUrl ? ["", `Full live showcase: ${viewUrl}`] : []
3186
3976
  ].join("\n");
3187
3977
  }
3188
- 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;
3189
3979
  var init_shopify_kit = __esm({
3190
3980
  "../shared/src/mcp/shopify-kit.ts"() {
3191
3981
  "use strict";
3192
3982
  init_html_kit();
3193
- DS_SHOPIFY_CSS = `.ds.ds-embed{background:transparent}
3194
- .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}
3195
4001
  .ds-announce p{margin:0}
3196
4002
  .ds-announce a{color:inherit;text-decoration:underline}
3197
- .ds-price{display:inline-flex;align-items:baseline;gap:.5rem;flex-wrap:wrap}
3198
- .ds-price-current{font-weight:600;color:var(--foreground,oklch(0.2 0 0))}
3199
- .ds-price-compare{color:var(--muted-foreground,oklch(0.55 0.01 260))}
3200
- .ds-card-media{display:block}
3201
- .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}
3202
4013
  .ds-product-card .ds-card-title a{color:inherit;text-decoration:none}
3203
- .ds-faq-item{border-bottom:1px solid var(--border,oklch(0.9 0.01 260));padding:.25rem 0}
3204
- .ds-faq-item summary{cursor:pointer;padding:.75rem 0;font-weight:600;color:var(--foreground,oklch(0.2 0 0))}
3205
- .ds-faq-answer{padding:0 0 1rem}
3206
- .ds-grid{display:grid;grid-template-columns:repeat(var(--ds-cols,4),1fr);gap:1rem}
3207
- @media (max-width:749px){.ds-grid{grid-template-columns:repeat(2,1fr)}}
3208
- .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}
3209
4022
  .ds-buy-form{margin:0;display:grid;gap:.75rem;justify-items:start}
3210
- .ds-featured{padding:2rem 0}
4023
+ .ds-buy-form .ds-btn{min-width:min(100%,16rem)}
4024
+ .ds-featured{display:block}
3211
4025
  .ds-qty{width:6rem}
3212
- .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))}
3213
- .ds-header-brand{font-weight:700;font-size:1.05rem;color:var(--foreground,oklch(0.2 0 0));text-decoration:none}
3214
- .ds-header-logo{display:block;max-height:2rem;width:auto}
3215
- .ds-header-nav{display:flex;flex:1;gap:1.1rem;flex-wrap:wrap}
3216
- .ds-header-nav a{color:var(--foreground,oklch(0.2 0 0));text-decoration:none;font-size:.9rem}
3217
- .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))}
3218
- .ds-footer nav{display:flex;gap:1rem;flex-wrap:wrap}
3219
- .ds-footer a{color:var(--muted-foreground,oklch(0.55 0.01 260));text-decoration:none;font-size:.875rem}
3220
- .ds-hero{display:grid;gap:1.25rem;padding:2.5rem 0}
3221
- .ds-hero img{display:block;width:100%;height:auto}
3222
- .ds-hero-actions{display:flex;gap:.75rem;flex-wrap:wrap}
3223
- .ds-image-text{display:grid;gap:1.5rem;align-items:center;padding:2rem 0}
3224
- .ds-product-layout{display:grid;gap:2rem;padding:2rem 0}
3225
- .ds-product-media img{display:block;width:100%;height:auto}
3226
- .ds-product-thumbs{display:grid;grid-template-columns:repeat(4,1fr);gap:.5rem;margin-top:.5rem}
3227
- .ds-product-info{display:grid;gap:1rem;align-content:start}
3228
- .ds-product-page{padding:2rem 0}
3229
- .ds-collection-page{padding:2rem 0}
3230
- .ds-pagination{display:block;text-align:center;margin:1.75rem 0 0}
3231
- .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}
3232
4110
  .ds-pagination a{color:var(--foreground,oklch(0.2 0 0))}
3233
4111
  .ds-pagination .current{font-weight:700;color:var(--primary,oklch(0.25 0.01 260))}
3234
- @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}}`;
3235
4118
  SNIPPET_BADGE = `{% comment %}
3236
4119
  Status label. Usage: {% render 'ds-badge', label: 'New' %}
3237
4120
  Optional tone: primary | outline | safe | warn | danger | unknown | verified
@@ -3256,7 +4139,7 @@ var init_shopify_kit = __esm({
3256
4139
  <span class="ds-price-current">{{ ds_price | money }}</span>
3257
4140
  {%- if ds_compare_at > ds_price -%}
3258
4141
  <s class="ds-price-compare">{{ ds_compare_at | money }}</s>
3259
- {% render 'ds-badge', label: 'Sale', tone: 'primary' %}
4142
+ {% render 'ds-badge', label: 'Sale', tone: 'warn' %}
3260
4143
  {%- endif -%}
3261
4144
  </span>`;
3262
4145
  SNIPPET_BUY_BUTTON = `{% comment %}
@@ -3307,6 +4190,21 @@ var init_shopify_kit = __esm({
3307
4190
  {{ paginate | default_pagination }}
3308
4191
  </nav>
3309
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 -%}`;
3310
4208
  SNIPPET_PRODUCT_CARD = `{% comment %}
3311
4209
  Product tile for grids. Usage: {% render 'ds-product-card', product: product %}
3312
4210
  Optional: show_vendor: true, enable_quick_add: true
@@ -3316,11 +4214,14 @@ var init_shopify_kit = __esm({
3316
4214
  {%- if product.featured_image -%}
3317
4215
  {%- assign ds_image_alt = product.featured_image.alt | default: product.title -%}
3318
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>
3319
4219
  {%- endif -%}
3320
4220
  </a>
3321
4221
  <div class="ds-card-body">
3322
- {%- if show_vendor -%}<p class="ds-supporting">{{ product.vendor }}</p>{%- endif -%}
3323
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 -%}
3324
4225
  {% render 'ds-price', product: product %}
3325
4226
  </div>
3326
4227
  {%- if enable_quick_add -%}
@@ -3379,17 +4280,17 @@ var init_shopify_kit = __esm({
3379
4280
  FAQ accordion using the browser's native details element. Merchants add it
3380
4281
  from the theme editor as "DS FAQ".
3381
4282
  {% endcomment %}
3382
- <div class="ds ds-embed ds-container ds-faq">
3383
- {%- if section.settings.heading != blank -%}
3384
- <h2 class="ds-section-title">{{ section.settings.heading }}</h2>
3385
- {%- 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 -%}
3386
4286
  {%- for block in section.blocks -%}
3387
- <details class="ds-faq-item" {{ block.shopify_attributes }}>
4287
+ <details class="ds-card ds-faq-item" {{ block.shopify_attributes }}>
3388
4288
  <summary>{{ block.settings.question }}</summary>
3389
4289
  <div class="ds-faq-answer">{{ block.settings.answer }}</div>
3390
4290
  </details>
3391
4291
  {%- endfor -%}
3392
4292
  </div>
4293
+ </section>
3393
4294
  {% schema %}
3394
4295
  {
3395
4296
  "name": "DS FAQ",
@@ -3414,19 +4315,28 @@ var init_shopify_kit = __esm({
3414
4315
  Product grid from a merchant-chosen collection. Merchants add it from the
3415
4316
  theme editor as "DS Featured collection".
3416
4317
  {% endcomment %}
3417
- {%- assign ds_collection = section.settings.collection -%}
3418
- <div class="ds ds-embed ds-container ds-featured">
3419
- {%- if section.settings.heading != blank -%}
3420
- <h2 class="ds-section-title">{{ section.settings.heading }}</h2>
3421
- {%- 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>
3422
4326
  <div class="ds-grid" style="--ds-cols: {{ section.settings.columns_desktop }}">
3423
4327
  {%- for product in ds_collection.products limit: section.settings.products_to_show -%}
3424
4328
  {% render 'ds-product-card', product: product, show_vendor: section.settings.show_vendor, enable_quick_add: section.settings.enable_quick_add %}
3425
4329
  {%- else -%}
3426
- <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>
3427
4335
  {%- endfor -%}
3428
4336
  </div>
3429
4337
  </div>
4338
+ </section>
4339
+ {%- endif -%}
3430
4340
  {% schema %}
3431
4341
  {
3432
4342
  "name": "DS Featured collection",
@@ -3436,90 +4346,189 @@ var init_shopify_kit = __esm({
3436
4346
  { "type": "range", "id": "products_to_show", "min": 2, "max": 12, "step": 1, "default": 4, "label": "Products to show" },
3437
4347
  { "type": "range", "id": "columns_desktop", "min": 2, "max": 5, "step": 1, "default": 4, "label": "Columns on desktop" },
3438
4348
  { "type": "checkbox", "id": "show_vendor", "label": "Show vendor", "default": false },
3439
- { "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 }
3440
4351
  ],
3441
4352
  "presets": [{ "name": "DS Featured collection" }]
3442
4353
  }
3443
4354
  {% endschema %}`;
3444
4355
  SECTION_HEADER = `{% comment %}
3445
- 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.
3446
4358
  {% endcomment %}
3447
- <header class="ds ds-embed ds-header">
3448
- <a class="ds-header-brand" href="{{ routes.root_url }}">
3449
- {%- if section.settings.logo -%}
3450
- {{ section.settings.logo | image_url: width: 300 | image_tag: alt: shop.name, class: 'ds-header-logo' }}
3451
- {%- else -%}
3452
- {{ shop.name }}
3453
- {%- endif -%}
3454
- </a>
3455
- <nav class="ds-header-nav" aria-label="Main">
3456
- {%- for link in section.settings.menu.links -%}
3457
- <a href="{{ link.url }}"{% if link.current %} aria-current="page"{% endif %}>{{ link.title }}</a>
3458
- {%- endfor -%}
3459
- </nav>
3460
- <a class="ds-btn ds-btn-outline" href="{{ routes.cart_url }}">Cart ({{ cart.item_count }})</a>
3461
- </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>
3462
4393
  {% schema %}
3463
4394
  {
3464
4395
  "name": "DS Header",
3465
4396
  "settings": [
3466
4397
  { "type": "image_picker", "id": "logo", "label": "Logo" },
3467
- { "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 }
3468
4405
  ],
3469
4406
  "presets": [{ "name": "DS Header" }]
3470
4407
  }
3471
4408
  {% endschema %}`;
3472
4409
  SECTION_FOOTER = `{% comment %}
3473
- 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.
3474
4412
  {% endcomment %}
3475
4413
  <footer class="ds ds-embed ds-footer">
3476
- <span class="ds-header-brand">{{ shop.name }}</span>
3477
- <nav aria-label="Footer">
3478
- {%- for link in section.settings.menu.links -%}
3479
- <a href="{{ link.url }}">{{ link.title }}</a>
3480
- {%- endfor -%}
3481
- </nav>
3482
- <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>
3483
4452
  </footer>
3484
4453
  {% schema %}
3485
4454
  {
3486
4455
  "name": "DS Footer",
3487
4456
  "settings": [
3488
- { "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 }
3489
4467
  ],
3490
4468
  "presets": [{ "name": "DS Footer" }]
3491
4469
  }
3492
4470
  {% endschema %}`;
3493
4471
  SECTION_HERO = `{% comment %}
3494
- 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.
3495
4474
  {% endcomment %}
3496
- <div class="ds ds-embed ds-container ds-hero">
3497
- {%- if section.settings.image -%}
3498
- {%- assign ds_image_alt = section.settings.heading | strip_html -%}
3499
- {{ section.settings.image | image_url: width: 1600 | image_tag: widths: '750, 1100, 1600', sizes: '100vw', alt: ds_image_alt }}
3500
- {%- endif -%}
3501
- <h2 class="ds-display">{{ section.settings.heading }}</h2>
3502
- {%- if section.settings.text != blank -%}<p class="ds-supporting">{{ section.settings.text }}</p>{%- endif -%}
3503
- <div class="ds-hero-actions">
3504
- {%- if section.settings.button_label != blank -%}
3505
- <a class="ds-btn ds-btn-primary" href="{{ section.settings.button_link }}">{{ section.settings.button_label }}</a>
3506
- {%- endif -%}
3507
- {%- if section.settings.secondary_label != blank -%}
3508
- <a class="ds-btn ds-btn-outline" href="{{ section.settings.secondary_link }}">{{ section.settings.secondary_label }}</a>
3509
- {%- 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 -%}
3510
4517
  </div>
3511
- </div>
4518
+ </section>
3512
4519
  {% schema %}
3513
4520
  {
3514
4521
  "name": "DS Hero",
3515
4522
  "settings": [
4523
+ { "type": "product", "id": "product", "label": "Featured product" },
3516
4524
  { "type": "image_picker", "id": "image", "label": "Image" },
3517
- { "type": "inline_richtext", "id": "heading", "label": "Heading", "default": "Tell your story" },
3518
- { "type": "text", "id": "text", "label": "Supporting line" },
3519
- { "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" },
3520
4528
  { "type": "url", "id": "button_link", "label": "Button link" },
3521
4529
  { "type": "text", "id": "secondary_label", "label": "Secondary button label" },
3522
- { "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." }
3523
4532
  ],
3524
4533
  "presets": [{ "name": "DS Hero" }]
3525
4534
  }
@@ -3527,13 +4536,15 @@ var init_shopify_kit = __esm({
3527
4536
  SECTION_RICH_TEXT = `{% comment %}
3528
4537
  Heading plus rich text, with an optional button.
3529
4538
  {% endcomment %}
3530
- <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">
3531
4541
  {%- if section.settings.heading != blank -%}<h2 class="ds-section-title">{{ section.settings.heading }}</h2>{%- endif -%}
3532
- <div>{{ section.settings.text }}</div>
4542
+ <div class="ds-section-copy">{{ section.settings.text }}</div>
3533
4543
  {%- if section.settings.button_label != blank -%}
3534
- <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>
3535
4545
  {%- endif -%}
3536
4546
  </div>
4547
+ </section>
3537
4548
  {% schema %}
3538
4549
  {
3539
4550
  "name": "DS Rich text",
@@ -3549,23 +4560,38 @@ var init_shopify_kit = __esm({
3549
4560
  SECTION_IMAGE_WITH_TEXT = `{% comment %}
3550
4561
  Image beside heading, text, and an optional button.
3551
4562
  {% endcomment %}
3552
- <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">
3553
4567
  {%- if section.settings.image -%}
3554
4568
  {%- assign ds_image_alt = section.settings.heading | strip_html -%}
3555
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>
3556
4579
  {%- endif -%}
4580
+ </div>{%- endif -%}
3557
4581
  <div class="ds-product-info">
3558
4582
  <h2 class="ds-section-title">{{ section.settings.heading }}</h2>
3559
- <div>{{ section.settings.text }}</div>
4583
+ <div class="ds-section-copy">{{ section.settings.text }}</div>
3560
4584
  {%- if section.settings.button_label != blank -%}
3561
4585
  <p><a class="ds-btn ds-btn-primary" href="{{ section.settings.button_link }}">{{ section.settings.button_label }}</a></p>
3562
4586
  {%- endif -%}
3563
4587
  </div>
3564
4588
  </div>
4589
+ </section>
3565
4590
  {% schema %}
3566
4591
  {
3567
4592
  "name": "DS Image with text",
3568
4593
  "settings": [
4594
+ { "type": "product", "id": "product", "label": "Product image fallback" },
3569
4595
  { "type": "image_picker", "id": "image", "label": "Image" },
3570
4596
  { "type": "inline_richtext", "id": "heading", "label": "Heading", "default": "Pair text with an image" },
3571
4597
  { "type": "richtext", "id": "text", "label": "Text" },
@@ -3582,10 +4608,14 @@ var init_shopify_kit = __esm({
3582
4608
  <div class="ds ds-embed ds-container ds-product-page">
3583
4609
  <div class="ds-product-layout">
3584
4610
  <div class="ds-product-media">
4611
+ <div class="ds-card ds-product-media-main">
3585
4612
  {%- if product.featured_image -%}
3586
4613
  {%- assign ds_image_alt = product.featured_image.alt | default: product.title -%}
3587
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>
3588
4617
  {%- endif -%}
4618
+ </div>
3589
4619
  {%- if product.images.size > 1 -%}
3590
4620
  <div class="ds-product-thumbs">
3591
4621
  {%- for image in product.images offset: 1 limit: 4 -%}
@@ -3595,12 +4625,13 @@ var init_shopify_kit = __esm({
3595
4625
  </div>
3596
4626
  {%- endif -%}
3597
4627
  </div>
3598
- <div class="ds-product-info">
3599
- {%- if section.settings.show_vendor -%}<p class="ds-supporting">{{ product.vendor }}</p>{%- endif -%}
4628
+ <div class="ds-card ds-product-info">
3600
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 -%}
3601
4632
  {% render 'ds-price', variant: product.selected_or_first_available_variant %}
3602
4633
  {% render 'ds-buy-button', product: product, show_quantity: section.settings.show_quantity %}
3603
- <div>{{ product.description }}</div>
4634
+ <div class="ds-product-description">{{ product.description }}</div>
3604
4635
  </div>
3605
4636
  </div>
3606
4637
  </div>
@@ -3620,9 +4651,11 @@ var init_shopify_kit = __esm({
3620
4651
  {% endcomment %}
3621
4652
  {% paginate collection.products by section.settings.products_per_page %}
3622
4653
  <div class="ds ds-embed ds-container ds-collection-page">
3623
- <h1 class="ds-display">{{ collection.title }}</h1>
3624
- {%- if collection.description != blank -%}<div class="ds-supporting">{{ collection.description }}</div>{%- endif -%}
3625
- <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 }}">
3626
4659
  {%- for product in collection.products -%}
3627
4660
  {% render 'ds-product-card', product: product, show_vendor: section.settings.show_vendor, enable_quick_add: section.settings.enable_quick_add %}
3628
4661
  {%- else -%}
@@ -3647,9 +4680,10 @@ var init_shopify_kit = __esm({
3647
4680
  SECTION_CONTACT = `{% comment %}
3648
4681
  Contact form posting to Shopify's contact endpoint. No JavaScript.
3649
4682
  {% endcomment %}
3650
- <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">
3651
4685
  <h2 class="ds-section-title">{{ section.settings.heading }}</h2>
3652
- {% form 'contact' %}
4686
+ {% form 'contact', class: 'ds-card ds-contact-form' %}
3653
4687
  {%- if form.posted_successfully? -%}
3654
4688
  <p role="status">Thanks for your message. We reply within one business day.</p>
3655
4689
  {%- else -%}
@@ -3670,6 +4704,7 @@ var init_shopify_kit = __esm({
3670
4704
  {%- endif -%}
3671
4705
  {% endform %}
3672
4706
  </div>
4707
+ </section>
3673
4708
  {% schema %}
3674
4709
  {
3675
4710
  "name": "DS Contact",
@@ -3682,19 +4717,22 @@ var init_shopify_kit = __esm({
3682
4717
  SECTION_MULTICOLUMN = `{% comment %}
3683
4718
  Row of text cards, for value props or feature callouts.
3684
4719
  {% endcomment %}
3685
- <div class="ds ds-embed ds-container" style="padding:2rem 0">
3686
- {%- if section.settings.heading != blank -%}<h2 class="ds-section-title">{{ section.settings.heading }}</h2>{%- endif -%}
3687
- <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 }}">
3688
4724
  {%- for block in section.blocks -%}
3689
- <div class="ds-card" {{ block.shopify_attributes }}>
3690
- <div class="ds-card-body">
3691
- <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>
3692
4730
  <p class="ds-card-desc">{{ block.settings.text }}</p>
3693
- </div>
3694
4731
  </div>
3695
4732
  {%- endfor -%}
3696
4733
  </div>
3697
4734
  </div>
4735
+ </section>
3698
4736
  {% schema %}
3699
4737
  {
3700
4738
  "name": "DS Multicolumn",
@@ -3707,6 +4745,7 @@ var init_shopify_kit = __esm({
3707
4745
  "type": "column",
3708
4746
  "name": "Column",
3709
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" }] },
3710
4749
  { "type": "text", "id": "heading", "label": "Heading" },
3711
4750
  { "type": "text", "id": "text", "label": "Text" }
3712
4751
  ]
@@ -3716,27 +4755,231 @@ var init_shopify_kit = __esm({
3716
4755
  "presets": [{ "name": "DS Multicolumn", "blocks": [{ "type": "column" }, { "type": "column" }, { "type": "column" }] }]
3717
4756
  }
3718
4757
  {% endschema %}`;
3719
- TEMPLATE_PRODUCT_JSON = `{
3720
- "sections": {
3721
- "main": {
3722
- "type": "ds-main-product",
3723
- "settings": {}
3724
- }
3725
- },
3726
- "order": ["main"]
3727
- }`;
3728
- TEMPLATE_COLLECTION_JSON = `{
3729
- "sections": {
3730
- "main": {
3731
- "type": "ds-main-collection",
3732
- "settings": {}
3733
- }
3734
- },
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
+ }
4976
+ },
3735
4977
  "order": ["main"]
3736
4978
  }`;
3737
4979
  SNIPPETS = [
3738
4980
  { path: "snippets/ds-badge.liquid", content: SNIPPET_BADGE },
3739
4981
  { path: "snippets/ds-price.liquid", content: SNIPPET_PRICE },
4982
+ { path: "snippets/ds-icon.liquid", content: SNIPPET_ICON },
3740
4983
  { path: "snippets/ds-variant-select.liquid", content: SNIPPET_VARIANT_SELECT },
3741
4984
  { path: "snippets/ds-buy-button.liquid", content: SNIPPET_BUY_BUTTON },
3742
4985
  { path: "snippets/ds-product-card.liquid", content: SNIPPET_PRODUCT_CARD },
@@ -3754,15 +4997,32 @@ var init_shopify_kit = __esm({
3754
4997
  { path: "sections/ds-featured-collection.liquid", content: SECTION_FEATURED_COLLECTION },
3755
4998
  { path: "sections/ds-main-product.liquid", content: SECTION_MAIN_PRODUCT },
3756
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 },
3757
5013
  { path: "templates/product.ds.json", content: TEMPLATE_PRODUCT_JSON },
3758
5014
  { path: "templates/collection.ds.json", content: TEMPLATE_COLLECTION_JSON }
3759
5015
  ];
3760
5016
  SHOPIFY_KIT_CONDUCT_MD = [
3761
5017
  "## Install into the merchant's theme (mandatory order)",
3762
- "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.",
3763
- "2. Load the stylesheet once in `layout/theme.liquid`, before `</head>`: `{{ 'ds-kit.css' | asset_url | stylesheet_tag }}`.",
3764
- '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.',
3765
- "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.",
3766
5026
  "",
3767
5027
  "## Class contract (pass/fail)",
3768
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.",
@@ -3782,6 +5042,43 @@ var init_shopify_kit = __esm({
3782
5042
  }
3783
5043
  });
3784
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
+
3785
5082
  // ../shared/src/mcp/preview-widget-sdk.generated.ts
3786
5083
  var PREVIEW_WIDGET_SDK_JS;
3787
5084
  var init_preview_widget_sdk_generated = __esm({
@@ -4446,6 +5743,26 @@ function decodeArbitrary(raw) {
4446
5743
  const v = raw.replace(/(?<!\\)_/g, " ").replace(/\\_/g, "_").trim();
4447
5744
  return v && !/[{}<>;]/.test(v) ? v : null;
4448
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
+ }
4449
5766
  function animateModifier(base) {
4450
5767
  const spin = {
4451
5768
  "animate-spin": "animation:spin 1s linear infinite",
@@ -4511,13 +5828,23 @@ function translateBase(base) {
4511
5828
  if (arb) {
4512
5829
  const val = decodeArbitrary(arb[2]);
4513
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(";");
4514
5842
  const prop = ARBITRARY_PROP[arb[1]];
4515
5843
  if (prop) return `${prop}:${val}`;
4516
5844
  const colorProp = COLOR_PROP[arb[1]];
4517
- if (colorProp) {
4518
- const v = arb[3] ? `color-mix(in oklab, ${val} ${arb[3]}%, transparent)` : val;
4519
- return `${colorProp}:${v}`;
4520
- }
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)`;
4521
5848
  }
4522
5849
  }
4523
5850
  const rside = base.match(/^rounded-(t|r|b|l|tl|tr|br|bl)(?:-(.+))?$/);
@@ -4619,7 +5946,7 @@ function splitVariants(cls) {
4619
5946
  out.push(cur);
4620
5947
  return out;
4621
5948
  }
4622
- 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;
4623
5950
  var init_tw_translate = __esm({
4624
5951
  "../shared/src/mcp/tw-translate.ts"() {
4625
5952
  "use strict";
@@ -5047,15 +6374,12 @@ var init_tw_translate = __esm({
5047
6374
  "rounded-xs": "border-radius:var(--radius-xs, 0.125rem)"
5048
6375
  };
5049
6376
  ARBITRARY_PROP = {
5050
- bg: "background",
5051
- text: "color",
5052
6377
  w: "width",
5053
6378
  h: "height",
5054
6379
  "min-w": "min-width",
5055
6380
  "max-w": "max-width",
5056
6381
  "min-h": "min-height",
5057
6382
  "max-h": "max-height",
5058
- size: "width",
5059
6383
  p: "padding",
5060
6384
  px: "padding-inline",
5061
6385
  py: "padding-block",
@@ -5072,8 +6396,6 @@ var init_tw_translate = __esm({
5072
6396
  ml: "margin-left",
5073
6397
  gap: "gap",
5074
6398
  rounded: "border-radius",
5075
- shadow: "box-shadow",
5076
- border: "border-width",
5077
6399
  top: "top",
5078
6400
  left: "left",
5079
6401
  right: "right",
@@ -5084,14 +6406,93 @@ var init_tw_translate = __esm({
5084
6406
  opacity: "opacity",
5085
6407
  z: "z-index",
5086
6408
  grid: "grid-template-columns",
5087
- "font": "font-size",
5088
6409
  "aspect": "aspect-ratio",
5089
- // transition props (transition-[background-color,box-shadow]) + timing
5090
- transition: "transition-property",
5091
6410
  duration: "transition-duration",
5092
6411
  delay: "transition-delay",
5093
6412
  ease: "transition-timing-function"
5094
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;
5095
6496
  MAX_W_SCALE = {
5096
6497
  xs: "20rem",
5097
6498
  sm: "24rem",
@@ -5168,47 +6569,73 @@ function decodeArbitrary2(raw) {
5168
6569
  const value = raw.replace(/(?<!\\)_/g, " ").replace(/\\_/g, "_").trim();
5169
6570
  return value && !/[<>{};]/.test(value) ? value : null;
5170
6571
  }
5171
- function restingValue(scope, cssProp, utilPrefixes) {
6572
+ function restingDeclaration(scope, cssProp, utilPrefixes) {
5172
6573
  for (const prefix of utilPrefixes) {
5173
6574
  const m = scope.match(new RegExp(`(?<![\\w:.\\-])${prefix}-\\[([^\\]]+)\\]`));
5174
6575
  if (m) {
5175
6576
  const v = decodeArbitrary2(m[1]);
5176
- if (v) return v;
6577
+ if (v) return resolveArbitraryDeclaration(prefix, v) ?? { property: cssProp, value: v };
5177
6578
  }
5178
6579
  }
5179
6580
  const pm = scope.match(new RegExp(`(?<!:)\\[${cssProp}:([^\\]]+)\\]`));
5180
- 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;
5181
6587
  }
5182
6588
  function variantBody(content, name) {
5183
6589
  const vi = content.search(/variant\s*:\s*\{/);
5184
6590
  const hay = vi !== -1 ? content.slice(vi) : content;
6591
+ const key = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
5185
6592
  const m = hay.match(
5186
- 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
+ )
5187
6596
  );
5188
6597
  return m ? m[1] : null;
5189
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
+ }
5190
6607
  function componentDepthCss(files) {
5191
6608
  const btn = files.find((f) => f.path && /(^|\/)button\.(tsx|jsx)$/.test(f.path));
5192
6609
  if (!btn?.content) return "";
5193
6610
  const content = btn.content;
5194
- const scope = variantBody(content, "primary") ?? variantBody(content, "default") ?? content;
6611
+ const scope = canonicalButtonVariantBody(content) ?? content;
5195
6612
  const shadow = /(?:^|\s|"|')shadow-none(?=\s|"|'|$)/.test(scope) ? "none" : restingValue(scope, "box-shadow", ["shadow"]);
5196
- const background = restingValue(scope, "background", ["bg"]) ?? restingValue(scope, "background-image", []);
6613
+ const background = restingDeclaration(scope, "background", ["bg"]) ?? restingDeclaration(scope, "background-image", []);
5197
6614
  const sel = ".bd-btn-primary, .bd-view, .ds-btn-primary";
5198
6615
  const rules = [];
5199
6616
  if (shadow) rules.push(`${sel} { box-shadow: ${shadow}; }`);
5200
- if (background) rules.push(`${sel} { background: ${background}; }`);
6617
+ if (background) rules.push(`${sel} { ${background.property}: ${background.value}; }`);
5201
6618
  return rules.join("\n");
5202
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
+ }
5203
6636
  function buildDsComponentCss(files) {
5204
6637
  const contentOf = (re) => stripComments2(files.find((f) => f.path && re.test(f.path))?.content ?? "");
5205
6638
  const has = (name) => Boolean(contentOf(new RegExp(`(^|/)${name}\\.(tsx|jsx)$`)));
5206
- const arbitrary = (scope, re) => {
5207
- const m = scope.match(re);
5208
- if (!m) return null;
5209
- const value = m[1].replace(/(?<!\\)_/g, " ").replace(/\\_/g, "_").trim();
5210
- return value && !/[<>{};]/.test(value) ? value : null;
5211
- };
5212
6639
  const rules = [componentRadiusCss(files), componentDepthCss(files)];
5213
6640
  const covered = /* @__PURE__ */ new Set();
5214
6641
  for (const name of ["button", "input", "alert", "avatar", "table", "tabs", "progress"]) {
@@ -5223,9 +6650,9 @@ function buildDsComponentCss(files) {
5223
6650
  const sw = contentOf(/(^|\/)switch\.(tsx|jsx)$/);
5224
6651
  if (sw) {
5225
6652
  covered.add("switch");
5226
- const bg = arbitrary(sw, /data-\[state=checked\]:bg-\[([^\]]+)\]/) ?? arbitrary(sw, /data-\[state=checked\]:\[background(?:-image)?:([^\]]+)\]/);
6653
+ const bg = checkedTrackBackground(sw);
5227
6654
  const shadow = arbitrary(sw, /data-\[state=checked\]:shadow-\[([^\]]+)\]/) ?? arbitrary(sw, /data-\[state=checked\]:\[box-shadow:([^\]]+)\]/);
5228
- if (bg) rules.push(`.ds-switch-track.on { background: ${bg}; }`);
6655
+ if (bg) rules.push(`.ds-switch-track.on { ${bg.property}: ${bg.value}; }`);
5229
6656
  if (shadow) rules.push(`.ds-switch-track.on { box-shadow: ${shadow}; }`);
5230
6657
  }
5231
6658
  const badge = contentOf(/(^|\/)badge\.(tsx|jsx)$/);
@@ -5311,10 +6738,11 @@ function translatedKitComponentCss(contentOf, covered) {
5311
6738
  const button = contentOf(/(^|\/)button\.(tsx|jsx)$/);
5312
6739
  if (button) {
5313
6740
  emit(".ds-btn", cvaBase(button), "button");
5314
- 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);
5315
6743
  for (const v of ["secondary", "outline", "ghost"]) {
5316
6744
  const body = variantBody(button, v);
5317
- if (body) emit(`.ds-btn-${v}`, quotedStrings(body));
6745
+ if (body) emit(`.ds .ds-btn-${v}`, quotedStrings(body));
5318
6746
  }
5319
6747
  }
5320
6748
  const shared = contentOf(/(^|\/)_shared\.(ts|tsx)$/);
@@ -5459,15 +6887,13 @@ ${PREVIEW_BASE_CSS}
5459
6887
  .bd-app { padding: 16px; }
5460
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)); }
5461
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)); }
5462
- .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; }
5463
- .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; }
5464
6891
  .bd-cta-actions { display: inline-flex; align-items: center; gap: 14px; }
5465
- .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; }
5466
- .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; }
5467
6894
  .bd-progress { padding: 6px 16px 12px; }
5468
- /* Fullscreen: the DS preview rendered IN-WIDGET (token-driven), full-bleed. No
5469
- iframe \u2014 claude.ai's sandbox blocks nested cross-origin frames and the
5470
- 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. */
5471
6897
  .bd-fs { position: fixed; inset: 0; display: flex; flex-direction: column; background: var(--background, oklch(1 0 0)); }
5472
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; }
5473
6899
  .bd-fs-body { flex: 1; overflow-y: auto; padding: 20px; max-width: 860px; width: 100%; margin: 0 auto; }
@@ -5514,14 +6940,14 @@ ${DS_KIT_CSS}
5514
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; }
5515
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; }
5516
6942
  .bd-nav-list { display: flex; flex-direction: column; gap: 1px; padding: 6px 6px 8px; }
5517
- .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; }
5518
- .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)); }
5519
6945
  .bd-nav-dot { width: 5px; height: 5px; border-radius: 50%; flex-shrink: 0; background: var(--border, oklch(0.85 0 0)); }
5520
6946
  .bd-nav-item.active .bd-nav-dot { background: var(--primary, oklch(0.45 0.15 264)); }
5521
6947
  /* SectionHeader (component-showcase.tsx:1659-1680). */
5522
6948
  .bd-sh { margin-bottom: 20px; margin-top: 64px; padding-top: 48px; border-top: 1px solid var(--border, oklch(0.9 0 0)); }
5523
6949
  .bd-sh.first { margin-top: 28px; padding-top: 0; border-top: none; }
5524
- .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)); }
5525
6951
  /* Grid (component-showcase.tsx:1565-1571 \u2014 cols-1 / sm:2 / xl:3, gap-3 = 12px).
5526
6952
  Own class (not the vignette's .bd-grid) + explicit responsive columns capped
5527
6953
  at 3, so the fullscreen main never renders a 4th column. [BD-117] */
@@ -5541,26 +6967,18 @@ ${DS_KIT_CSS}
5541
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); }
5542
6968
  .bd-fs .bd-show-scroll { max-height: none; overflow: visible; -webkit-mask-image: none; mask-image: none; }
5543
6969
  .bd-fs .bd-main { max-width: 920px; }
5544
- /* Real-page iframe (ChatGPT). Inline = capped height + footer; fullscreen =
5545
- full viewport + a Back bar. The iframe shows the actual /design-systems/<id>
5546
- page (?embed=1, no app nav), so the components are real, not approximated. */
5547
- .bd-frame-wrap { display: flex; flex-direction: column; background: var(--background, oklch(1 0 0)); }
5548
- .bd-frame { width: 100%; border: 0; display: block; background: var(--background, oklch(1 0 0)); height: 460px; }
5549
- .bd-frame-wrap.bd-fs { position: fixed; inset: 0; height: 100vh; }
5550
- .bd-frame-wrap.bd-fs .bd-frame { flex: 1; height: auto; }
5551
- /* Real-page screenshot (claude.ai, which can't iframe). Inline = capped + fade;
5552
- fullscreen = scrollable. [BD-136] */
6970
+ /* Real-page screenshot. Inline = capped + fade; fullscreen = scrollable. */
5553
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); }
5554
- .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; }
5555
6973
  .bd-shot { display: block; width: 100%; height: auto; }
5556
- .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))); }
5557
- .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)); }
5558
6976
  .bd-cta-actions { display: inline-flex; align-items: center; gap: 14px; }
5559
- /* claude.ai link-out card (can't iframe). */
5560
- .bd-link-card { padding: 22px 20px; display: flex; flex-direction: column; gap: 6px; align-items: flex-start; }
5561
- .bd-link-title { font-size: 17px; font-weight: 600; color: var(--foreground, oklch(0.2 0 0)); }
5562
- .bd-link-desc { font-size: 13px; color: var(--muted-foreground, oklch(0.5 0 0)); margin: 0 0 6px; }
5563
- .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
+ }
5564
6982
  </style>
5565
6983
  <style id="bd-tokens"></style>
5566
6984
  </head>
@@ -5582,7 +7000,7 @@ ${DS_KIT_CSS}
5582
7000
  return String(s == null ? "" : s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
5583
7001
  }
5584
7002
 
5585
- // 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
5586
7004
  // javascript:/data:/etc. so a hostile MCP payload can't inject a scheme that
5587
7005
  // executes in the widget sandbox. Returns "" for anything else.
5588
7006
  function safeUrl(u) {
@@ -5631,12 +7049,12 @@ ${DS_KIT_CSS}
5631
7049
  return showcaseSections().map(function (s, i) {
5632
7050
  var cards = s[2].map(function (it) {
5633
7051
  return '<div class="bd-dc" data-name="' + esc(it[0].toLowerCase()) + '">' +
5634
- '<div class="bd-dc-demo">' + it[1] + '</div>' +
7052
+ '<div class="bd-dc-demo" inert aria-hidden="true">' + it[1] + '</div>' +
5635
7053
  '<div class="bd-dc-foot">' + esc(it[0]) + '</div>' +
5636
7054
  '</div>';
5637
7055
  }).join("");
5638
7056
  return '<section class="bd-sec" data-sec="' + esc(s[0]) + '">' +
5639
- '<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>' +
5640
7058
  '<div class="bd-show-grid">' + cards + '</div>' +
5641
7059
  '</section>';
5642
7060
  }).join("");
@@ -5655,7 +7073,7 @@ ${DS_KIT_CSS}
5655
7073
  // for each semantic color token. Fullscreen only (CSS). [BD-117]
5656
7074
  function paletteHtml() {
5657
7075
  var TOK = [["Background", "background"], ["Foreground", "foreground"], ["Primary", "primary"], ["Secondary", "secondary"], ["Accent", "accent"], ["Muted", "muted"], ["Card", "card"], ["Border", "border"], ["Destructive", "destructive"], ["Ring", "ring"]];
5658
- 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">' +
5659
7077
  TOK.map(function (t) {
5660
7078
  return '<div class="bd-tok"><span class="bd-tok-sw" style="background: var(--' + t[1] + ', transparent)"></span>' + esc(t[0]) + '</div>';
5661
7079
  }).join("") + '</div></div></aside>';
@@ -5663,7 +7081,7 @@ ${DS_KIT_CSS}
5663
7081
 
5664
7082
  // The full showcase. Mirrors the real page: a header (h1 + description), then a
5665
7083
  // 3-column layout (left nav + main sections + right palette). Same render on
5666
- // 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
5667
7085
  // like the real page below lg/xl; fullscreen shows everything. [BD-117]
5668
7086
  function showcaseBody(out, full) {
5669
7087
  var nm = esc(out.name || "Design system");
@@ -5721,7 +7139,7 @@ ${DS_KIT_CSS}
5721
7139
  de.removeProperty("--font-serif");
5722
7140
  de.removeProperty("--font-mono");
5723
7141
  } else if (hostFontsPayload && applyHostFontsFn) {
5724
- try { applyHostFontsFn(hostFontsPayload); } catch (e) {}
7142
+ try { applyHostFontsFn(hostFontsPayload); } catch (e) { /* best-effort: host font apply; fallback fonts stand */ }
5725
7143
  }
5726
7144
  }
5727
7145
 
@@ -5732,15 +7150,15 @@ ${DS_KIT_CSS}
5732
7150
  // mode field = declined); only switch to what it actually granted.
5733
7151
  var apply = function (m) { displayMode = m || displayMode; if (lastOut) render(lastOut); };
5734
7152
  try {
5735
- if (bdApp && bdApp.requestDisplayMode) {
5736
- bdApp.requestDisplayMode({ mode: mode }).then(function (r) { apply(r && r.mode); }, function () {});
5737
- return;
5738
- }
5739
7153
  if (window.openai && window.openai.requestDisplayMode) {
5740
7154
  Promise.resolve(window.openai.requestDisplayMode({ mode: mode })).then(function (r) { apply(r && r.mode); }, function () {});
5741
7155
  return;
5742
7156
  }
5743
- } 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 */ }
5744
7162
  apply(mode);
5745
7163
  }
5746
7164
 
@@ -5771,91 +7189,32 @@ ${DS_KIT_CSS}
5771
7189
  return !!(modes && modes.indexOf && modes.indexOf("fullscreen") !== -1);
5772
7190
  }
5773
7191
 
5774
- // ChatGPT (OpenAI Apps) honors frameDomains, so we can iframe our own origin;
5775
- // claude.ai hardcodes frame-src and ignores frameDomains (anthropics/claude-ai
5776
- // -mcp#40), so it can never iframe \u2014 there we link out instead. [BD-117]
5777
- //
5778
- // Identify ChatGPT by window.openai ALONE. Do NOT also test !bdApp: bdApp is
5779
- // our OWN inlined MCP-Apps client (window.__bdMcp), instantiated
5780
- // unconditionally on every host, so it is always set after first paint. The
5781
- // old check (window.openai AND not bdApp) therefore flipped to false on every
5782
- // re-render (Fullscreen, set_globals), wrongly showing the claude.ai link card
5783
- // on ChatGPT. window.openai is absent on claude.ai, so it is the right signal.
5784
- function canIframe() {
5785
- return !!window.openai;
5786
- }
5787
-
5788
- // The real page, with ?embed=1 so it renders the showcase WITHOUT the app nav
5789
- // (clean component showcase to embed). [BD-117]
5790
- function embedUrl(view) {
5791
- if (!view) return "";
5792
- return view + (view.indexOf("?") === -1 ? "?embed=1" : "&embed=1");
5793
- }
5794
-
5795
- // Embed the REAL /design-systems/<id> (or /projects/<id>) page \u2014 the actual
5796
- // React components, not a token approximation. Inline = capped height + a
5797
- // footer (Fullscreen + Open). Fullscreen = full-bleed iframe with NO header of
5798
- // our own: both hosts render a native close button in fullscreen (ChatGPT's
5799
- // top bar, claude.ai's native header), so our own Back bar was redundant and
5800
- // visually broken (misaligned, disliked chevron). [BD-136]
5801
- // embed = the PUBLIC, cookieless URL we iframe (the sandbox carries no
5802
- // session cookie, so an owner-scoped page would 404 inside it). open = the
5803
- // owner-facing page for the human "Open" link (they have the cookie). [BD-164]
5804
- function iframeBody(out, embed, open, full) {
5805
- var nm = esc(out.name || "Design system");
5806
- var frame = '<iframe class="bd-frame" src="' + esc(embedUrl(embed)) + '" title="' + nm + ' showcase" referrerpolicy="no-referrer-when-downgrade"></iframe>';
5807
- if (full) {
5808
- return '<div class="bd-frame-wrap bd-fs">' + frame + '</div>';
5809
- }
5810
- var openHref = open || embed;
5811
- return '<div class="bd-frame-wrap">' + frame +
5812
- '<div class="bd-frame-foot"><span class="bd-cta-name">' + nm + '</span><span class="bd-cta-actions">' +
5813
- (canFullscreen() ? '<button class="bd-expand" data-bd-fullscreen type="button">Fullscreen' + ICON_EXPAND + '</button>' : "") +
5814
- '<a class="bd-cta" data-bd-open href="' + esc(openHref) + '" target="_blank" rel="noopener">Open' + ICON_EXTERNAL + '</a>' +
5815
- '</span></div>' +
5816
- '</div>';
5817
- }
5818
-
5819
- // claude.ai can't iframe \u2014 surface the REAL page as a browser link (no
5820
- // approximation, just not embedded). [BD-117]
5821
- function linkBody(out, view) {
5822
- var nm = esc(out.name || "Design system");
5823
- return '<div class="bd-link-card">' +
5824
- '<div class="bd-link-title">' + nm + '</div>' +
5825
- '<p class="bd-link-desc">Open the live showcase to see every component, styled by this system.</p>' +
5826
- '<a class="bd-cta bd-cta-lg" data-bd-open href="' + esc(view) + '" target="_blank" rel="noopener">Open live showcase' + ICON_EXTERNAL + '</a>' +
5827
- '</div>';
5828
- }
5829
-
5830
- // The name + Fullscreen + Open footer, shared by the iframe / screenshot /
5831
- // token previews. Name left, actions right (CSS handles the split). [BD-136]
5832
- function footHtml(out) {
7192
+ // The name + Fullscreen + Open footer shared by screenshot and token previews.
7193
+ function footHtml(out, full) {
5833
7194
  var view = safeUrl(out.viewUrl);
5834
- return '<div class="bd-frame-foot"><span class="bd-cta-name">' + esc(out.name || "Design system") + '</span><span class="bd-cta-actions">' +
5835
- (canFullscreen() ? '<button class="bd-expand" data-bd-fullscreen type="button">Fullscreen' + ICON_EXPAND + '</button>' : "") +
5836
- (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>' : "") +
5837
7198
  '</span></div>';
5838
7199
  }
5839
7200
 
5840
- // A REAL screenshot of the showcase page (server-rendered via Cloudflare
5841
- // Browser Rendering), shown where the page can't be iframed (claude.ai). Real
5842
- // components + real CSS, just a flat image. inline = capped + fade; fullscreen
5843
- // = scrollable. Falls back to the token render if the image fails. [BD-136]
5844
- function shotBody(out, full) {
5845
- 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">' +
5846
7206
  '<img class="bd-shot" data-bd-shot src="' + esc(safeUrl(out.shotUrl)) + '" alt="' + esc(out.name || "Design system") + ' showcase" />' +
5847
7207
  '</div>';
5848
7208
  }
5849
7209
 
5850
- // If the screenshot 404s / errors, drop to the token render so claude.ai still
5851
- // shows a preview rather than a broken image. [BD-136]
7210
+ // If the screenshot 404s or errors, show the token render instead.
5852
7211
  function wireShotFallback() {
5853
7212
  var img = root.querySelector("[data-bd-shot]");
5854
7213
  if (!img) return;
5855
7214
  img.addEventListener("error", function () { shotFailed = true; if (lastOut) render(lastOut); });
5856
7215
  // The screenshot loads async; its real height only exists after it decodes.
5857
- // claude.ai reads the widget's documentElement height from the DOM, so re-pin
5858
- // 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
5859
7218
  // be complete before this runs). [BD-136]
5860
7219
  img.addEventListener("load", scheduleResize);
5861
7220
  if (img.complete && img.naturalHeight) scheduleResize();
@@ -5868,43 +7227,30 @@ ${DS_KIT_CSS}
5868
7227
  syncHostFonts();
5869
7228
  // Only an http(s) URL is usable; reject any other scheme up front.
5870
7229
  var view = safeUrl(out.viewUrl);
5871
- // The iframe must load a PUBLIC, cookieless URL (the sandbox has no session
5872
- // cookie). previewUrl points at /preview/<fragmentId>; fall back to view if
5873
- // the host didn't supply one (e.g. catalog DSes, whose view is public). [BD-164]
5874
- var embed = safeUrl(out.previewUrl) || view;
5875
7230
  var isError = out.status === "error";
5876
7231
  var generating = out.status && out.status !== "completed" && !isError;
5877
7232
  var completed = !isError && !generating;
5878
- // claude.ai (can't iframe): prefer a REAL screenshot; fall back to the token
5879
- // render if there's no shotUrl or the image failed to load.
5880
- var useShot = !shotFailed && !!safeUrl(out.shotUrl);
5881
-
5882
- // ChatGPT's fullscreen "Open in <app>" button defaults to the iframe's
5883
- // current path, which is now the PUBLIC /preview page. Repoint it at the
5884
- // owner's editable /projects/<id> (they have the session cookie on the web,
5885
- // 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]
5886
7239
  if (completed && view && window.openai && window.openai.setOpenInAppUrl) {
5887
- 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 */ }
5888
7241
  }
5889
7242
 
5890
- // Fullscreen: ChatGPT iframes the real page; claude.ai shows the full
5891
- // screenshot (scrollable) or the token showcase. [BD-136]
7243
+ // Fullscreen remains self-contained: show the full screenshot or token UI.
5892
7244
  if (displayMode === "fullscreen" && completed) {
5893
- if (canIframe() && embed) { root.innerHTML = iframeBody(out, embed, view, true); return; }
5894
- 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(); }
5895
7246
  else { root.innerHTML = showcaseBody(out, true); wireShowcase(); }
5896
7247
  return;
5897
7248
  }
5898
7249
 
5899
- // Inline completed: ChatGPT = real-page iframe; claude.ai = real screenshot
5900
- // (capped) or token showcase, + a footer (Fullscreen + Open). [BD-136]
7250
+ // Inline completed: capped screenshot or token showcase, plus clear actions.
5901
7251
  if (completed) {
5902
- if (canIframe() && embed) {
5903
- root.innerHTML = '<div class="bd-card-shell">' + iframeBody(out, embed, view, false) + '</div>';
5904
- return;
5905
- }
5906
- var body = useShot ? shotBody(out, false) : showcaseBody(out, false);
5907
- 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>';
5908
7254
  if (useShot) wireShotFallback();
5909
7255
  return;
5910
7256
  }
@@ -5916,7 +7262,7 @@ ${DS_KIT_CSS}
5916
7262
  var hero = errorBody(out);
5917
7263
  var cta = view
5918
7264
  ? '<div class="bd-cta-row"><span class="bd-cta-name">' + esc(out.name || "Design system") + '</span>' +
5919
- '<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>'
5920
7266
  : "";
5921
7267
  root.innerHTML = '<div class="bd-card-shell">' + hero + cta + "</div>";
5922
7268
  }
@@ -5925,9 +7271,9 @@ ${DS_KIT_CSS}
5925
7271
  // The MCP Apps client emits ui/notifications/size-changed (ResizeObserver on
5926
7272
  // documentElement + body); ChatGPT honors that and auto-sizes its widget.
5927
7273
  // claude.ai web does NOT honor size-changed \u2014 it reads the widget's
5928
- // documentElement height straight from the DOM and pins the iframe to whatever
5929
- // inline height the document carries. With no explicit height the iframe stays
5930
- // 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;
5931
7277
  // claude-ai-mcp#69). So on the DOM-reading hosts we set
5932
7278
  // document.documentElement.style.height ourselves, AFTER content + the async
5933
7279
  // screenshot have real height. [BD-136]
@@ -5965,14 +7311,14 @@ ${DS_KIT_CSS}
5965
7311
  if (typeof MutationObserver === "function") {
5966
7312
  new MutationObserver(scheduleResize).observe(root, { childList: true, subtree: true });
5967
7313
  }
5968
- } catch (e) {}
7314
+ } catch (e) { /* best-effort: sandbox without MutationObserver; the height pin just updates less often */ }
5969
7315
  try {
5970
7316
  if (typeof ResizeObserver === "function") {
5971
7317
  new ResizeObserver(scheduleResize).observe(root);
5972
7318
  }
5973
- } catch (e) {}
7319
+ } catch (e) { /* best-effort: sandbox without ResizeObserver; the height pin just updates less often */ }
5974
7320
 
5975
- // 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>
5976
7322
  // cannot open a tab or navigate the top frame, so the CTA looks dead. Route the
5977
7323
  // click through the host: MCP Apps app.openLink({url}) or OpenAI Apps
5978
7324
  // openai.openExternal({href}). Falls through to the native anchor for hosts
@@ -5982,19 +7328,18 @@ ${DS_KIT_CSS}
5982
7328
  var t = e.target;
5983
7329
  if (!t || !t.closest) return;
5984
7330
  if (t.closest("[data-bd-fullscreen]")) { e.preventDefault(); setDisplayMode("fullscreen"); return; }
5985
- if (t.closest("[data-bd-collapse]")) { e.preventDefault(); setDisplayMode("inline"); return; }
5986
7331
  var a = t.closest("a[data-bd-open]");
5987
7332
  if (!a) return;
5988
7333
  var url = a.getAttribute("href");
5989
7334
  if (!url) return;
5990
- if (bdApp && bdApp.openLink) {
7335
+ if (window.openai && window.openai.openExternal) {
5991
7336
  e.preventDefault();
5992
- 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 */ }
5993
7338
  return;
5994
7339
  }
5995
- if (window.openai && window.openai.openExternal) {
7340
+ if (bdApp && bdApp.openLink) {
5996
7341
  e.preventDefault();
5997
- 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 */ }
5998
7343
  }
5999
7344
  });
6000
7345
 
@@ -6041,9 +7386,9 @@ ${DS_KIT_CSS}
6041
7386
  // this every later host-context-changed (theme/font) would rebuild the
6042
7387
  // DOM and lose scroll + active-section state in fullscreen. [BD-117]
6043
7388
  else if (ctx && ctx.availableDisplayModes && !sawDisplayModes && lastOut) { sawDisplayModes = true; render(lastOut); }
6044
- } catch (e) {}
7389
+ } catch (e) { /* best-effort: a malformed host context must not kill the widget; keep the last rendered state */ }
6045
7390
  };
6046
- 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 */ });
6047
7392
  }
6048
7393
  } catch (e) { /* host without MCP Apps support; OpenAI bridge or static text covers it */ }
6049
7394
  })();
@@ -6053,8 +7398,132 @@ ${DS_KIT_CSS}
6053
7398
  }
6054
7399
  });
6055
7400
 
6056
- // ../shared/src/mcp/tools/design-system-tools.ts
7401
+ // ../shared/src/mcp/shopify-primitives.ts
6057
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";
6058
7527
  function installCommandWithInit(installCommand) {
6059
7528
  return `${SHADCN_INIT_IF_MISSING}
6060
7529
 
@@ -6092,7 +7561,34 @@ async function fetchProjectTokensCss(projectId, apiFetch, authContext) {
6092
7561
  if (!res.ok) return null;
6093
7562
  const json = await res.json();
6094
7563
  return { tokensCss: tokensCssFromRegistry(json), name: json.name, files: json.files ?? [] };
6095
- } 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);
6096
7592
  return null;
6097
7593
  }
6098
7594
  }
@@ -6133,25 +7629,50 @@ function completedShopifyKitStructuredContent(name, viewUrl, tokensCss, themeFil
6133
7629
  tokensCss
6134
7630
  };
6135
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
+ }
6136
7647
  async function fetchCatalog(designSystemId) {
6137
7648
  try {
6138
7649
  const [globalsRes, ...componentResults] = await Promise.all([
6139
7650
  fetch(`${REGISTRY_BASE_URL}/${designSystemId}/globals.json`),
6140
7651
  ...CATALOG_KIT_COMPONENTS.map(
6141
- (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
+ })
6142
7656
  )
6143
7657
  ]);
6144
7658
  if (!globalsRes.ok) return { tokensCss: "", files: [] };
6145
7659
  const globals = REGISTRY_ITEM_SCHEMA.safeParse(await globalsRes.json());
6146
7660
  if (!globals.success) return { tokensCss: "", files: [] };
6147
7661
  const files = [...globals.data.files ?? []];
6148
- for (const res of componentResults) {
6149
- 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
+ }
6150
7670
  const item = REGISTRY_ITEM_SCHEMA.safeParse(await res.json().catch(() => null));
6151
7671
  if (item.success && item.data.files) files.push(...item.data.files);
6152
7672
  }
6153
7673
  return { tokensCss: tokensCssFromRegistry({ cssVars: globals.data.cssVars, files }), files };
6154
- } catch {
7674
+ } catch (error) {
7675
+ console.warn(`catalog registry fetch failed for ${designSystemId}`, error);
6155
7676
  return { tokensCss: "", files: [] };
6156
7677
  }
6157
7678
  }
@@ -6179,8 +7700,8 @@ function registerDesignSystemTools({
6179
7700
  authContext,
6180
7701
  apiFetch = globalThis.fetch
6181
7702
  }, { enableShopifyKit = true } = {}) {
6182
- const searchInputSchema = z5.object({
6183
- query: z5.string().describe(
7703
+ const searchInputSchema = z6.object({
7704
+ query: z6.string().describe(
6184
7705
  "The user's app, UI, industry, or visual direction. Examples: 'World Cup betting app for friends', 'warm editorial ecommerce', or 'professional fintech dashboard'."
6185
7706
  )
6186
7707
  });
@@ -6399,31 +7920,24 @@ ${fence3}` }],
6399
7920
  }) => tracked("get-design-system-kit", { target: "shopify", designSystemId, projectId, context }, async () => {
6400
7921
  if (projectId) {
6401
7922
  const viewUrl2 = `${APP_BASE_URL}/projects/${projectId}`;
6402
- const result = await fetchProjectTokensCss(projectId, apiFetch, authContext);
6403
- 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) {
6404
7930
  return {
6405
- 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}.` }],
6406
7932
  structuredContent: { status: "error", name: `Project ${projectId.slice(0, 8)}`, viewUrl: viewUrl2, tokensCss: "" }
6407
7933
  };
6408
7934
  }
6409
- const componentKit2 = buildDsComponentCss(result.files);
6410
- const name = result.name ?? `Project ${projectId.slice(0, 8)}`;
6411
- const text2 = buildDsShopifyKit({
6412
- name,
6413
- tokensCss: result.tokensCss,
6414
- viewUrl: viewUrl2,
6415
- source: { kind: "project", projectId },
6416
- componentKit: componentKit2
6417
- });
6418
- return {
6419
- content: [{ type: "text", text: text2 }],
6420
- structuredContent: completedShopifyKitStructuredContent(
6421
- name,
6422
- viewUrl2,
6423
- result.tokensCss,
6424
- buildDsShopifyFiles({ tokensCss: result.tokensCss, componentKit: componentKit2 })
6425
- )
6426
- };
7935
+ return completedShopifyKitResult(
7936
+ result.name,
7937
+ viewUrl2,
7938
+ { kind: "project", projectId },
7939
+ result.primitives
7940
+ );
6427
7941
  }
6428
7942
  if (!designSystemId) {
6429
7943
  return {
@@ -6431,7 +7945,10 @@ ${fence3}` }],
6431
7945
  structuredContent: { status: "error", name: "Missing design system", tokensCss: "" }
6432
7946
  };
6433
7947
  }
6434
- const ds = await provider.getDesignSystemById(designSystemId, { includeComponents: false });
7948
+ const ds = await provider.getDesignSystemById(designSystemId, {
7949
+ includeComponents: false,
7950
+ includeShopifyPrimitives: true
7951
+ });
6435
7952
  if (!ds) {
6436
7953
  return {
6437
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.` }],
@@ -6439,30 +7956,20 @@ ${fence3}` }],
6439
7956
  };
6440
7957
  }
6441
7958
  const viewUrl = `${APP_BASE_URL}/design-systems/${designSystemId}`;
6442
- const { tokensCss, files } = await fetchCatalog(designSystemId);
6443
- 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}.`;
6444
7962
  return {
6445
- content: [{ type: "text", text: `Could not load tokens for "${designSystemId}". The full live showcase is at ${viewUrl}.` }],
7963
+ content: [{ type: "text", text }],
6446
7964
  structuredContent: { status: "error", name: ds.metadata.title, viewUrl, tokensCss: "" }
6447
7965
  };
6448
7966
  }
6449
- const componentKit = buildDsComponentCss(files);
6450
- const text = buildDsShopifyKit({
6451
- name: ds.metadata.title,
6452
- tokensCss,
7967
+ return completedShopifyKitResult(
7968
+ ds.metadata.title,
6453
7969
  viewUrl,
6454
- source: { kind: "catalog", designSystemId },
6455
- componentKit
6456
- });
6457
- return {
6458
- content: [{ type: "text", text }],
6459
- structuredContent: completedShopifyKitStructuredContent(
6460
- ds.metadata.title,
6461
- viewUrl,
6462
- tokensCss,
6463
- buildDsShopifyFiles({ tokensCss, componentKit })
6464
- )
6465
- };
7970
+ { kind: "catalog", designSystemId },
7971
+ primitives
7972
+ );
6466
7973
  });
6467
7974
  server.registerTool(
6468
7975
  "get-design-system-kit",
@@ -6494,22 +8001,22 @@ existing setup; otherwise use the default Base UI. Both flavors install to the
6494
8001
  same components/ui/ paths.
6495
8002
 
6496
8003
  ${BUILD_TARGET_RULE}`,
6497
- inputSchema: z5.object({
8004
+ inputSchema: z6.object({
6498
8005
  // Off-pilot callers must not see the target at all, not merely be refused it.
6499
- target: z5.enum(
8006
+ target: z6.enum(
6500
8007
  enableShopifyKit ? ["react", "html", "shopify"] : ["react", "html"]
6501
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.' : ""}`),
6502
- 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.`),
6503
- 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"'}.`),
6504
- 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."),
6505
- 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.'),
6506
8013
  context: CONTEXT_ARG
6507
8014
  }),
6508
8015
  outputSchema: buildKitOutputSchema(enableShopifyKit),
6509
8016
  annotations: READ_ONLY_TOOL_ANNOTATIONS
6510
8017
  // This tool returns build instructions, not a visual preview. Attaching
6511
- // the preview template makes ChatGPT render a nested showcase iframe;
6512
- // 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
6513
8020
  // though the kit succeeded. preview-design-system remains the dedicated
6514
8021
  // interactive preview path. [BD-240]
6515
8022
  },
@@ -6723,15 +8230,18 @@ _${installNote}_`,
6723
8230
 
6724
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\`.
6725
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
+
6726
8235
  How it works:
6727
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.
6728
8237
  2. Generation runs in the background and typically takes 1-3 minutes.
6729
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.
6730
8239
 
6731
8240
  Share the project URL with the user right away so they can watch the generation live.`,
6732
- inputSchema: z5.object({
6733
- 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'."),
6734
- 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).")
6735
8245
  }),
6736
8246
  annotations: {
6737
8247
  readOnlyHint: false,
@@ -6739,7 +8249,7 @@ Share the project URL with the user right away so they can watch the generation
6739
8249
  openWorldHint: false
6740
8250
  }
6741
8251
  },
6742
- 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 () => {
6743
8253
  const startUrl = `${APP_BASE_URL}/api/projects/anonymous`;
6744
8254
  let projectId;
6745
8255
  const requestHeaders = { "Content-Type": "application/json" };
@@ -6753,7 +8263,8 @@ Share the project URL with the user right away so they can watch the generation
6753
8263
  headers: requestHeaders,
6754
8264
  body: JSON.stringify({
6755
8265
  value: prompt,
6756
- ...baseDesignSystemId ? { designSystemId: baseDesignSystemId } : {}
8266
+ ...baseDesignSystemId ? { designSystemId: baseDesignSystemId } : {},
8267
+ ...brandCss ? { brandCss } : {}
6757
8268
  })
6758
8269
  });
6759
8270
  if (!startRes.ok) {
@@ -6774,10 +8285,10 @@ Share the project URL with the user right away so they can watch the generation
6774
8285
  "",
6775
8286
  `Project id: \`${projectId}\``,
6776
8287
  "",
6777
- ...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:`],
6778
8289
  projectUrl,
6779
8290
  "",
6780
- `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.`,
6781
8292
  inlineBuildMd(projectId)
6782
8293
  ];
6783
8294
  return { content: [{ type: "text", text: lines.join("\n") }] };
@@ -6819,10 +8330,17 @@ npx shadcn@latest add ${registryUrl}
6819
8330
  }
6820
8331
  } catch {
6821
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
+ ] : [];
6822
8339
  const lines = [
6823
8340
  `# Design system ready`,
6824
8341
  "",
6825
- ...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:`],
6826
8344
  projectUrl,
6827
8345
  "",
6828
8346
  `Project id: \`${projectId}\``,
@@ -6838,7 +8356,6 @@ npx shadcn@latest add ${registryUrl}
6838
8356
  "",
6839
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.`
6840
8358
  ];
6841
- const previewUrl = project.previewFragmentId ? `${APP_BASE_URL}/preview/${project.previewFragmentId}` : projectUrl;
6842
8359
  const shotUrl = project.previewFragmentId ? `${APP_BASE_URL}/api/og/preview?fragment=${project.previewFragmentId}` : void 0;
6843
8360
  return {
6844
8361
  content: [{ type: "text", text: lines.join("\n") }],
@@ -6876,8 +8393,8 @@ Check again in ~30 seconds. Watch it live on the project page: ${projectUrl}` }]
6876
8393
  const statusConfig = {
6877
8394
  title: "Get Design System Generation Status",
6878
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.`,
6879
- inputSchema: z5.object({
6880
- 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."),
6881
8398
  context: CONTEXT_ARG
6882
8399
  }),
6883
8400
  outputSchema: PREVIEW_OUTPUT_SCHEMA,
@@ -6900,8 +8417,8 @@ Check again in ~30 seconds. Watch it live on the project page: ${projectUrl}` }]
6900
8417
  {
6901
8418
  title: "Preview a Design System",
6902
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.`,
6903
- inputSchema: z5.object({
6904
- 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'."),
6905
8422
  context: CONTEXT_ARG
6906
8423
  }),
6907
8424
  outputSchema: PREVIEW_OUTPUT_SCHEMA,
@@ -6951,8 +8468,7 @@ Check again in ~30 seconds. Watch it live on the project page: ${projectUrl}` }]
6951
8468
  status: "completed",
6952
8469
  name: ds.metadata.title,
6953
8470
  viewUrl,
6954
- // Real screenshot of the showcase, for hosts that can't iframe it
6955
- // (claude.ai). The widget shows this as the inline image. [BD-136]
8471
+ // Real screenshot of the showcase for the self-contained widget.
6956
8472
  shotUrl: `${APP_BASE_URL}/api/og/preview?id=${encodeURIComponent(designSystemId)}`,
6957
8473
  tokensCss
6958
8474
  }
@@ -6967,55 +8483,26 @@ var init_design_system_tools = __esm({
6967
8483
  init_guardrails();
6968
8484
  init_html_kit();
6969
8485
  init_shopify_kit();
8486
+ init_shopify_primitives();
6970
8487
  init_preview_html();
6971
8488
  init_shared();
6972
- REGISTRY_ITEM_SCHEMA = z5.object({
6973
- cssVars: z5.object({
6974
- light: z5.record(z5.string(), z5.string()).optional(),
6975
- dark: z5.record(z5.string(), z5.string()).optional(),
6976
- 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()
6977
8494
  }).optional(),
6978
- 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()
6979
8496
  });
6980
8497
  SHADCN_INIT_IF_MISSING = "[ -f components.json ] || npx shadcn@latest init -d -y";
6981
8498
  SAFE_COMPONENT_NAME_RE = /^[a-z0-9][a-z0-9-]*$/i;
6982
8499
  CATALOG_KIT_COMPONENTS = ["button", "input", "card", "badge", "switch"];
6983
- DS_INDEX_SCHEMA = z5.object({
6984
- 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()
6985
8502
  });
6986
8503
  }
6987
8504
  });
6988
8505
 
6989
- // ../shared/src/registry-css.ts
6990
- function parseCssVars(css) {
6991
- const out = { theme: {}, light: {}, dark: {} };
6992
- const matchBlock = (selector) => {
6993
- const re = new RegExp(`${selector}\\s*\\{([^}]*)\\}`, "g");
6994
- const blocks = [];
6995
- let m;
6996
- while (m = re.exec(css)) blocks.push(m[1]);
6997
- return blocks.join("\n");
6998
- };
6999
- const extract = (body) => {
7000
- const result = {};
7001
- const clean = body.replace(/\/\*[\s\S]*?\*\//g, "");
7002
- const declRe = /--([a-zA-Z0-9_-]+)\s*:\s*([^;]+);/g;
7003
- let m;
7004
- while (m = declRe.exec(clean)) {
7005
- result[m[1]] = m[2].replace(/\s+/g, " ").trim();
7006
- }
7007
- return result;
7008
- };
7009
- out.light = extract(matchBlock(":root"));
7010
- out.dark = extract(matchBlock("\\.dark"));
7011
- return out;
7012
- }
7013
- var init_registry_css = __esm({
7014
- "../shared/src/registry-css.ts"() {
7015
- "use strict";
7016
- }
7017
- });
7018
-
7019
8506
  // ../shared/src/mcp/color-to-oklch.ts
7020
8507
  function clamp01(n) {
7021
8508
  return Math.min(1, Math.max(0, n));
@@ -7077,6 +8564,16 @@ function parseRgb(value) {
7077
8564
  a: parseAlpha(alpha)
7078
8565
  });
7079
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
+ }
7080
8577
  function hueToRgb(p, q, t) {
7081
8578
  let tt = t;
7082
8579
  if (tt < 0) tt += 1;
@@ -7163,6 +8660,7 @@ function toOklch(value) {
7163
8660
  if (v.startsWith("#")) rgba = parseHex(v);
7164
8661
  else if (/^rgba?\(/i.test(v)) rgba = parseRgb(v);
7165
8662
  else if (/^hsla?\(/i.test(v)) rgba = parseHsl(v);
8663
+ else if (v.includes(",")) rgba = parseBareTriplet(v);
7166
8664
  return rgba ? rgbaToOklch(rgba) : null;
7167
8665
  }
7168
8666
  var NAMED_HEX, PASSTHROUGH_KEYWORDS, NON_COLOR_KEYWORDS;
@@ -7361,7 +8859,7 @@ function normalizeMap(raw) {
7361
8859
  }
7362
8860
  return { tokens, kinds };
7363
8861
  }
7364
- function extractDesignSystem(css, name) {
8862
+ function collectCssVars(css) {
7365
8863
  const { light: rootLight, dark: rootDark } = parseCssVars(css);
7366
8864
  const themeVars = extractThemeBlock(css);
7367
8865
  const mergedLight = {};
@@ -7369,8 +8867,11 @@ function extractDesignSystem(css, name) {
7369
8867
  mergedLight[denamespace(k)] = v;
7370
8868
  }
7371
8869
  Object.assign(mergedLight, rootLight);
7372
- const light = normalizeMap(mergedLight);
7373
- 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);
7374
8875
  const hasLight = Object.keys(light.tokens).length > 0;
7375
8876
  const hasDark = Object.keys(dark.tokens).length > 0;
7376
8877
  const themeMode = hasLight && hasDark ? "dual" : hasDark ? "dark" : hasLight ? "light" : "none";
@@ -7435,6 +8936,8 @@ function formatExtraction(result) {
7435
8936
  };
7436
8937
  const meta = [
7437
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,
7438
8941
  `**Theme:** ${modeLabel[result.themeMode]}`,
7439
8942
  `**Tokens extracted:** ${result.tokenCount}`,
7440
8943
  `**Colors normalized to oklch.**`
@@ -7461,7 +8964,7 @@ function formatExtraction(result) {
7461
8964
  }
7462
8965
  sections.push(
7463
8966
  "## Token sheet (oklch-normalized, paste into globals.css)\n\n```css\n" + blocks.join("\n\n") + "\n```",
7464
- "## 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."
7465
8968
  );
7466
8969
  return sections.join("\n\n");
7467
8970
  }
@@ -7512,9 +9015,198 @@ var init_extract_design_system = __esm({
7512
9015
  }
7513
9016
  });
7514
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
+
7515
9194
  // ../shared/src/mcp/tools/extract-tools.ts
7516
- import { z as z6 } from "zod";
7517
- 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 }) {
7518
9210
  server.registerTool(
7519
9211
  "extract-design-system",
7520
9212
  {
@@ -7525,32 +9217,100 @@ Use this when the user says "match our existing style", "use our design system",
7525
9217
 
7526
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.
7527
9219
 
7528
- 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.`,
7529
- inputSchema: z6.object({
7530
- 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)."),
7531
- name: z6.string().trim().min(1).max(120).optional().describe("Optional label for the extracted system, e.g. the app or repo name."),
7532
- 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.")
7533
9240
  }),
7534
9241
  annotations: READ_ONLY_TOOL_ANNOTATIONS
7535
9242
  },
7536
- async ({ css, name, maxTokens }) => tracked("extract-design-system", { name: name ?? null }, async () => {
7537
- 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);
7538
9257
  let text = formatExtraction(result);
7539
9258
  if (maxTokens) text = truncateToTokens(text, maxTokens);
7540
9259
  return { content: [{ type: "text", text }] };
7541
9260
  })
7542
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
+ );
7543
9302
  }
7544
9303
  var init_extract_tools = __esm({
7545
9304
  "../shared/src/mcp/tools/extract-tools.ts"() {
7546
9305
  "use strict";
7547
9306
  init_shared();
7548
9307
  init_extract_design_system();
9308
+ init_brand_sources();
7549
9309
  }
7550
9310
  });
7551
9311
 
7552
9312
  // ../shared/src/mcp/tools/illustration-tools.ts
7553
- import { z as z7 } from "zod";
9313
+ import { z as z8 } from "zod";
7554
9314
  function fail(message) {
7555
9315
  return {
7556
9316
  content: [{ type: "text", text: message }],
@@ -7571,7 +9331,7 @@ function registerIllustrationTools({
7571
9331
  authContext,
7572
9332
  apiFetch = globalThis.fetch
7573
9333
  }) {
7574
- const authHeaders2 = () => {
9334
+ const authHeaders3 = () => {
7575
9335
  const headers = { "Content-Type": "application/json" };
7576
9336
  if (authContext) {
7577
9337
  const bearer2 = authContext.kind === "api-key" ? authContext.apiKey : authContext.accessToken;
@@ -7596,11 +9356,11 @@ How to call it:
7596
9356
  4. Stop when \`faults\` is empty.
7597
9357
 
7598
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.`,
7599
- inputSchema: z7.object({
7600
- svg: z7.string().min(1).describe("The complete svg element you drew."),
7601
- intent: z7.string().min(1).describe('What it is meant to be, in plain words. For example "an open inbox tray with letters".'),
7602
- animated: z7.boolean().optional().describe("Set when the drawing was meant to move, so the motion checks apply."),
7603
- 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.")
7604
9364
  }),
7605
9365
  outputSchema: REVIEW_OUTPUT_SCHEMA,
7606
9366
  annotations: READ_ONLY_TOOL_ANNOTATIONS
@@ -7617,7 +9377,7 @@ Markup carrying script, event handlers, remote references, or styles that reach
7617
9377
  try {
7618
9378
  const res = await apiFetch(`${APP_BASE_URL}/api/illustrations/review`, {
7619
9379
  method: "POST",
7620
- headers: authHeaders2(),
9380
+ headers: authHeaders3(),
7621
9381
  signal: AbortSignal.timeout(6e4),
7622
9382
  body: JSON.stringify({ svg, intent, animated: animated ?? false, reviewToken })
7623
9383
  });
@@ -7669,16 +9429,16 @@ var init_illustration_tools = __esm({
7669
9429
  init_shared();
7670
9430
  REVIEW_OUTPUT_SCHEMA = {
7671
9431
  /** Empty when nothing is visibly wrong. */
7672
- faults: z7.array(z7.string()),
9432
+ faults: z8.array(z8.string()),
7673
9433
  /** Group ids in paint order, the parts an animation can target. */
7674
- layers: z7.array(z7.string()),
9434
+ layers: z8.array(z8.string()),
7675
9435
  /** False when the markup was refused; faults says why. */
7676
- reviewed: z7.boolean(),
9436
+ reviewed: z8.boolean(),
7677
9437
  /** True when the drawing could not be rendered, so only structure was checked. */
7678
- unseen: z7.boolean(),
9438
+ unseen: z8.boolean(),
7679
9439
  /** Pass this back to review the same drawing again without spending another credit. */
7680
- reviewToken: z7.string(),
7681
- message: z7.string()
9440
+ reviewToken: z8.string(),
9441
+ message: z8.string()
7682
9442
  };
7683
9443
  }
7684
9444
  });
@@ -7715,15 +9475,13 @@ var init_preview_resource = __esm({
7715
9475
  prefersBorder: true,
7716
9476
  csp: {
7717
9477
  connectDomains: [APP_BASE_URL, ...WIDGET_RESOURCE_DOMAINS],
7718
- resourceDomains: [APP_BASE_URL, ...WIDGET_RESOURCE_DOMAINS],
7719
- frameDomains: WIDGET_FRAME_DOMAINS
9478
+ resourceDomains: [APP_BASE_URL, ...WIDGET_RESOURCE_DOMAINS]
7720
9479
  },
7721
9480
  domain: WIDGET_APP_DOMAIN
7722
9481
  },
7723
9482
  "openai/widgetCSP": {
7724
9483
  connect_domains: [APP_BASE_URL, ...WIDGET_RESOURCE_DOMAINS],
7725
9484
  resource_domains: [APP_BASE_URL, ...WIDGET_RESOURCE_DOMAINS],
7726
- frame_domains: WIDGET_FRAME_DOMAINS,
7727
9485
  redirect_domains: [APP_BASE_URL]
7728
9486
  }
7729
9487
  };
@@ -7731,9 +9489,9 @@ var init_preview_resource = __esm({
7731
9489
  });
7732
9490
 
7733
9491
  // ../shared/src/mcp/tools.ts
7734
- function registerAllTools(server, provider, onToolUsage, authContext, options = {}, apiFetch = globalThis.fetch) {
9492
+ function registerAllTools(server, provider, onToolUsage, authContext, options = {}, apiFetch = globalThis.fetch, userId) {
7735
9493
  const { track, tracked } = createTracking(server, onToolUsage);
7736
- const deps = { server, provider, track, tracked, authContext, apiFetch };
9494
+ const deps = { server, provider, track, tracked, authContext, apiFetch, userId };
7737
9495
  registerPrincipleTools(deps, options);
7738
9496
  registerComprehensionTools(deps);
7739
9497
  registerIconTools(deps);
@@ -7761,9 +9519,22 @@ var init_tools = __esm({
7761
9519
  var mcp_exports = {};
7762
9520
  __export(mcp_exports, {
7763
9521
  BUILD_TARGET_RULE: () => BUILD_TARGET_RULE,
9522
+ GUIDANCE_TOOLS: () => GUIDANCE_TOOLS,
7764
9523
  INSTRUCTIONS: () => INSTRUCTIONS,
9524
+ MCP_SESSION_WINDOW_MS: () => MCP_SESSION_WINDOW_MS,
9525
+ REVIEW_TOOLS: () => REVIEW_TOOLS,
7765
9526
  SEARCH_TOOLS: () => SEARCH_TOOLS,
7766
- 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
7767
9538
  });
7768
9539
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
7769
9540
  function createMcpServer(options) {
@@ -7776,7 +9547,8 @@ function createMcpServer(options) {
7776
9547
  enableReactNativeGuide = true,
7777
9548
  enableThreeJsGuide = true,
7778
9549
  enableShopifyKit = true,
7779
- apiFetch = globalThis.fetch
9550
+ apiFetch = globalThis.fetch,
9551
+ userId
7780
9552
  } = options;
7781
9553
  const server = new McpServer(
7782
9554
  { name, version },
@@ -7792,7 +9564,8 @@ function createMcpServer(options) {
7792
9564
  enableThreeJsGuide,
7793
9565
  enableShopifyKit
7794
9566
  },
7795
- apiFetch
9567
+ apiFetch,
9568
+ userId
7796
9569
  );
7797
9570
  return server;
7798
9571
  }
@@ -7803,7 +9576,9 @@ var init_mcp = __esm({
7803
9576
  init_tools();
7804
9577
  init_tools();
7805
9578
  init_instructions();
9579
+ init_session_guidance();
7806
9580
  init_shared();
9581
+ init_shopify_primitives();
7807
9582
  }
7808
9583
  });
7809
9584
 
@@ -7813,7 +9588,9 @@ __export(api_client_exports, {
7813
9588
  getDesignSystem: () => getDesignSystem,
7814
9589
  getDesignSystemComponents: () => getDesignSystemComponents,
7815
9590
  getPrinciple: () => getPrinciple,
9591
+ getProjectShopifyPrimitives: () => getProjectShopifyPrimitives,
7816
9592
  getReviewRules: () => getReviewRules,
9593
+ getSessionGuidance: () => getSessionGuidance,
7817
9594
  listPrinciples: () => listPrinciples,
7818
9595
  searchDesignSystems: () => searchDesignSystems,
7819
9596
  searchIconLibraries: () => searchIconLibraries,
@@ -7848,11 +9625,14 @@ async function searchDesignSystems(query, limit = 5) {
7848
9625
  `/v1/design-systems/search?q=${encodeURIComponent(query)}&limit=${limit}`
7849
9626
  );
7850
9627
  }
7851
- async function getDesignSystem(id, includeHtml = false) {
7852
- const params = includeHtml ? "?includeHtml=true" : "";
7853
- return request(
7854
- `/v1/design-systems/${encodeURIComponent(id)}${params}`
7855
- );
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}`);
7856
9636
  }
7857
9637
  async function getDesignSystemComponents(id, names, includeCode = true) {
7858
9638
  const params = new URLSearchParams();
@@ -7863,6 +9643,9 @@ async function getDesignSystemComponents(id, names, includeCode = true) {
7863
9643
  `/v1/design-systems/${encodeURIComponent(id)}/components${query}`
7864
9644
  );
7865
9645
  }
9646
+ async function getProjectShopifyPrimitives(projectId) {
9647
+ return request(`/projects/${encodeURIComponent(projectId)}/shopify/all.json`);
9648
+ }
7866
9649
  async function listPrinciples(area) {
7867
9650
  const params = area ? `?area=${area}` : "";
7868
9651
  return request(`/v1/principles${params}`);
@@ -7894,13 +9677,20 @@ async function getReviewRules(category = "all") {
7894
9677
  const params = category !== "all" ? `?category=${encodeURIComponent(category)}` : "";
7895
9678
  return request(`/v1/review-rules${params}`);
7896
9679
  }
9680
+ async function getSessionGuidance(windowMs) {
9681
+ const params = new URLSearchParams({ windowMs: String(Math.round(windowMs)) });
9682
+ return request(`/v1/session-guidance?${params}`);
9683
+ }
7897
9684
  function postAlert(body) {
7898
9685
  fetch(`${API_URL2}/api/internal/alert`, {
7899
9686
  method: "POST",
7900
9687
  headers: { "Content-Type": "application/json" },
7901
9688
  body: JSON.stringify(body),
7902
9689
  signal: AbortSignal.timeout(5e3)
7903
- }).catch(() => {
9690
+ }).then((res) => {
9691
+ if (!res.ok) console.warn(`postAlert failed: HTTP ${res.status}`);
9692
+ }).catch((err) => {
9693
+ console.warn("postAlert failed", err);
7904
9694
  });
7905
9695
  }
7906
9696
  function trackToolUsage(tool, params, context = {}) {
@@ -7957,17 +9747,36 @@ function createHttpDataProvider() {
7957
9747
  return results;
7958
9748
  },
7959
9749
  async getDesignSystemById(id, options = {}) {
7960
- const systemRes = await getDesignSystem(id, options.includeHtml);
9750
+ const systemRes = await getDesignSystem(id, {
9751
+ includeHtml: options.includeHtml,
9752
+ includeShopifyPrimitives: options.includeShopifyPrimitives
9753
+ });
7961
9754
  const componentsRes = options.includeComponents === false ? { components: [] } : await getDesignSystemComponents(
7962
9755
  id,
7963
9756
  options.componentNames,
7964
9757
  options.includeComponentCode
7965
9758
  );
7966
9759
  return {
7967
- 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
+ },
7968
9769
  components: componentsRes.components
7969
9770
  };
7970
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
+ },
7971
9780
  async listPrinciples(area) {
7972
9781
  const { principles } = await listPrinciples(area);
7973
9782
  return principles;
@@ -7975,7 +9784,8 @@ function createHttpDataProvider() {
7975
9784
  async getPrincipleByTopic(topic, area) {
7976
9785
  try {
7977
9786
  return await getPrinciple(topic, area);
7978
- } catch {
9787
+ } catch (err) {
9788
+ console.warn(`getPrincipleByTopic(${topic}) failed`, err);
7979
9789
  return null;
7980
9790
  }
7981
9791
  },
@@ -7987,6 +9797,23 @@ function createHttpDataProvider() {
7987
9797
  const { rules } = await getReviewRules(category);
7988
9798
  return rules;
7989
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
+ },
7990
9817
  async searchIconLibraries(query, limit = 5) {
7991
9818
  const { results } = await searchIconLibraries(query, limit);
7992
9819
  return results;
@@ -8013,6 +9840,7 @@ function createHttpDataProvider() {
8013
9840
  var init_http_data_provider = __esm({
8014
9841
  "src/lib/http-data-provider.ts"() {
8015
9842
  "use strict";
9843
+ init_mcp();
8016
9844
  init_api_client();
8017
9845
  }
8018
9846
  });
@@ -8060,11 +9888,7 @@ __export(mcp_debug_log_exports, {
8060
9888
  mcpDebugLogPath: () => mcpDebugLogPath
8061
9889
  });
8062
9890
  function mcpDebugLogPath() {
8063
- try {
8064
- return typeof process !== "undefined" ? process.env?.MCP_DEBUG_LOG : void 0;
8065
- } catch {
8066
- return void 0;
8067
- }
9891
+ return typeof process !== "undefined" ? process.env?.MCP_DEBUG_LOG : void 0;
8068
9892
  }
8069
9893
  function mcpDebugLog(entry) {
8070
9894
  const path5 = mcpDebugLogPath();