create-skybridge 0.0.0-dev.1b2b016 → 0.0.0-dev.1b3b81f

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 (163) hide show
  1. package/LICENSE +21 -0
  2. package/dist/index.d.ts +1 -1
  3. package/dist/index.js +373 -122
  4. package/index.js +6 -1
  5. package/package.json +15 -12
  6. package/templates/blank/.dockerignore +4 -0
  7. package/templates/blank/AGENTS.md +1 -0
  8. package/templates/blank/Dockerfile +53 -0
  9. package/templates/blank/README.md +92 -0
  10. package/templates/blank/_gitignore +7 -0
  11. package/templates/blank/alpic.json +3 -0
  12. package/templates/blank/node_modules/.bin/alpic +21 -0
  13. package/templates/blank/node_modules/.bin/sb +21 -0
  14. package/templates/blank/node_modules/.bin/skybridge +21 -0
  15. package/templates/blank/node_modules/.bin/tsc +21 -0
  16. package/templates/blank/node_modules/.bin/tsserver +21 -0
  17. package/templates/blank/node_modules/.bin/tsx +21 -0
  18. package/templates/blank/node_modules/.bin/vite +21 -0
  19. package/templates/blank/package.json +29 -0
  20. package/templates/blank/src/helpers.ts +4 -0
  21. package/templates/blank/src/server.ts +16 -0
  22. package/templates/blank/tsconfig.json +5 -0
  23. package/templates/blank/vite.config.ts +12 -0
  24. package/templates/demo/.dockerignore +4 -0
  25. package/templates/demo/AGENTS.md +1 -0
  26. package/templates/demo/Dockerfile +53 -0
  27. package/templates/demo/README.md +95 -0
  28. package/templates/demo/_gitignore +7 -0
  29. package/templates/demo/alpic.json +3 -0
  30. package/templates/demo/node_modules/.bin/alpic +21 -0
  31. package/templates/demo/node_modules/.bin/sb +21 -0
  32. package/templates/demo/node_modules/.bin/skybridge +21 -0
  33. package/templates/demo/node_modules/.bin/tsc +21 -0
  34. package/templates/demo/node_modules/.bin/tsserver +21 -0
  35. package/templates/demo/node_modules/.bin/tsx +21 -0
  36. package/templates/demo/node_modules/.bin/vite +21 -0
  37. package/templates/demo/package.json +41 -0
  38. package/templates/demo/src/helpers.ts +4 -0
  39. package/templates/demo/src/index.css +59 -0
  40. package/templates/demo/src/server.ts +94 -0
  41. package/templates/demo/src/views/components/doc-link.tsx +22 -0
  42. package/templates/demo/src/views/components/doc.tsx +21 -0
  43. package/templates/demo/src/views/components/nav.tsx +31 -0
  44. package/templates/demo/src/views/components/progress.tsx +35 -0
  45. package/templates/demo/src/views/components/steps/outro.tsx +68 -0
  46. package/templates/demo/src/views/components/steps/state.tsx +47 -0
  47. package/templates/demo/src/views/components/steps/tool-call.tsx +53 -0
  48. package/templates/demo/src/views/components/steps/tool-output.tsx +40 -0
  49. package/templates/demo/src/views/images/mascot/beret.png +0 -0
  50. package/templates/demo/src/views/images/mascot/chapka.png +0 -0
  51. package/templates/demo/src/views/images/mascot/cowboy-hat.png +0 -0
  52. package/templates/demo/src/views/images/mascot/fez.png +0 -0
  53. package/templates/demo/src/views/images/mascot/jester-hat.png +0 -0
  54. package/templates/demo/src/views/images/mascot/mitre.png +0 -0
  55. package/templates/demo/src/views/images/mascot/non-la.png +0 -0
  56. package/templates/demo/src/views/images/mascot/original.png +0 -0
  57. package/templates/demo/src/views/images/mascot/propeller-beanie.png +0 -0
  58. package/templates/demo/src/views/images/mascot/ski-mask.png +0 -0
  59. package/templates/demo/src/views/images/mascot/sombrero.png +0 -0
  60. package/templates/demo/src/views/images/mascot/top-hat.png +0 -0
  61. package/templates/demo/src/views/images/mascot/viking-helmet.png +0 -0
  62. package/templates/demo/src/views/onboarding.tsx +63 -0
  63. package/templates/demo/src/views/use-mascot.ts +60 -0
  64. package/templates/demo/tsconfig.json +11 -0
  65. package/{template/web → templates/demo}/vite.config.ts +7 -3
  66. package/templates/ecom/.dockerignore +4 -0
  67. package/templates/ecom/.env.template +2 -0
  68. package/templates/ecom/.ladle/components.tsx +26 -0
  69. package/templates/ecom/.ladle/config.mjs +11 -0
  70. package/templates/ecom/.ladle/vite.config.ts +11 -0
  71. package/templates/ecom/AGENTS.md +2 -0
  72. package/templates/ecom/Dockerfile +53 -0
  73. package/templates/ecom/README.md +92 -0
  74. package/templates/ecom/_gitignore +9 -0
  75. package/templates/ecom/alpic.json +3 -0
  76. package/templates/ecom/node_modules/.bin/alpic +21 -0
  77. package/templates/ecom/node_modules/.bin/ladle +21 -0
  78. package/templates/ecom/node_modules/.bin/sb +21 -0
  79. package/templates/ecom/node_modules/.bin/skybridge +21 -0
  80. package/templates/ecom/node_modules/.bin/tsc +21 -0
  81. package/templates/ecom/node_modules/.bin/tsserver +21 -0
  82. package/templates/ecom/node_modules/.bin/tsx +21 -0
  83. package/templates/ecom/node_modules/.bin/vite +21 -0
  84. package/templates/ecom/package.json +41 -0
  85. package/templates/ecom/src/components/chip.css.ts +63 -0
  86. package/templates/ecom/src/components/chip.stories.tsx +27 -0
  87. package/templates/ecom/src/components/chip.tsx +49 -0
  88. package/templates/ecom/src/components/empty-state.stories.tsx +3 -0
  89. package/templates/ecom/src/components/empty-state.tsx +12 -0
  90. package/templates/ecom/src/components/expandable-text.css.ts +49 -0
  91. package/templates/ecom/src/components/expandable-text.stories.tsx +20 -0
  92. package/templates/ecom/src/components/expandable-text.tsx +53 -0
  93. package/templates/ecom/src/components/image-gallery.css.ts +172 -0
  94. package/templates/ecom/src/components/image-gallery.stories.tsx +30 -0
  95. package/templates/ecom/src/components/image-gallery.tsx +163 -0
  96. package/templates/ecom/src/components/product-card.css.ts +159 -0
  97. package/templates/ecom/src/components/product-card.stories.tsx +58 -0
  98. package/templates/ecom/src/components/product-card.tsx +102 -0
  99. package/templates/ecom/src/components/product-carousel.css.ts +134 -0
  100. package/templates/ecom/src/components/product-carousel.stories.tsx +64 -0
  101. package/templates/ecom/src/components/product-carousel.tsx +202 -0
  102. package/templates/ecom/src/components/variant-picker.css.ts +27 -0
  103. package/templates/ecom/src/components/variant-picker.stories.tsx +67 -0
  104. package/templates/ecom/src/components/variant-picker.tsx +70 -0
  105. package/templates/ecom/src/components/view-frame.css.ts +22 -0
  106. package/templates/ecom/src/components/view-frame.tsx +27 -0
  107. package/templates/ecom/src/config.ts +12 -0
  108. package/templates/ecom/src/design/contract.css.ts +39 -0
  109. package/templates/ecom/src/design/fonts.css +15 -0
  110. package/templates/ecom/src/design/primitives.css.ts +101 -0
  111. package/templates/ecom/src/design/recipes/typography.css.ts +75 -0
  112. package/templates/ecom/src/design/sprinkles.css.ts +128 -0
  113. package/templates/ecom/src/design/themes/dark.css.ts +36 -0
  114. package/templates/ecom/src/design/themes/light.css.ts +36 -0
  115. package/templates/ecom/src/design/tokens.ts +8 -0
  116. package/templates/ecom/src/helpers.ts +4 -0
  117. package/templates/ecom/src/i18n.ts +35 -0
  118. package/templates/ecom/src/index.css +9 -0
  119. package/templates/ecom/src/lib/cx.ts +9 -0
  120. package/templates/ecom/src/lib/format.ts +9 -0
  121. package/templates/ecom/src/lib/variants.ts +150 -0
  122. package/templates/ecom/src/server.ts +43 -0
  123. package/templates/ecom/src/tools/render-carousel.ts +245 -0
  124. package/templates/ecom/src/tools/search-products.ts +185 -0
  125. package/templates/ecom/src/types.ts +15 -0
  126. package/templates/ecom/src/views/carousel/detail/detail.css.ts +113 -0
  127. package/templates/ecom/src/views/carousel/detail/detail.stories.tsx +126 -0
  128. package/templates/ecom/src/views/carousel/detail/index.tsx +254 -0
  129. package/templates/ecom/src/views/carousel/index.tsx +234 -0
  130. package/templates/ecom/tsconfig.json +11 -0
  131. package/templates/ecom/vite.config.ts +8 -0
  132. package/template/.cursor/mcp.json +0 -7
  133. package/template/.nvmrc +0 -1
  134. package/template/.vscode/launch.json +0 -16
  135. package/template/.vscode/settings.json +0 -3
  136. package/template/.vscode/tasks.json +0 -14
  137. package/template/README.md +0 -116
  138. package/template/_gitignore +0 -194
  139. package/template/alpic.json +0 -4
  140. package/template/docs/demo.gif +0 -0
  141. package/template/package.json +0 -22
  142. package/template/pnpm-lock.yaml +0 -408
  143. package/template/pnpm-workspace.yaml +0 -7
  144. package/template/server/nodemon.json +0 -5
  145. package/template/server/package.json +0 -37
  146. package/template/server/pnpm-lock.yaml +0 -3796
  147. package/template/server/src/env.ts +0 -12
  148. package/template/server/src/index.ts +0 -34
  149. package/template/server/src/middleware.ts +0 -54
  150. package/template/server/src/pokedex.ts +0 -148
  151. package/template/server/src/server.ts +0 -76
  152. package/template/server/tsconfig.json +0 -17
  153. package/template/web/components.json +0 -22
  154. package/template/web/package.json +0 -33
  155. package/template/web/pnpm-lock.yaml +0 -2629
  156. package/template/web/src/components/ui/shadcn-io/spinner/index.tsx +0 -272
  157. package/template/web/src/helpers.ts +0 -4
  158. package/template/web/src/index.css +0 -120
  159. package/template/web/src/utils.ts +0 -6
  160. package/template/web/src/widgets/pokemon.tsx +0 -203
  161. package/template/web/tsconfig.app.json +0 -34
  162. package/template/web/tsconfig.json +0 -13
  163. package/template/web/tsconfig.node.json +0 -26
@@ -0,0 +1,254 @@
1
+ import { useEffect, useState } from "react";
2
+ import { useOpenExternal, useSetOpenInAppUrl, useUser } from "skybridge/web";
3
+ import { ExpandableText } from "../../../components/expandable-text";
4
+ import { ImageGallery } from "../../../components/image-gallery";
5
+ import { VariantPicker } from "../../../components/variant-picker";
6
+ import { sprinkles, text } from "../../../design/tokens";
7
+ import { type Labels, useLabels } from "../../../i18n";
8
+ import { cx } from "../../../lib/cx";
9
+ import { formatPrice } from "../../../lib/format";
10
+ import {
11
+ initialSelection,
12
+ resolveVariant,
13
+ type Selection,
14
+ } from "../../../lib/variants.js";
15
+ import type { Product, Variant } from "../../../tools/render-carousel.js";
16
+ import * as styles from "./detail.css";
17
+
18
+ // Price to show: the resolved variant's price, else the range across variants
19
+ // (or a single price when they agree), else the card price, else a fallback.
20
+ function priceText(
21
+ product: Product,
22
+ variantPrice: Product["card"]["price"],
23
+ locale: string,
24
+ labels: Labels,
25
+ ): string {
26
+ if (variantPrice) {
27
+ return formatPrice(variantPrice, locale);
28
+ }
29
+ const amounts: number[] = [];
30
+ let currency = "";
31
+ for (const variant of product.variants) {
32
+ if (variant.price) {
33
+ amounts.push(variant.price.amount);
34
+ currency = variant.price.currency;
35
+ }
36
+ }
37
+ if (amounts.length > 0) {
38
+ const min = Math.min(...amounts);
39
+ const max = Math.max(...amounts);
40
+ if (min === max) {
41
+ return formatPrice({ amount: min, currency }, locale);
42
+ }
43
+ return `${formatPrice({ amount: min, currency }, locale)} – ${formatPrice({ amount: max, currency }, locale)}`;
44
+ }
45
+ if (product.card.price) {
46
+ return formatPrice(product.card.price, locale);
47
+ }
48
+ return labels.priceOnRequest;
49
+ }
50
+
51
+ // data-llm narrates the variant the user is currently looking at. The full
52
+ // product spec (every variant) is pushed to view state by the carousel
53
+ // orchestrator, so the model can answer beyond what is on screen; this stays
54
+ // scoped to the visible selection.
55
+ function grounding(
56
+ product: Product,
57
+ variant: Variant | undefined,
58
+ title: string,
59
+ price: string,
60
+ selection: Selection,
61
+ unpurchasable: boolean,
62
+ labels: Labels,
63
+ ): string {
64
+ const parts = [`The user is viewing "${title}" (product id: ${product.id}).`];
65
+ const chosen: string[] = [];
66
+ for (const option of product.options) {
67
+ const valueId = selection[option.id];
68
+ let label: string | undefined;
69
+ for (const value of option.values) {
70
+ if (value.id === valueId) {
71
+ label = value.label;
72
+ break;
73
+ }
74
+ }
75
+ if (label === undefined) {
76
+ // Axis skipped by the shown variant vs simply not picked yet.
77
+ label =
78
+ variant != null && variant.selection[option.id] == null
79
+ ? "not applicable"
80
+ : "not selected";
81
+ }
82
+ chosen.push(`${option.label}: ${label}`);
83
+ }
84
+ if (chosen.length > 0) {
85
+ parts.push(`Selected — ${chosen.join(", ")}.`);
86
+ }
87
+ parts.push(`Price: ${price}.`);
88
+ if (unpurchasable) {
89
+ // Sold-out real variant vs a combination that does not exist.
90
+ parts.push(
91
+ variant != null ? labels.outOfStock : labels.combinationUnavailable,
92
+ );
93
+ }
94
+ return parts.join(" ");
95
+ }
96
+
97
+ /**
98
+ * Product detail view, rendered fullscreen over the carousel (see the carousel
99
+ * orchestrator). Reads a single product from the payload already in `_meta`;
100
+ * does no fetch. Option choices resolve against the product's sparse variant
101
+ * list in-place (no remount).
102
+ */
103
+ export function DetailView({ product }: { product: Product }) {
104
+ const { locale } = useUser();
105
+ const labels = useLabels();
106
+ const openExternal = useOpenExternal();
107
+ const setOpenInAppUrl = useSetOpenInAppUrl();
108
+ const [selection, setSelection] = useState<Selection>(() =>
109
+ initialSelection(product),
110
+ );
111
+
112
+ // The exact variant for the selection; card fields fill in when none
113
+ // resolves (the applyChoice edge case, or a product with no variants).
114
+ const shown = resolveVariant(product, selection);
115
+ const displayTitle = shown?.title ?? product.card.title;
116
+ const description = shown?.description ?? product.card.description;
117
+ const media = shown?.media.length ? shown.media : product.card.media;
118
+ const specs = shown?.specs ?? product.card.specs;
119
+ // Sold out, or no variant resolved.
120
+ const unpurchasable = shown ? (shown.outOfStock ?? false) : true;
121
+ const url = shown?.url ?? product.card.url;
122
+ // The shown item's id: the resolved variant's SKU, else the product id.
123
+ const reference = shown?.id ?? product.id;
124
+ // @todo: read any custom Meta fields you added the same way (shown first,
125
+ // then card), e.g. `const rating = shown?.rating ?? product.card.rating;`,
126
+ // then render them in the agreed spot (rating by the title, discount by the
127
+ // price, badges as chips…).
128
+
129
+ const price = priceText(product, shown?.price, locale, labels);
130
+ // Buy CTA is enabled only for an exact, in-stock variant with a real link
131
+ // (an empty url would leave the button enabled but dead).
132
+ const canBuy = shown != null && Boolean(url) && !unpurchasable;
133
+
134
+ // Point the host's fullscreen "Open in app" affordance at the selected
135
+ // variant's page. Apps-SDK only; MCP Apps hosts reject, so ignore that.
136
+ useEffect(() => {
137
+ if (url) {
138
+ setOpenInAppUrl(url).catch(() => {});
139
+ }
140
+ }, [url, setOpenInAppUrl]);
141
+
142
+ return (
143
+ <div
144
+ className={styles.detail}
145
+ data-llm={grounding(
146
+ product,
147
+ shown,
148
+ displayTitle,
149
+ price,
150
+ selection,
151
+ unpurchasable,
152
+ labels,
153
+ )}
154
+ >
155
+ {/* Product / variant reference, idiomatically top-right. @todo: move it
156
+ (e.g. into the info column under the title) or drop it; restyle in
157
+ detail.css.ts (`reference`). */}
158
+ <p className={cx(text({ style: "bodyS" }), styles.reference)}>
159
+ {labels.reference} {reference}
160
+ </p>
161
+
162
+ <div className={styles.grid}>
163
+ <div className={styles.galleryCell}>
164
+ {media.length > 0 ? (
165
+ // Key on the media set so switching to a variant with different
166
+ // images remounts the gallery fresh (index reset to the first image).
167
+ <ImageGallery
168
+ key={media.join("|")}
169
+ media={media}
170
+ alt={displayTitle}
171
+ />
172
+ ) : null}
173
+ </div>
174
+
175
+ <div className={styles.info}>
176
+ <h1
177
+ className={cx(
178
+ text({ style: "headingS", weight: "medium" }),
179
+ styles.title,
180
+ )}
181
+ >
182
+ {displayTitle}
183
+ </h1>
184
+
185
+ <p className={cx(text({ style: "headingS" }), styles.price)}>
186
+ {price}
187
+ </p>
188
+
189
+ <VariantPicker
190
+ product={product}
191
+ selection={selection}
192
+ onChange={setSelection}
193
+ />
194
+
195
+ {description ? <ExpandableText>{description}</ExpandableText> : null}
196
+
197
+ {/* Sold-out and unresolved selections stay composable; the CTA locks
198
+ and its label names the cause. */}
199
+ <button
200
+ type="button"
201
+ className={cx(styles.cta, sprinkles({ mt: "3xs" }))}
202
+ disabled={!canBuy}
203
+ onClick={canBuy && url ? () => openExternal(url) : undefined}
204
+ >
205
+ {shown
206
+ ? unpurchasable
207
+ ? labels.outOfStock
208
+ : labels.viewOnSite
209
+ : labels.combinationUnavailable}
210
+ </button>
211
+
212
+ {/* Product facts as a simple list, after the CTA: one "label: value"
213
+ line per fact (label-less facts show the value alone). This is the
214
+ visual treatment only; the full spec is already in view state for
215
+ the model (see the carousel orchestrator), so restyling or dropping
216
+ it never hides facts from the assistant. @todo: pick the shape that
217
+ best presents your specs: this list (default), a two-column table,
218
+ grouped sections, inline chips, etc. */}
219
+ {specs.length > 0 ? (
220
+ <section>
221
+ <h2 className={text({ style: "labelM", weight: "medium" })}>
222
+ {labels.specifications}
223
+ </h2>
224
+ <dl className={styles.specList}>
225
+ {specs.map((spec) => (
226
+ <div
227
+ key={`${spec.label ?? ""}:${spec.value}`}
228
+ className={styles.specRow}
229
+ >
230
+ {spec.label ? (
231
+ <dt
232
+ className={cx(
233
+ text({ style: "bodyS" }),
234
+ styles.specLabel,
235
+ )}
236
+ >
237
+ {spec.label}:
238
+ </dt>
239
+ ) : null}
240
+ <dd
241
+ className={cx(text({ style: "bodyS" }), styles.specValue)}
242
+ >
243
+ {spec.value}
244
+ </dd>
245
+ </div>
246
+ ))}
247
+ </dl>
248
+ </section>
249
+ ) : null}
250
+ </div>
251
+ </div>
252
+ </div>
253
+ );
254
+ }
@@ -0,0 +1,234 @@
1
+ import "../../index.css";
2
+
3
+ import {
4
+ type ReactNode,
5
+ useEffect,
6
+ useLayoutEffect,
7
+ useRef,
8
+ useState,
9
+ } from "react";
10
+ import { useDisplayMode, useViewState } from "skybridge/web";
11
+ import { EmptyState } from "../../components/empty-state";
12
+ import {
13
+ ProductCard,
14
+ ProductCardSkeleton,
15
+ } from "../../components/product-card";
16
+ import * as cardStyles from "../../components/product-card.css";
17
+ import { ProductCarousel } from "../../components/product-carousel";
18
+ import { ViewFrame } from "../../components/view-frame";
19
+ import { sprinkles } from "../../design/tokens";
20
+ import { useToolInfo } from "../../helpers.js";
21
+ import { useLabels } from "../../i18n";
22
+ import { formatPrice } from "../../lib/format";
23
+ import type { Product } from "../../tools/render-carousel.js";
24
+ import type { Price, Spec } from "../../types.js";
25
+ import { DetailView } from "./detail";
26
+
27
+ const SKELETON_COUNT = 4;
28
+
29
+ // One narration line per on-screen product. `id` ties the card back to its full
30
+ // record in structuredContent.
31
+ function narrate(product: Product, index: number): string {
32
+ const { card } = product;
33
+ const price = card.price ? ` - ${formatPrice(card.price)}` : "";
34
+ const oos = card.outOfStock ? " [out of stock]" : "";
35
+ return `${index + 1}. ${card.title} (id: ${product.id})${price}${oos}`;
36
+ }
37
+
38
+ // View state, persisted on the host so an open detail survives a remount (e.g.
39
+ // after a follow-up message). scrollLeft restores the carousel position on the
40
+ // way back; spec is the full product spec (every variant) the model can answer
41
+ // from while the detail is open.
42
+ type VariantSpec = {
43
+ selection: Record<string, string>; // option label -> chosen value label
44
+ price?: Price;
45
+ available: boolean;
46
+ specs: Spec[];
47
+ };
48
+ type ProductSpec = { id: string; title: string; variants: VariantSpec[] };
49
+ type ViewState = {
50
+ selectedId: string | null;
51
+ scrollLeft: number;
52
+ spec: ProductSpec | null;
53
+ };
54
+
55
+ // The complete spec of the open product — every variant, not just the visible
56
+ // one — pushed to view state so the model can answer any detail question. The
57
+ // on-screen variant is narrated separately (data-llm, in the detail view). Keep
58
+ // it to a single product; a very large spec trips the view-state size warning.
59
+ function buildProductSpec(product: Product): ProductSpec {
60
+ const variants: VariantSpec[] = [];
61
+ for (const variant of product.variants) {
62
+ const selection: Record<string, string> = {};
63
+ for (const option of product.options) {
64
+ const valueId = variant.selection[option.id];
65
+ if (!valueId) {
66
+ continue;
67
+ }
68
+ let label = valueId;
69
+ for (const value of option.values) {
70
+ if (value.id === valueId) {
71
+ label = value.label;
72
+ break;
73
+ }
74
+ }
75
+ selection[option.label] = label;
76
+ }
77
+ variants.push({
78
+ selection,
79
+ price: variant.price,
80
+ available: !variant.outOfStock,
81
+ specs: variant.specs,
82
+ });
83
+ }
84
+ return { id: product.id, title: product.card.title, variants };
85
+ }
86
+
87
+ /**
88
+ * Carousel view + product detail, in one view. The carousel is the inline
89
+ * surface; tapping a card opens the detail fullscreen over it (the carousel is
90
+ * hidden, not unmounted). Both read the full products from `_meta`; the detail
91
+ * needs no extra fetch.
92
+ */
93
+ function Carousel() {
94
+ const { responseMetadata } = useToolInfo<"render-carousel">();
95
+ const labels = useLabels();
96
+ const trackRef = useRef<HTMLElement>(null);
97
+ const [visibleIndices, setVisibleIndices] = useState<number[]>([]);
98
+ const [mode, setMode] = useDisplayMode();
99
+ const [nav, setNav] = useViewState<ViewState>({
100
+ selectedId: null,
101
+ scrollLeft: 0,
102
+ spec: null,
103
+ });
104
+ // True between requesting fullscreen and the host applying it, so the
105
+ // collapse-is-back effect below does not fire mid-transition.
106
+ const enteringRef = useRef(false);
107
+
108
+ const selectedId = nav.selectedId;
109
+ // The detail only mounts once the host is actually fullscreen: rendering the
110
+ // tall page inside the small inline frame would flash a cramped layout.
111
+ const showDetail = selectedId != null && mode === "fullscreen";
112
+
113
+ // A host-driven exit from fullscreen (the user used host chrome) means "back".
114
+ useEffect(() => {
115
+ if (mode === "fullscreen") {
116
+ enteringRef.current = false;
117
+ return;
118
+ }
119
+ if (selectedId != null && !enteringRef.current) {
120
+ setNav((prev) => ({ ...prev, selectedId: null, spec: null }));
121
+ }
122
+ }, [mode, selectedId, setNav]);
123
+
124
+ // Restore carousel scroll when back on the carousel. display:none resets
125
+ // scrollLeft, so re-apply once it is visible again (layout effect, not mount).
126
+ useLayoutEffect(() => {
127
+ if (!showDetail && trackRef.current) {
128
+ trackRef.current.scrollLeft = nav.scrollLeft;
129
+ }
130
+ }, [showDetail, nav.scrollLeft]);
131
+
132
+ function openProduct(id: string) {
133
+ enteringRef.current = true;
134
+ const list = responseMetadata?.products ?? [];
135
+ let spec: ProductSpec | null = null;
136
+ for (const product of list) {
137
+ if (product.id === id) {
138
+ spec = buildProductSpec(product);
139
+ break;
140
+ }
141
+ }
142
+ setNav({
143
+ selectedId: id,
144
+ scrollLeft: trackRef.current?.scrollLeft ?? 0,
145
+ spec,
146
+ });
147
+ setMode("fullscreen");
148
+ }
149
+
150
+ // Tool still resolving: reserve the layout with skeleton cards.
151
+ if (responseMetadata == null) {
152
+ const skeletons: ReactNode[] = [];
153
+ for (let i = 0; i < SKELETON_COUNT; i++) {
154
+ skeletons.push(<ProductCardSkeleton key={i} />);
155
+ }
156
+ return (
157
+ <ViewFrame>
158
+ <div className={sprinkles({ p: "3xs" })}>
159
+ <ProductCarousel loading>{skeletons}</ProductCarousel>
160
+ </div>
161
+ </ViewFrame>
162
+ );
163
+ }
164
+
165
+ const products = responseMetadata.products ?? [];
166
+
167
+ if (products.length === 0) {
168
+ return (
169
+ <ViewFrame>
170
+ <EmptyState message={labels.noProducts} />
171
+ </ViewFrame>
172
+ );
173
+ }
174
+
175
+ // Guard against a stale id (e.g. the model rendered a new carousel while a
176
+ // detail was open): fall back to the carousel rather than an empty page.
177
+ const selectedProduct =
178
+ selectedId != null
179
+ ? products.find((product) => product.id === selectedId)
180
+ : undefined;
181
+ // The detail actually renders only when we have a product to show. Everything
182
+ // that hides the carousel keys off this, so a stale id shows the carousel, not
183
+ // a blank page.
184
+ const detailProduct = showDetail ? selectedProduct : undefined;
185
+
186
+ const cards: ReactNode[] = [];
187
+ for (const [index, product] of products.entries()) {
188
+ const { card } = product;
189
+ cards.push(
190
+ <div key={product.id} className={cardStyles.cardClickable}>
191
+ <ProductCard
192
+ // Drop per-card grounding while the detail owns the screen.
193
+ data-llm={
194
+ !detailProduct && visibleIndices.includes(index)
195
+ ? narrate(product, index)
196
+ : ""
197
+ }
198
+ title={card.title}
199
+ price={card.price}
200
+ media={card.media}
201
+ outOfStock={card.outOfStock}
202
+ />
203
+ <button
204
+ type="button"
205
+ aria-label={card.title}
206
+ className={cardStyles.cardButton}
207
+ onClick={() => openProduct(product.id)}
208
+ />
209
+ </div>,
210
+ );
211
+ }
212
+
213
+ const narration = `Carousel of ${products.length} product(s); the user scrolls horizontally. On screen now:`;
214
+
215
+ return (
216
+ <ViewFrame>
217
+ <div
218
+ className={sprinkles({ p: "3xs" })}
219
+ style={{ display: detailProduct ? "none" : undefined }}
220
+ >
221
+ <ProductCarousel
222
+ trackRef={trackRef}
223
+ onVisibleChange={setVisibleIndices}
224
+ data-llm={detailProduct ? "" : narration}
225
+ >
226
+ {cards}
227
+ </ProductCarousel>
228
+ </div>
229
+ {detailProduct ? <DetailView product={detailProduct} /> : null}
230
+ </ViewFrame>
231
+ );
232
+ }
233
+
234
+ export default Carousel;
@@ -0,0 +1,11 @@
1
+ {
2
+ "extends": "skybridge/tsconfig",
3
+
4
+ "compilerOptions": {
5
+ "module": "ESNext",
6
+ "moduleResolution": "Bundler",
7
+ "types": ["node", "vite/client"]
8
+ },
9
+
10
+ "include": ["src", ".skybridge/**/*.d.ts"]
11
+ }
@@ -0,0 +1,8 @@
1
+ import { vanillaExtractPlugin } from "@vanilla-extract/vite-plugin";
2
+ import react from "@vitejs/plugin-react";
3
+ import { skybridge } from "skybridge/vite";
4
+ import { defineConfig, type PluginOption } from "vite";
5
+
6
+ export default defineConfig({
7
+ plugins: [skybridge() as PluginOption, react(), vanillaExtractPlugin()],
8
+ });
@@ -1,7 +0,0 @@
1
- {
2
- "mcpServers": {
3
- "local": {
4
- "url": "http://localhost:3000/mcp"
5
- }
6
- }
7
- }
package/template/.nvmrc DELETED
@@ -1 +0,0 @@
1
- lts/jod
@@ -1,16 +0,0 @@
1
- {
2
- "version": "0.0.1",
3
- "configurations": [
4
- {
5
- "name": "Debug MCP Server",
6
- "type": "node",
7
- "request": "launch",
8
- "program": "${workspaceFolder}/dist/index.js",
9
- "console": "integratedTerminal",
10
- "sourceMaps": true,
11
- "outFiles": ["${workspaceFolder}/dist/**/*.js"],
12
- "preLaunchTask": "npm: build",
13
- "stopOnEntry": false
14
- }
15
- ]
16
- }
@@ -1,3 +0,0 @@
1
- {
2
- "editor.formatOnSave": true
3
- }
@@ -1,14 +0,0 @@
1
- {
2
- "version": "2.0.0",
3
- "tasks": [
4
- {
5
- "type": "shell",
6
- "command": "npm",
7
- "args": ["build"],
8
- "group": "build",
9
- "label": "npm: build",
10
- "detail": "npm build",
11
- "problemMatcher": ["$tsc"]
12
- }
13
- ]
14
- }
@@ -1,116 +0,0 @@
1
- # ChatGPT Apps SDK Alpic Starter
2
-
3
- This repository is a minimal Typescript application demonstrating how to build an OpenAI Apps SDK compatible MCP server with widget rendering in ChatGPT.
4
-
5
- ![Demo](docs/demo.gif)
6
-
7
- ## Overview
8
-
9
- This project shows how to integrate a Typescript express application with the ChatGPT Apps SDK using the Model Context Protocol (MCP). It includes a working MCP server that exposes tools and resources that can be called from ChatGPT, with responses rendered natively in ChatGPT. It also includes MCP tools without UI widgets.
10
-
11
- ## Getting Started
12
-
13
- ### Prerequisites
14
-
15
- - Node.js 22+ (see `.nvmrc` for exact version)
16
- - pnpm (install with `npm install -g pnpm`)
17
- - Ngrok
18
-
19
- ### Local Development with Hot Module Replacement (HMR)
20
-
21
- This project uses Vite for React widget development with full HMR support, allowing you to see changes in real-time, directly within ChatGPT conversation, without restarting the server.
22
-
23
- #### 1. Install
24
-
25
- ```bash
26
- pnpm install
27
- ```
28
-
29
- #### 2. Start the Development Server
30
-
31
- Run the development server from the root directory:
32
-
33
- ```bash
34
- pnpm dev
35
- ```
36
-
37
- This command starts an Express server on port 3000. This server packages:
38
-
39
- - an MCP endpoint on `/mcp` - aka the ChatGPT App Backend
40
- - a React application on Vite HMR dev server - aka the ChatGPT App Frontend
41
-
42
- #### 3. Expose Your Local Server
43
-
44
- In a separate terminal, expose your local server using ngrok:
45
-
46
- ```bash
47
- ngrok http 3000
48
- ```
49
-
50
- Copy the forwarding URL from ngrok output:
51
-
52
- ```bash
53
- Forwarding https://3785c5ddc4b6.ngrok-free.app -> http://localhost:3000
54
- ```
55
-
56
- #### 4. Connect to ChatGPT
57
-
58
- - Enable **Settings → Connectors → Advanced → Developer mode** in the ChatGPT client
59
- - Navigate to **Settings → Connectors → Create**
60
- - Enter your ngrok URL with the `/mcp` path (e.g., `https://3785c5ddc4b6.ngrok-free.app/mcp`)
61
- - Click **Create**
62
-
63
- #### 5. Test Your Integration
64
-
65
- - Start a new conversation in ChatGPT
66
- - Select your newly created connector using **the + button → Your connector**
67
- - Try prompting the model (e.g., "Show me pikachu details")
68
-
69
- #### 6. Develop with HMR
70
-
71
- Now you can edit React components in `web` and see changes instantly:
72
-
73
- - Make changes to any component
74
- - Save the file
75
- - The widget will automatically update in ChatGPT without refreshing or reconnecting
76
- - The Express server and MCP server continue running without interruption
77
-
78
- **Note:** When you modify widget components, changes will be reflected immediately. If you modify MCP server code (in `src/`), you may need to reload your connector in **Settings → Connectors → [Your connector] → Reload**.
79
-
80
- ## Widget Naming Convention
81
-
82
- **Important:** For a widget to work properly, the name of the endpoint in your MCP server must match the file name of the corresponding React component in `web/src/widgets/`.
83
-
84
- For example:
85
-
86
- - If you create a widget endpoint named `pokemon-card`, you must create a corresponding React component file at `web/src/widgets/pokemon-card.tsx`
87
- - The endpoint name and the widget file name (without the `.tsx` extension) must be identical
88
-
89
- This naming convention allows the system to automatically map widget requests to their corresponding React components.
90
-
91
- ## Deploy to Production
92
-
93
- Use Alpic to deploy your OpenAI App to production.
94
-
95
- [![Deploy on Alpic](https://assets.alpic.ai/button.svg)](https://app.alpic.ai/new/clone?repositoryUrl=https%3A%2F%2Fgithub.com%2Falpic-ai%2Fapps-sdk-template)
96
-
97
- - In ChatGPT, navigate to **Settings → Connectors → Create** and add your MCP server URL (e.g., `https://your-app-name.alpic.live`)
98
-
99
- ## Project Structure
100
-
101
- ```
102
- .
103
- ├── server/
104
- │ ├── app.ts # OpenAI App extension class with widget API implementation
105
- │ ├── server.ts # MCP server with tool/resource/prompt registration
106
- │ └── index.ts # Express server definition
107
- └── web/
108
- └── src/
109
- └── widgets/ # React widget components (must match endpoint names)
110
- ```
111
-
112
- ## Resources
113
-
114
- - [Apps SDK Documentation](https://developers.openai.com/apps-sdk)
115
- - [Model Context Protocol Documentation](https://modelcontextprotocol.io/)
116
- - [Alpic Documentation](https://docs.alpic.ai/)