create-skybridge 0.0.0-dev.06d207f → 0.0.0-dev.06f809c

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.
Files changed (78) hide show
  1. package/dist/index.js +46 -24
  2. package/package.json +2 -2
  3. package/templates/blank/Dockerfile +2 -2
  4. package/templates/blank/node_modules/.bin/alpic +2 -2
  5. package/templates/blank/node_modules/.bin/tsx +2 -2
  6. package/templates/blank/node_modules/.bin/vite +2 -2
  7. package/templates/blank/package.json +6 -6
  8. package/templates/demo/Dockerfile +2 -2
  9. package/templates/demo/node_modules/.bin/alpic +2 -2
  10. package/templates/demo/node_modules/.bin/tsx +2 -2
  11. package/templates/demo/node_modules/.bin/vite +2 -2
  12. package/templates/demo/package.json +11 -11
  13. package/templates/ecom/.dockerignore +4 -0
  14. package/templates/ecom/.env.template +2 -0
  15. package/templates/ecom/.ladle/components.tsx +26 -0
  16. package/templates/ecom/.ladle/config.mjs +11 -0
  17. package/templates/ecom/.ladle/vite.config.ts +11 -0
  18. package/templates/ecom/AGENTS.md +2 -0
  19. package/templates/ecom/Dockerfile +53 -0
  20. package/templates/ecom/README.md +92 -0
  21. package/templates/ecom/_gitignore +9 -0
  22. package/templates/ecom/alpic.json +3 -0
  23. package/templates/ecom/node_modules/.bin/alpic +21 -0
  24. package/templates/ecom/node_modules/.bin/ladle +21 -0
  25. package/templates/ecom/node_modules/.bin/sb +21 -0
  26. package/templates/ecom/node_modules/.bin/skybridge +21 -0
  27. package/templates/ecom/node_modules/.bin/tsc +21 -0
  28. package/templates/ecom/node_modules/.bin/tsserver +21 -0
  29. package/templates/ecom/node_modules/.bin/tsx +21 -0
  30. package/templates/ecom/node_modules/.bin/vite +21 -0
  31. package/templates/ecom/package.json +41 -0
  32. package/templates/ecom/src/components/chip.css.ts +56 -0
  33. package/templates/ecom/src/components/chip.stories.tsx +26 -0
  34. package/templates/ecom/src/components/chip.tsx +41 -0
  35. package/templates/ecom/src/components/empty-state.stories.tsx +3 -0
  36. package/templates/ecom/src/components/empty-state.tsx +12 -0
  37. package/templates/ecom/src/components/expandable-text.css.ts +49 -0
  38. package/templates/ecom/src/components/expandable-text.stories.tsx +20 -0
  39. package/templates/ecom/src/components/expandable-text.tsx +53 -0
  40. package/templates/ecom/src/components/image-gallery.css.ts +172 -0
  41. package/templates/ecom/src/components/image-gallery.stories.tsx +30 -0
  42. package/templates/ecom/src/components/image-gallery.tsx +163 -0
  43. package/templates/ecom/src/components/product-card.css.ts +159 -0
  44. package/templates/ecom/src/components/product-card.stories.tsx +58 -0
  45. package/templates/ecom/src/components/product-card.tsx +102 -0
  46. package/templates/ecom/src/components/product-carousel.css.ts +134 -0
  47. package/templates/ecom/src/components/product-carousel.stories.tsx +64 -0
  48. package/templates/ecom/src/components/product-carousel.tsx +202 -0
  49. package/templates/ecom/src/components/variant-picker.css.ts +27 -0
  50. package/templates/ecom/src/components/variant-picker.stories.tsx +64 -0
  51. package/templates/ecom/src/components/variant-picker.tsx +82 -0
  52. package/templates/ecom/src/components/view-frame.css.ts +22 -0
  53. package/templates/ecom/src/components/view-frame.tsx +27 -0
  54. package/templates/ecom/src/config.ts +12 -0
  55. package/templates/ecom/src/design/contract.css.ts +39 -0
  56. package/templates/ecom/src/design/fonts.css +15 -0
  57. package/templates/ecom/src/design/primitives.css.ts +101 -0
  58. package/templates/ecom/src/design/recipes/typography.css.ts +75 -0
  59. package/templates/ecom/src/design/sprinkles.css.ts +128 -0
  60. package/templates/ecom/src/design/themes/dark.css.ts +36 -0
  61. package/templates/ecom/src/design/themes/light.css.ts +36 -0
  62. package/templates/ecom/src/design/tokens.ts +8 -0
  63. package/templates/ecom/src/helpers.ts +4 -0
  64. package/templates/ecom/src/i18n.ts +35 -0
  65. package/templates/ecom/src/index.css +9 -0
  66. package/templates/ecom/src/lib/cx.ts +9 -0
  67. package/templates/ecom/src/lib/format.ts +9 -0
  68. package/templates/ecom/src/lib/variants.ts +79 -0
  69. package/templates/ecom/src/server.ts +43 -0
  70. package/templates/ecom/src/tools/render-carousel.ts +241 -0
  71. package/templates/ecom/src/tools/search-products.ts +185 -0
  72. package/templates/ecom/src/types.ts +15 -0
  73. package/templates/ecom/src/views/carousel/detail/detail.css.ts +115 -0
  74. package/templates/ecom/src/views/carousel/detail/detail.stories.tsx +115 -0
  75. package/templates/ecom/src/views/carousel/detail/index.tsx +240 -0
  76. package/templates/ecom/src/views/carousel/index.tsx +234 -0
  77. package/templates/ecom/tsconfig.json +11 -0
  78. package/templates/ecom/vite.config.ts +8 -0
@@ -0,0 +1,128 @@
1
+ import { createSprinkles, defineProperties } from "@vanilla-extract/sprinkles";
2
+ import { colors } from "./contract.css";
3
+ import { primitives } from "./primitives.css";
4
+
5
+ /**
6
+ * Atomic style props built on the primitives + color contract. Use sprinkles
7
+ * for one-off layout/spacing/color on an element, e.g.
8
+ * sprinkles({ display: "flex", gap: "s", color: "intense" })
9
+ *
10
+ * Structural component styling belongs in a co-located `.css.ts` `style()`
11
+ * block; sprinkles is the thin glue layer on top.
12
+ */
13
+
14
+ const spaceProperties = defineProperties({
15
+ properties: {
16
+ padding: primitives.space,
17
+ paddingTop: primitives.space,
18
+ paddingRight: primitives.space,
19
+ paddingBottom: primitives.space,
20
+ paddingLeft: primitives.space,
21
+ margin: primitives.space,
22
+ marginTop: primitives.space,
23
+ marginRight: primitives.space,
24
+ marginBottom: primitives.space,
25
+ marginLeft: primitives.space,
26
+ gap: primitives.space,
27
+ rowGap: primitives.space,
28
+ columnGap: primitives.space,
29
+ },
30
+ shorthands: {
31
+ p: ["padding"],
32
+ pt: ["paddingTop"],
33
+ pr: ["paddingRight"],
34
+ pb: ["paddingBottom"],
35
+ pl: ["paddingLeft"],
36
+ px: ["paddingLeft", "paddingRight"],
37
+ py: ["paddingTop", "paddingBottom"],
38
+ m: ["margin"],
39
+ mt: ["marginTop"],
40
+ mr: ["marginRight"],
41
+ mb: ["marginBottom"],
42
+ ml: ["marginLeft"],
43
+ mx: ["marginLeft", "marginRight"],
44
+ my: ["marginTop", "marginBottom"],
45
+ },
46
+ });
47
+
48
+ const colorProperties = defineProperties({
49
+ properties: {
50
+ backgroundColor: {
51
+ ...colors.surface,
52
+ accent: colors.common.accent,
53
+ invertAccent: colors.common.invertAccent,
54
+ highlight: colors.common.highlight,
55
+ transparent: "transparent",
56
+ },
57
+ color: {
58
+ ...colors.content,
59
+ accent: colors.common.accent,
60
+ invertAccent: colors.common.invertAccent,
61
+ highlight: colors.common.highlight,
62
+ error: colors.common.error,
63
+ success: colors.common.success,
64
+ },
65
+ borderColor: {
66
+ ...colors.border,
67
+ accent: colors.common.accent,
68
+ invertAccent: colors.common.invertAccent,
69
+ highlight: colors.common.highlight,
70
+ transparent: "transparent",
71
+ },
72
+ },
73
+ });
74
+
75
+ const radiusProperties = defineProperties({
76
+ properties: {
77
+ borderRadius: primitives.radius,
78
+ borderTopLeftRadius: primitives.radius,
79
+ borderTopRightRadius: primitives.radius,
80
+ borderBottomLeftRadius: primitives.radius,
81
+ borderBottomRightRadius: primitives.radius,
82
+ },
83
+ });
84
+
85
+ const typographyProperties = defineProperties({
86
+ properties: {
87
+ fontFamily: primitives.font.family,
88
+ fontWeight: primitives.font.weight,
89
+ fontSize: primitives.font.size,
90
+ lineHeight: primitives.font.lineHeight,
91
+ letterSpacing: primitives.font.letterSpacing,
92
+ },
93
+ });
94
+
95
+ const strokeProperties = defineProperties({
96
+ properties: {
97
+ borderWidth: primitives.stroke,
98
+ },
99
+ });
100
+
101
+ const layoutProperties = defineProperties({
102
+ properties: {
103
+ display: ["none", "flex", "inline-flex", "block", "inline-block", "grid"],
104
+ flexDirection: ["row", "column", "row-reverse", "column-reverse"],
105
+ alignItems: ["flex-start", "center", "flex-end", "stretch", "baseline"],
106
+ justifyContent: [
107
+ "flex-start",
108
+ "center",
109
+ "flex-end",
110
+ "space-between",
111
+ "space-around",
112
+ "space-evenly",
113
+ ],
114
+ flexWrap: ["wrap", "nowrap", "wrap-reverse"],
115
+ textAlign: ["left", "center", "right"],
116
+ },
117
+ });
118
+
119
+ export const sprinkles = createSprinkles(
120
+ spaceProperties,
121
+ colorProperties,
122
+ radiusProperties,
123
+ typographyProperties,
124
+ strokeProperties,
125
+ layoutProperties,
126
+ );
127
+
128
+ export type Sprinkles = Parameters<typeof sprinkles>[0];
@@ -0,0 +1,36 @@
1
+ import { createTheme } from "@vanilla-extract/css";
2
+ import { colors } from "../contract.css";
3
+ import { primitives } from "../primitives.css";
4
+
5
+ /**
6
+ * Dark palette. Fills the same contract slots as light.css.ts (surface and
7
+ * content swap ends of the grey ramp).
8
+ *
9
+ * @todo: tune these mappings to your brand.
10
+ */
11
+ export const darkTheme = createTheme(colors, {
12
+ surface: {
13
+ extraLight: primitives.grey.black,
14
+ light: primitives.grey["900"],
15
+ subtle: primitives.grey["700"],
16
+ intense: primitives.grey["400"],
17
+ },
18
+ content: {
19
+ intense: primitives.grey["50"],
20
+ subtle: primitives.grey["300"],
21
+ invertIntense: primitives.grey["900"],
22
+ invertSubtle: primitives.grey["500"],
23
+ },
24
+ border: {
25
+ thin: primitives.grey["700"],
26
+ subtle: primitives.grey["600"],
27
+ intense: primitives.grey["400"],
28
+ },
29
+ common: {
30
+ accent: primitives.accent["400"],
31
+ invertAccent: primitives.grey.black,
32
+ highlight: primitives.accent["400"],
33
+ error: primitives.status.error,
34
+ success: primitives.status.success,
35
+ },
36
+ });
@@ -0,0 +1,36 @@
1
+ import { createTheme } from "@vanilla-extract/css";
2
+ import { colors } from "../contract.css";
3
+ import { primitives } from "../primitives.css";
4
+
5
+ /**
6
+ * Light palette: maps each semantic slot to a primitive.
7
+ *
8
+ * @todo: tune these mappings to your brand. dark.css.ts must fill the same
9
+ * slots (the contract enforces it).
10
+ */
11
+ export const lightTheme = createTheme(colors, {
12
+ surface: {
13
+ extraLight: primitives.grey.white,
14
+ light: primitives.grey["50"],
15
+ subtle: primitives.grey["200"],
16
+ intense: primitives.grey["400"],
17
+ },
18
+ content: {
19
+ intense: primitives.grey["900"],
20
+ subtle: primitives.grey["500"],
21
+ invertIntense: primitives.grey["50"],
22
+ invertSubtle: primitives.grey["300"],
23
+ },
24
+ border: {
25
+ thin: primitives.grey["200"],
26
+ subtle: primitives.grey["300"],
27
+ intense: primitives.grey["400"],
28
+ },
29
+ common: {
30
+ accent: primitives.accent["600"],
31
+ invertAccent: primitives.grey.white,
32
+ highlight: primitives.accent["600"],
33
+ error: primitives.status.error,
34
+ success: primitives.status.success,
35
+ },
36
+ });
@@ -0,0 +1,8 @@
1
+ // Barrel for the design system. Import tokens from here rather than reaching
2
+ // into individual files.
3
+ export { colors } from "./contract.css";
4
+ export { primitives } from "./primitives.css";
5
+ export { text } from "./recipes/typography.css";
6
+ export { type Sprinkles, sprinkles } from "./sprinkles.css";
7
+ export { darkTheme } from "./themes/dark.css";
8
+ export { lightTheme } from "./themes/light.css";
@@ -0,0 +1,4 @@
1
+ import { generateHelpers } from "skybridge/web";
2
+ import type { AppType } from "./server.js";
3
+
4
+ export const { useToolInfo, useCallTool } = generateHelpers<AppType>();
@@ -0,0 +1,35 @@
1
+ import { useUser } from "skybridge/web";
2
+
3
+ // Centralized UI labels. The active locale comes from the host via useUser();
4
+ // useLabels matches on the language subtag ("en-US" -> "en") and falls back to
5
+ // English for anything unlisted.
6
+ // @todo: adapt the English copy to your brand voice, and add a locale key (e.g.
7
+ // `fr`) with the same shape for each language you want to support.
8
+ const LABELS = {
9
+ en: {
10
+ outOfStock: "Out of stock",
11
+ noProducts: "No products to show.",
12
+ carousel: "carousel",
13
+ products: "Products",
14
+ previous: "Previous",
15
+ next: "Next",
16
+ // Detail view.
17
+ reference: "Ref.",
18
+ viewOnSite: "View on site",
19
+ selectOptions: "Select options",
20
+ priceOnRequest: "Price on request",
21
+ specifications: "Specifications",
22
+ readMore: "Read more",
23
+ readLess: "Read less",
24
+ },
25
+ } as const;
26
+
27
+ const DEFAULT_LOCALE = "en";
28
+
29
+ export type Labels = (typeof LABELS)[typeof DEFAULT_LOCALE];
30
+
31
+ export function useLabels(): Labels {
32
+ const { locale } = useUser();
33
+ const lang = locale.split("-")[0] ?? DEFAULT_LOCALE;
34
+ return lang in LABELS ? LABELS[lang as keyof typeof LABELS] : LABELS.en;
35
+ }
@@ -0,0 +1,9 @@
1
+ @import "./design/fonts.css";
2
+
3
+ /* The view mounts in a host iframe whose <body> keeps the user-agent default
4
+ margin (~8px). Zero it out so the surface paints edge to edge. */
5
+ html,
6
+ body {
7
+ margin: 0;
8
+ padding: 0;
9
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Zero-dep class-name joiner. vanilla-extract's `recipe`/`style` return plain
3
+ * strings, so merging a recipe class with an optional consumer `className`
4
+ * just needs string concatenation that tolerates `undefined`/`false`/`null`.
5
+ *
6
+ * className={cx(text({ style: "bodyM" }), sprinkles({ color: "accent" }))}
7
+ */
8
+ export const cx = (...classes: (string | false | null | undefined)[]) =>
9
+ classes.filter(Boolean).join(" ");
@@ -0,0 +1,9 @@
1
+ import type { Price } from "../types.js";
2
+
3
+ // Pass useUser().locale from the view; omit for the runtime default.
4
+ export function formatPrice(price: Price, locale?: string): string {
5
+ return new Intl.NumberFormat(locale, {
6
+ style: "currency",
7
+ currency: price.currency,
8
+ }).format(price.amount);
9
+ }
@@ -0,0 +1,79 @@
1
+ import type { Product, Variant } from "../tools/render-carousel.js";
2
+
3
+ // Pure helpers that turn the client's option choices into a concrete variant.
4
+ // The `variants` list is SPARSE: only combinations that exist are present, and
5
+ // that is the whole contingency model. These helpers never encode rules; they
6
+ // filter the list.
7
+
8
+ // A selection is one chosen value per axis, keyed by Option.id -> OptionValue.id.
9
+ export type Selection = Record<string, string>;
10
+
11
+ /**
12
+ * The variant matching a full selection (one value per axis), or undefined if
13
+ * the selection is partial or the combination does not exist. A product with no
14
+ * options resolves to its single variant on an empty selection.
15
+ */
16
+ export function resolveVariant(
17
+ product: Product,
18
+ selection: Selection,
19
+ ): Variant | undefined {
20
+ for (const option of product.options) {
21
+ if (selection[option.id] == null) {
22
+ return undefined; // partial selection: nothing resolved yet
23
+ }
24
+ }
25
+ return product.variants.find((variant) =>
26
+ product.options.every(
27
+ (option) => variant.selection[option.id] === selection[option.id],
28
+ ),
29
+ );
30
+ }
31
+
32
+ /**
33
+ * The values of `axisId` still reachable given the choices already made on the
34
+ * OTHER axes. A value is reachable if at least one variant carries it while
35
+ * matching every other current choice. Values not in this set have no surviving
36
+ * variant, so the picker disables them ("Red only in M").
37
+ */
38
+ export function selectableValues(
39
+ product: Product,
40
+ axisId: string,
41
+ selection: Selection,
42
+ ): Set<string> {
43
+ const reachable = new Set<string>();
44
+ for (const variant of product.variants) {
45
+ let matchesOthers = true;
46
+ for (const option of product.options) {
47
+ if (option.id === axisId) {
48
+ continue;
49
+ }
50
+ const chosen = selection[option.id];
51
+ if (chosen != null && variant.selection[option.id] !== chosen) {
52
+ matchesOthers = false;
53
+ break;
54
+ }
55
+ }
56
+ if (matchesOthers) {
57
+ reachable.add(variant.selection[axisId]);
58
+ }
59
+ }
60
+ return reachable;
61
+ }
62
+
63
+ /**
64
+ * The selection to preselect when a product opens: the variant the client
65
+ * tapped (its id equals the opened product id), else the first variant. A
66
+ * variant is always preselected when the product has any, so the buy CTA is
67
+ * live on open rather than starting disabled.
68
+ */
69
+ export function initialSelection(product: Product): Selection {
70
+ let base: Variant | undefined;
71
+ for (const variant of product.variants) {
72
+ if (variant.id === product.id) {
73
+ base = variant;
74
+ break;
75
+ }
76
+ }
77
+ base ??= product.variants[0];
78
+ return base ? { ...base.selection } : {};
79
+ }
@@ -0,0 +1,43 @@
1
+ import { existsSync } from "node:fs";
2
+ import { McpServer } from "skybridge/server";
3
+ import { CAROUSEL_RANGE, MIN_SEARCH_ITERATIONS } from "./config.js";
4
+ import {
5
+ renderCarouselDefinition,
6
+ renderCarouselHandler,
7
+ } from "./tools/render-carousel.js";
8
+ import {
9
+ searchProductsDefinition,
10
+ searchProductsHandler,
11
+ } from "./tools/search-products.js";
12
+
13
+ // Load .env into process.env when present (native to Node, no dependency).
14
+ if (existsSync(".env")) {
15
+ process.loadEnvFile();
16
+ }
17
+
18
+ const server = new McpServer(
19
+ {
20
+ // @todo: name and version your app.
21
+ name: "skybridge-ecom",
22
+ version: "0.0.1",
23
+ },
24
+ {
25
+ // @todo: adapt this server-wide prompt to your catalog.
26
+ instructions: `\
27
+ Two phases:
28
+
29
+ SEARCH: Call search-products ${MIN_SEARCH_ITERATIONS}+ times before presenting—never off one call. \
30
+ Vary the keyword, apply filters from a prior response, or page deeper. \
31
+ Stay silent while searching: emit NO text between calls. Speak only \
32
+ once the carousel renders. Never call a category unavailable before searching.
33
+
34
+ RENDER: After curating, call render-carousel with the chosen product IDs (aim for ${CAROUSEL_RANGE}). \
35
+ Speak only once it renders, then recommend in carousel order.`,
36
+ },
37
+ )
38
+ .registerTool(searchProductsDefinition, searchProductsHandler)
39
+ .registerTool(renderCarouselDefinition, renderCarouselHandler);
40
+
41
+ export default await server.run();
42
+
43
+ export type AppType = typeof server;
@@ -0,0 +1,241 @@
1
+ import { z } from "zod";
2
+ import { CAROUSEL_MAX_SIZE, CAROUSEL_RANGE } from "../config.js";
3
+ import { type Price, PriceSchema, type Spec, SpecSchema } from "../types.js";
4
+
5
+ // The `render-carousel` tool: takes the IDs the model curated and returns the
6
+ // matching products for the carousel view to render.
7
+ // Everything this tool needs lives in this file.
8
+
9
+ // ---------------------------------------------------------------------------
10
+ // Product model
11
+ // ---------------------------------------------------------------------------
12
+ // Model: variant-as-full-product. Each `Variant` is a complete, buyable product
13
+ // (its own title, price, media). A `Product` ties sibling variants together and
14
+ // declares the axes (`Option`s) they vary on. A product with no variations is
15
+ // just a product with a single variant and no options.
16
+
17
+ // One selectable value on an axis, e.g. the "Black" choice on the "Color" axis.
18
+ type OptionValue = {
19
+ id: string; // stable key referenced by Variant.selection, e.g. "black"
20
+ label: string; // shown to the user, e.g. "Black"
21
+ media?: string; // optional swatch / image representing this value
22
+ };
23
+
24
+ // A variation axis the variants differ on, e.g. Color or Size.
25
+ type Option = {
26
+ id: string; // stable key, used as a key in Variant.selection, e.g. "color"
27
+ label: string; // shown to the user, e.g. "Color"
28
+ values: OptionValue[]; // in display order
29
+ };
30
+
31
+ // Display fields shared by a Variant and by a product's `card`.
32
+ type Meta = {
33
+ title: string;
34
+ description?: string;
35
+ price?: Price;
36
+ media: string[]; // images for this item; media[0] is the primary/cover
37
+ url?: string; // link to this item's external product page
38
+ outOfStock?: boolean; // true = not purchasable
39
+ // Objective, product-specific facts (material, dimensions, capacity, care…),
40
+ // rendered as-is. Each fact's label is optional.
41
+ specs: Spec[];
42
+
43
+ // @todo: Add whatever custom fields the carousel should render as real types
44
+ // (e.g. `rating` → stars, `discountPct` → badge, `badges` → chips).
45
+ };
46
+
47
+ // One buyable product: full display Meta plus which value it takes on each axis.
48
+ export type Variant = Meta & {
49
+ id: string; // SKU / article number; unique within the catalog
50
+ // The chosen value per axis: keys are Option.id, values are OptionValue.id.
51
+ // e.g. { color: "black", size: "40" }
52
+ selection: Record<string, string>;
53
+ };
54
+
55
+ // A product: one carousel card backed by one or more variants and the axes they
56
+ // vary on (none for a single-variant product).
57
+ export type Product = {
58
+ id: string; // stable product key
59
+ options: Option[]; // the axes the variants vary on
60
+ // Only the variants that actually exist. A missing combination (e.g. no
61
+ // { color: "black", size: "40" }) is simply absent from this list — that is how
62
+ // contingent variations are expressed. Derive the selectable values for an axis
63
+ // by filtering this list on the choices already made.
64
+ variants: Variant[];
65
+ // The product's carousel card. Surfaced both in the carousel (the view
66
+ // renders it) and to the model (structuredContent is projected from it).
67
+ // How you build it depends on your mapping strategy: see getProducts.
68
+ card: Meta;
69
+ };
70
+
71
+ // ---------------------------------------------------------------------------
72
+ // Input
73
+ // ---------------------------------------------------------------------------
74
+
75
+ const inputSchema = {
76
+ ids: z
77
+ .array(z.string())
78
+ .min(1)
79
+ .max(CAROUSEL_MAX_SIZE)
80
+ .describe("Product IDs to present, in display order."),
81
+ };
82
+
83
+ type RenderInput = z.infer<z.ZodObject<typeof inputSchema>>;
84
+
85
+ // ---------------------------------------------------------------------------
86
+ // Output — model-facing grounding, for the LLM ONLY. The carousel view is NOT
87
+ // built from this; it renders from the full data in `_meta`. Keep it to what the
88
+ // model needs to reference and compare the displayed products afterward.
89
+ // ---------------------------------------------------------------------------
90
+
91
+ const outputSchema = {
92
+ products: z
93
+ .array(
94
+ z.object({
95
+ id: z.string().describe("Product SKU or reference."),
96
+ title: z.string(),
97
+ options: z
98
+ .array(z.object({ label: z.string(), values: z.array(z.string()) }))
99
+ .describe("Variations available (e.g. colors, sizes)."),
100
+ description: z.string().optional(),
101
+ price: PriceSchema.optional(),
102
+ outOfStock: z.boolean(),
103
+ specs: z
104
+ .array(SpecSchema)
105
+ .describe("Product-specific facts (material, dimensions, care…)."),
106
+ }),
107
+ )
108
+ .describe(
109
+ "The products shown in the carousel, in display order. For your reference only — to curate, compare, and answer follow-ups. Ground every claim in this data; never invent facts.",
110
+ ),
111
+ };
112
+
113
+ type RenderOutput = z.infer<z.ZodObject<typeof outputSchema>>;
114
+
115
+ // ---------------------------------------------------------------------------
116
+ // Data access
117
+ // ---------------------------------------------------------------------------
118
+
119
+ async function getProducts(_ids: string[]): Promise<Product[]> {
120
+ // @todo: fetch each id from your product API / DB and map the results into
121
+ // `Product`s (rename `_ids` -> `ids` once you use it).
122
+ //
123
+ // First decide your mapping strategy — it depends on your catalog:
124
+ // - no variants (simple products): one `Product`, a single variant, card = that variant, options: [],
125
+ // - grouped: one `Product` per product; `card` = union of its variants, one picture per requested variant
126
+ // - one card per requested variant: `card` = that variant
127
+ // Either way, set `variants` to ALL variants the source returns for the product;
128
+ // the detail view reads them so the client can switch variant.
129
+ //
130
+ // Returns [] for now, so the carousel is empty.
131
+ return [];
132
+ }
133
+
134
+ // ---------------------------------------------------------------------------
135
+ // Mapping: trim each product's `card` and `options` into the model-facing
136
+ // grounding (outputSchema), dropping presentational fields (media, url). The
137
+ // full data stays in `_meta` for the view.
138
+ // @todo: choose what the model sees per product. Grounding only: no
139
+ // presentational data (media, styling); that rides in `_meta` for the view.
140
+ // ---------------------------------------------------------------------------
141
+
142
+ function toStructuredContent(products: Product[]): RenderOutput {
143
+ const groundingProducts: RenderOutput["products"] = [];
144
+
145
+ for (const product of products) {
146
+ const { card } = product;
147
+
148
+ const options: { label: string; values: string[] }[] = [];
149
+ for (const option of product.options) {
150
+ const values: string[] = [];
151
+ for (const value of option.values) {
152
+ values.push(value.label);
153
+ }
154
+ options.push({ label: option.label, values });
155
+ }
156
+
157
+ groundingProducts.push({
158
+ id: product.id,
159
+ title: card.title,
160
+ description: card.description,
161
+ price: card.price,
162
+ outOfStock: card.outOfStock ?? false,
163
+ options,
164
+ specs: card.specs,
165
+ });
166
+ }
167
+
168
+ return { products: groundingProducts };
169
+ }
170
+
171
+ // ---------------------------------------------------------------------------
172
+ // Tool (registered from server.ts to keep the typed tool chain intact)
173
+ // ---------------------------------------------------------------------------
174
+
175
+ export const renderCarouselDefinition = {
176
+ name: "render-carousel" as const,
177
+
178
+ // @todo: adapt the wording to your catalog and brand voice (tone, vocabulary,
179
+ // how to present products). The behavioral rules below apply to any catalog.
180
+ description: `\
181
+ Display the products you curated as an inline carousel for the client.
182
+
183
+ ## When to call
184
+ Call this AFTER searching and curating, and BEFORE writing your recommendation. Do not describe the products in text first; the carousel shows them. Stay silent until it has rendered, then speak.
185
+
186
+ ## What to pass
187
+ Pass the IDs of the ${CAROUSEL_RANGE} products you chose, in display order (most relevant first). Order is significant: the carousel shows them in this exact order and your recommendation must follow the same sequence. Pass distinct products, not several variants of the same one; the detail view lets the client explore a product's variants (colors, sizes, and so on).
188
+
189
+ ## After the carousel
190
+ Recommend in carousel order so the client can follow along. The cards already show image, title, price, and key facts, so do not repeat them: add useful analysis tied to the client's need. Suggest a refinement the client has not addressed yet (from the available filters), never one they already used.
191
+
192
+ ## Accuracy
193
+ Use only the data returned for each product. Never invent facts, materials, or availability. If the client asks about something not present, open that product's detail or search again before answering.`,
194
+ annotations: {
195
+ readOnlyHint: true,
196
+ openWorldHint: false,
197
+ destructiveHint: false,
198
+ },
199
+
200
+ // @todo: customize the status messages shown in ChatGPT while the tool runs.
201
+ _meta: {
202
+ "openai/toolInvocation/invoking": "Loading product carousel",
203
+ "openai/toolInvocation/invoked": "Loaded product carousel",
204
+ },
205
+
206
+ // The carousel and product details UI rendered inline in the conversation.
207
+ view: {
208
+ // `as const` keeps this a literal (like `name` above) so it matches the
209
+ // generated ViewNameRegistry; a bare string widens and fails the build.
210
+ component: "carousel" as const,
211
+ description: "Browse the curated products.",
212
+ // @todo: declare the CSP domains this view needs. Add your image origins to
213
+ // `resourceDomains` so product images load, and the product site to
214
+ // `redirectDomains` so the detail view's "View on site" link and the host's
215
+ // "Open in app" URL (useOpenExternal / setOpenInAppUrl) are allowed.
216
+ // csp: {
217
+ // resourceDomains: ["https://images.example.com"],
218
+ // redirectDomains: ["https://www.example.com"],
219
+ // },
220
+ },
221
+
222
+ inputSchema,
223
+ outputSchema,
224
+ };
225
+
226
+ export async function renderCarouselHandler({ ids }: RenderInput) {
227
+ const products = await getProducts(ids);
228
+
229
+ return {
230
+ // Full products (incl. variants) for the view; not in model context.
231
+ _meta: { products },
232
+ structuredContent: toStructuredContent(products),
233
+ content: [
234
+ {
235
+ type: "text" as const,
236
+ text: `Rendered ${products.length} product(s) in the carousel.`,
237
+ },
238
+ ],
239
+ isError: false,
240
+ };
241
+ }