sunpeak 0.5.8 → 0.5.10

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 (43) hide show
  1. package/README.md +13 -11
  2. package/bin/sunpeak.js +3 -3
  3. package/dist/chatgpt/mock-openai.d.ts +7 -0
  4. package/dist/chatgpt/simple-sidebar.d.ts +38 -0
  5. package/dist/chatgpt/theme-provider.d.ts +2 -2
  6. package/dist/index.cjs +7733 -199
  7. package/dist/index.cjs.map +1 -1
  8. package/dist/index.js +7734 -201
  9. package/dist/index.js.map +1 -1
  10. package/dist/mcp/index.cjs +80 -106
  11. package/dist/mcp/index.cjs.map +1 -1
  12. package/dist/mcp/index.js +80 -106
  13. package/dist/mcp/index.js.map +1 -1
  14. package/dist/style.css +2890 -315
  15. package/package.json +6 -5
  16. package/template/README.md +1 -0
  17. package/template/dev/main.tsx +6 -10
  18. package/template/package.json +5 -4
  19. package/template/scripts/build-all.mjs +19 -10
  20. package/template/scripts/validate.mjs +8 -2
  21. package/template/src/components/album/album-card.test.tsx +62 -0
  22. package/template/src/components/album/album-card.tsx +14 -16
  23. package/template/src/components/album/albums.test.tsx +88 -0
  24. package/template/src/components/album/albums.tsx +50 -64
  25. package/template/src/components/album/film-strip.test.tsx +64 -0
  26. package/template/src/components/album/film-strip.tsx +16 -16
  27. package/template/src/components/album/fullscreen-viewer.test.tsx +77 -0
  28. package/template/src/components/album/fullscreen-viewer.tsx +45 -50
  29. package/template/src/components/card/card.test.tsx +1 -4
  30. package/template/src/components/card/card.tsx +38 -46
  31. package/template/src/components/carousel/carousel.tsx +57 -67
  32. package/template/src/components/resources/{AlbumsResource.tsx → albums-resource.tsx} +5 -5
  33. package/template/src/components/resources/{CarouselResource.tsx → carousel-resource.tsx} +18 -18
  34. package/template/src/components/resources/{CounterResource.tsx → counter-resource.tsx} +11 -31
  35. package/template/src/components/resources/index.ts +3 -3
  36. package/template/src/simulations/albums-simulation.ts +71 -71
  37. package/template/src/simulations/carousel-simulation.ts +34 -34
  38. package/template/src/simulations/counter-simulation.ts +2 -2
  39. package/template/vite.config.build.ts +2 -2
  40. package/template/vite.config.ts +1 -1
  41. package/template/vitest.config.ts +1 -1
  42. package/dist/runtime/index.d.ts +0 -7
  43. /package/dist/{runtime → providers}/provider-detection.d.ts +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sunpeak",
3
- "version": "0.5.8",
3
+ "version": "0.5.10",
4
4
  "description": "The MCP App SDK. Quickstart, build, & test your ChatGPT App locally!",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",
@@ -73,8 +73,8 @@
73
73
  "@testing-library/react": "^16.3.0",
74
74
  "@testing-library/user-event": "^14.6.1",
75
75
  "@types/node": "^24.10.1",
76
- "@types/react": "^18.3.12",
77
- "@types/react-dom": "^18.3.1",
76
+ "@types/react": "^19.2.7",
77
+ "@types/react-dom": "^19.2.3",
78
78
  "@typescript-eslint/eslint-plugin": "^8.47.0",
79
79
  "@typescript-eslint/parser": "^8.47.0",
80
80
  "@vitejs/plugin-react": "^4.3.4",
@@ -84,8 +84,8 @@
84
84
  "eslint-plugin-react-hooks": "^7.0.1",
85
85
  "jsdom": "^27.2.0",
86
86
  "prettier": "^3.6.2",
87
- "react": "^18.3.1",
88
- "react-dom": "^18.3.1",
87
+ "react": "^19.2.0",
88
+ "react-dom": "^19.2.0",
89
89
  "tailwindcss": "^4.1.17",
90
90
  "ts-node": "^10.9.2",
91
91
  "tsx": "^4.20.6",
@@ -105,6 +105,7 @@
105
105
  "scripts": {
106
106
  "build": "vite build",
107
107
  "dev": "pnpm --filter my-sunpeak-app dev",
108
+ "format": "prettier --write --list-different \"**/*.{ts,tsx,js,jsx,json,md}\"",
108
109
  "lint": "eslint . --ext .ts,.tsx --fix",
109
110
  "typecheck": "tsc --noEmit",
110
111
  "test": "vitest run",
@@ -25,6 +25,7 @@ pnpm validate
25
25
  ```
26
26
 
27
27
  This will:
28
+
28
29
  - Run linting, typechecking, and unit tests
29
30
  - Build your app
30
31
  - Verify that build outputs are created correctly
@@ -8,13 +8,13 @@ import * as Resources from '../src/components/resources';
8
8
 
9
9
  /**
10
10
  * Extract the resource component name from a URI
11
- * Example: 'ui://CounterResource.tsx' -> 'CounterResource'
11
+ * Example: 'ui://CounterResource' -> 'CounterResource'
12
12
  */
13
13
  function getResourceComponentFromURI(uri: string): React.ComponentType {
14
- // Extract component name from URI pattern: ui://ComponentName.tsx
15
- const match = uri.match(/^ui:\/\/(.+)\.tsx$/);
14
+ // Extract component name from URI pattern: ui://ComponentName
15
+ const match = uri.match(/^ui:\/\/(.+)$/);
16
16
  if (!match) {
17
- throw new Error(`Invalid resource URI format: ${uri}. Expected format: ui://ComponentName.tsx`);
17
+ throw new Error(`Invalid resource URI format: ${uri}. Expected format: ui://ComponentName`);
18
18
  }
19
19
 
20
20
  const componentName = match[1];
@@ -23,7 +23,7 @@ function getResourceComponentFromURI(uri: string): React.ComponentType {
23
23
  if (!component) {
24
24
  throw new Error(
25
25
  `Resource component "${componentName}" not found. ` +
26
- `Make sure it's exported from src/components/resources/index.ts`
26
+ `Make sure it's exported from src/components/resources/index.ts`
27
27
  );
28
28
  }
29
29
 
@@ -38,10 +38,6 @@ const simulations: Simulation[] = Object.values(SIMULATIONS).map((simulation) =>
38
38
 
39
39
  createRoot(document.getElementById('root')!).render(
40
40
  <StrictMode>
41
- <ChatGPTSimulator
42
- simulations={simulations}
43
- appName="Sunpeak App"
44
- appIcon="🌄"
45
- />
41
+ <ChatGPTSimulator simulations={simulations} appName="Sunpeak App" appIcon="🌄" />
46
42
  </StrictMode>
47
43
  );
@@ -6,6 +6,7 @@
6
6
  "scripts": {
7
7
  "build": "node scripts/build-all.mjs",
8
8
  "dev": "vite --port ${PORT:-6767}",
9
+ "format": "prettier --write --list-different \"**/*.{ts,tsx,js,jsx,json,md}\"",
9
10
  "mcp": "nodemon",
10
11
  "mcp:serve": "tsx mcp/server.ts",
11
12
  "lint": "eslint . --ext .ts,.tsx --fix",
@@ -27,8 +28,8 @@
27
28
  "@testing-library/react": "^16.3.0",
28
29
  "@testing-library/user-event": "^14.6.1",
29
30
  "@types/node": "^24.10.1",
30
- "@types/react": "^18.3.12",
31
- "@types/react-dom": "^18.3.1",
31
+ "@types/react": "^19.0.0",
32
+ "@types/react-dom": "^19.0.0",
32
33
  "@typescript-eslint/eslint-plugin": "^8.47.0",
33
34
  "@typescript-eslint/parser": "^8.47.0",
34
35
  "@vitejs/plugin-react": "^4.3.4",
@@ -40,8 +41,8 @@
40
41
  "nodemon": "^3.1.11",
41
42
  "postcss": "^8.4.49",
42
43
  "prettier": "^3.6.2",
43
- "react": "^18.3.1",
44
- "react-dom": "^18.3.1",
44
+ "react": "^19.0.0",
45
+ "react-dom": "^19.0.0",
45
46
  "tailwindcss": "^4.1.17",
46
47
  "ts-node": "^10.9.2",
47
48
  "tsx": "^4.20.6",
@@ -23,24 +23,33 @@ mkdirSync(tempDir, { recursive: true });
23
23
 
24
24
  // Auto-discover all resources
25
25
  const resourceFiles = readdirSync(resourcesDir)
26
- .filter(file => file.endsWith('Resource.tsx'))
26
+ .filter(file => file.endsWith('-resource.tsx'))
27
27
  .map(file => {
28
- const resourceName = file.replace('Resource.tsx', '');
28
+ // Extract kebab-case name: 'counter-resource.tsx' -> 'counter'
29
+ const kebabName = file.replace('-resource.tsx', '');
30
+
31
+ // Convert kebab-case to PascalCase: 'counter' -> 'Counter', 'my-widget' -> 'MyWidget'
32
+ const pascalName = kebabName
33
+ .split('-')
34
+ .map(word => word.charAt(0).toUpperCase() + word.slice(1))
35
+ .join('');
36
+
29
37
  return {
30
- componentName: `${resourceName}Resource`,
31
- entry: `.tmp/index-${resourceName.toLowerCase()}.tsx`,
32
- output: `${resourceName.toLowerCase()}.js`,
33
- buildOutDir: path.join(buildDir, resourceName.toLowerCase()),
38
+ componentName: `${pascalName}Resource`,
39
+ componentFile: file.replace('.tsx', ''),
40
+ entry: `.tmp/index-${kebabName}.tsx`,
41
+ output: `${kebabName}.js`,
42
+ buildOutDir: path.join(buildDir, kebabName),
34
43
  };
35
44
  });
36
45
 
37
- console.log('Building all tools...\n');
46
+ console.log('Building all resources...\n');
38
47
 
39
48
  // Read the template
40
49
  const template = readFileSync(templateFile, 'utf-8');
41
50
 
42
51
  // Build all resources (but don't copy yet)
43
- resourceFiles.forEach(({ componentName, entry, output, buildOutDir }, index) => {
52
+ resourceFiles.forEach(({ componentName, componentFile, entry, output, buildOutDir }, index) => {
44
53
  console.log(`[${index + 1}/${resourceFiles.length}] Building ${output}...`);
45
54
 
46
55
  try {
@@ -51,7 +60,7 @@ resourceFiles.forEach(({ componentName, entry, output, buildOutDir }, index) =>
51
60
 
52
61
  // Create entry file from template in temp directory
53
62
  const entryContent = template
54
- .replace('// RESOURCE_IMPORT', `import { ${componentName} } from '../src/components/resources/${componentName}';`)
63
+ .replace('// RESOURCE_IMPORT', `import { ${componentName} } from '../src/components/resources/${componentFile}';`)
55
64
  .replace('// RESOURCE_MOUNT', `createRoot(root).render(<${componentName} />);`);
56
65
 
57
66
  const entryPath = path.join(__dirname, '..', entry);
@@ -104,5 +113,5 @@ if (existsSync(buildDir)) {
104
113
  rmSync(buildDir, { recursive: true });
105
114
  }
106
115
 
107
- console.log('\n✓ All tools built successfully!');
116
+ console.log('\n✓ All resources built successfully!');
108
117
  console.log(`\nBuilt files:`, readdirSync(distDir));
@@ -77,6 +77,12 @@ try {
77
77
  console.log()
78
78
  printSuccess('pnpm install');
79
79
 
80
+ console.log('\nRunning: pnpm format');
81
+ if (!runCommand('pnpm format', PROJECT_ROOT)) {
82
+ throw new Error('pnpm format failed');
83
+ }
84
+ printSuccess('pnpm format');
85
+
80
86
  console.log('\nRunning: pnpm lint');
81
87
  if (!runCommand('pnpm lint', PROJECT_ROOT)) {
82
88
  throw new Error('pnpm lint failed');
@@ -124,8 +130,8 @@ try {
124
130
  printSuccess('pnpm build');
125
131
 
126
132
  // MCP Server Check
127
- console.log('\nRunning: pnpm mcp');
128
- const mcpProcess = spawn('pnpm', ['mcp'], {
133
+ console.log('\nRunning: pnpm mcp:serve');
134
+ const mcpProcess = spawn('pnpm', ['mcp:serve'], {
129
135
  cwd: PROJECT_ROOT,
130
136
  stdio: ['ignore', 'pipe', 'pipe'],
131
137
  env: { ...process.env, FORCE_COLOR: '1' },
@@ -0,0 +1,62 @@
1
+ import { render, screen, fireEvent } from '@testing-library/react';
2
+ import { describe, it, expect, vi } from 'vitest';
3
+ import { AlbumCard } from './album-card';
4
+ import type { Album } from './albums';
5
+
6
+ describe('AlbumCard', () => {
7
+ const mockAlbum: Album = {
8
+ id: 'test-album',
9
+ title: 'Test Album',
10
+ cover: 'https://example.com/cover.jpg',
11
+ photos: [
12
+ { id: '1', title: 'Photo 1', url: 'https://example.com/1.jpg' },
13
+ { id: '2', title: 'Photo 2', url: 'https://example.com/2.jpg' },
14
+ ],
15
+ };
16
+
17
+ it('correctly pluralizes photo count', () => {
18
+ // Test plural (2 photos)
19
+ const { rerender } = render(<AlbumCard album={mockAlbum} />);
20
+ expect(screen.getByText('2 photos')).toBeInTheDocument();
21
+
22
+ // Test singular (1 photo)
23
+ const singlePhotoAlbum: Album = {
24
+ ...mockAlbum,
25
+ photos: [{ id: '1', title: 'Photo 1', url: 'https://example.com/1.jpg' }],
26
+ };
27
+ rerender(<AlbumCard album={singlePhotoAlbum} />);
28
+ expect(screen.getByText('1 photo')).toBeInTheDocument();
29
+
30
+ // Test zero photos
31
+ const emptyAlbum: Album = {
32
+ ...mockAlbum,
33
+ photos: [],
34
+ };
35
+ rerender(<AlbumCard album={emptyAlbum} />);
36
+ expect(screen.getByText('0 photos')).toBeInTheDocument();
37
+ });
38
+
39
+ it('calls onSelect with correct album when clicked', () => {
40
+ const onSelect = vi.fn();
41
+ render(<AlbumCard album={mockAlbum} onSelect={onSelect} />);
42
+
43
+ const card = screen.getByRole('button');
44
+ fireEvent.click(card);
45
+
46
+ expect(onSelect).toHaveBeenCalledTimes(1);
47
+ expect(onSelect).toHaveBeenCalledWith(mockAlbum);
48
+ });
49
+
50
+ it('renders album title and cover image with correct attributes', () => {
51
+ render(<AlbumCard album={mockAlbum} />);
52
+
53
+ // Check title is displayed
54
+ expect(screen.getByText('Test Album')).toBeInTheDocument();
55
+
56
+ // Check image has correct src and alt
57
+ const image = screen.getByRole('img');
58
+ expect(image).toHaveAttribute('src', 'https://example.com/cover.jpg');
59
+ expect(image).toHaveAttribute('alt', 'Test Album');
60
+ expect(image).toHaveAttribute('loading', 'lazy');
61
+ });
62
+ });
@@ -1,13 +1,13 @@
1
- import * as React from "react"
2
- import { Button } from "@openai/apps-sdk-ui/components/Button"
3
- import { cn } from "../../lib/index"
4
- import type { Album } from "./albums"
1
+ import * as React from 'react';
2
+ import { Button } from '@openai/apps-sdk-ui/components/Button';
3
+ import { cn } from '../../lib/index';
4
+ import type { Album } from './albums';
5
5
 
6
6
  export type AlbumCardProps = {
7
- album: Album
8
- onSelect?: (album: Album) => void
9
- className?: string
10
- }
7
+ album: Album;
8
+ onSelect?: (album: Album) => void;
9
+ className?: string;
10
+ };
11
11
 
12
12
  export const AlbumCard = React.forwardRef<HTMLButtonElement, AlbumCardProps>(
13
13
  ({ album, onSelect, className }, ref) => {
@@ -17,7 +17,7 @@ export const AlbumCard = React.forwardRef<HTMLButtonElement, AlbumCardProps>(
17
17
  variant="ghost"
18
18
  color="secondary"
19
19
  className={cn(
20
- "rounded-xl flex-shrink-0 w-full h-full p-0 text-left flex flex-col [&:hover]:bg-transparent hover:bg-transparent cursor-pointer",
20
+ 'rounded-xl flex-shrink-0 w-full h-full p-0 text-left flex flex-col [&:hover]:bg-transparent hover:bg-transparent cursor-pointer',
21
21
  className
22
22
  )}
23
23
  onClick={() => onSelect?.(album)}
@@ -31,15 +31,13 @@ export const AlbumCard = React.forwardRef<HTMLButtonElement, AlbumCardProps>(
31
31
  />
32
32
  </div>
33
33
  <div className="flex-shrink-0 w-full p-2">
34
- <div className="text-base font-normal text-primary">
35
- {album.title}
36
- </div>
34
+ <div className="text-base font-normal text-primary">{album.title}</div>
37
35
  <div className="text-sm text-secondary">
38
- {album.photos.length} {album.photos.length === 1 ? "photo" : "photos"}
36
+ {album.photos.length} {album.photos.length === 1 ? 'photo' : 'photos'}
39
37
  </div>
40
38
  </div>
41
39
  </Button>
42
- )
40
+ );
43
41
  }
44
- )
45
- AlbumCard.displayName = "AlbumCard"
42
+ );
43
+ AlbumCard.displayName = 'AlbumCard';
@@ -0,0 +1,88 @@
1
+ import { render, screen, fireEvent } from '@testing-library/react';
2
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
3
+ import { Albums, type AlbumsData } from './albums';
4
+
5
+ // Mock sunpeak hooks
6
+ const mockSetWidgetState = vi.fn();
7
+ const mockRequestDisplayMode = vi.fn();
8
+ let mockWidgetData: AlbumsData = { albums: [] };
9
+
10
+ vi.mock('sunpeak', () => ({
11
+ useWidgetProps: () => mockWidgetData,
12
+ useWidgetState: () => [{ selectedAlbumId: null }, mockSetWidgetState],
13
+ useDisplayMode: () => 'default',
14
+ useWidgetAPI: () => ({ requestDisplayMode: mockRequestDisplayMode }),
15
+ }));
16
+
17
+ // Mock child components to simplify testing
18
+ vi.mock('./fullscreen-viewer', () => ({
19
+ FullscreenViewer: ({ album }: { album: { title: string } }) => (
20
+ <div data-testid="fullscreen-viewer">{album.title}</div>
21
+ ),
22
+ }));
23
+
24
+ vi.mock('../carousel', () => ({
25
+ Carousel: ({ children }: { children: React.ReactNode }) => (
26
+ <div data-testid="carousel">{children}</div>
27
+ ),
28
+ }));
29
+
30
+ describe('Albums', () => {
31
+ const mockAlbums = [
32
+ {
33
+ id: 'album-1',
34
+ title: 'Summer Vacation',
35
+ cover: 'https://example.com/1.jpg',
36
+ photos: [{ id: 'p1', title: 'Beach', url: 'https://example.com/p1.jpg' }],
37
+ },
38
+ {
39
+ id: 'album-2',
40
+ title: 'City Trip',
41
+ cover: 'https://example.com/2.jpg',
42
+ photos: [{ id: 'p2', title: 'Downtown', url: 'https://example.com/p2.jpg' }],
43
+ },
44
+ ];
45
+
46
+ beforeEach(() => {
47
+ vi.clearAllMocks();
48
+ mockWidgetData = { albums: mockAlbums };
49
+ });
50
+
51
+ it('renders Carousel with all albums in default mode', () => {
52
+ render(<Albums />);
53
+
54
+ // Should render carousel
55
+ expect(screen.getByTestId('carousel')).toBeInTheDocument();
56
+
57
+ // Should render both album titles
58
+ expect(screen.getByText('Summer Vacation')).toBeInTheDocument();
59
+ expect(screen.getByText('City Trip')).toBeInTheDocument();
60
+ });
61
+
62
+ it('calls setWidgetState and requestDisplayMode when album is selected', () => {
63
+ render(<Albums />);
64
+
65
+ // Find and click the first album
66
+ const firstAlbum = screen.getByText('Summer Vacation').closest('button')!;
67
+ fireEvent.click(firstAlbum);
68
+
69
+ // Should update widget state with selected album ID
70
+ expect(mockSetWidgetState).toHaveBeenCalledWith({ selectedAlbumId: 'album-1' });
71
+
72
+ // Should request fullscreen mode
73
+ expect(mockRequestDisplayMode).toHaveBeenCalledWith({ mode: 'fullscreen' });
74
+ });
75
+
76
+ it('renders empty carousel when no albums provided', () => {
77
+ mockWidgetData = { albums: [] };
78
+
79
+ const { container } = render(<Albums />);
80
+
81
+ // Should render carousel (even if empty)
82
+ expect(screen.getByTestId('carousel')).toBeInTheDocument();
83
+
84
+ // Should not render any album cards
85
+ const buttons = container.querySelectorAll('button');
86
+ expect(buttons.length).toBe(0);
87
+ });
88
+ });
@@ -1,77 +1,63 @@
1
- import * as React from "react"
2
- import { useWidgetState, useDisplayMode, useWidgetAPI, useWidgetProps } from "sunpeak"
3
- import { Carousel } from "../carousel"
4
- import { AlbumCard } from "./album-card"
5
- import { FullscreenViewer } from "./fullscreen-viewer"
1
+ import * as React from 'react';
2
+ import { useWidgetState, useDisplayMode, useWidgetAPI, useWidgetProps } from 'sunpeak';
3
+ import { Carousel } from '../carousel';
4
+ import { AlbumCard } from './album-card';
5
+ import { FullscreenViewer } from './fullscreen-viewer';
6
6
 
7
7
  export interface Album {
8
- id: string
9
- title: string
10
- cover: string
8
+ id: string;
9
+ title: string;
10
+ cover: string;
11
11
  photos: Array<{
12
- id: string
13
- title: string
14
- url: string
15
- }>
12
+ id: string;
13
+ title: string;
14
+ url: string;
15
+ }>;
16
16
  }
17
17
 
18
18
  export interface AlbumsData extends Record<string, unknown> {
19
- albums: Album[]
19
+ albums: Album[];
20
20
  }
21
21
 
22
22
  export interface AlbumsState extends Record<string, unknown> {
23
- selectedAlbumId?: string | null
23
+ selectedAlbumId?: string | null;
24
24
  }
25
25
 
26
26
  export type AlbumsProps = {
27
- className?: string
28
- }
29
-
30
- export const Albums = React.forwardRef<HTMLDivElement, AlbumsProps>(
31
- ({ className }, ref) => {
32
- const data = useWidgetProps<AlbumsData>(() => ({ albums: [] }))
33
- const [widgetState, setWidgetState] = useWidgetState<AlbumsState>(() => ({
34
- selectedAlbumId: null,
35
- }))
36
- const displayMode = useDisplayMode()
37
- const api = useWidgetAPI()
38
-
39
- const albums = data.albums || []
40
- const selectedAlbum = albums.find(
41
- (album) => album.id === widgetState?.selectedAlbumId
42
- )
43
-
44
- const handleSelectAlbum = React.useCallback(
45
- (album: Album) => {
46
- setWidgetState({ selectedAlbumId: album.id })
47
- api?.requestDisplayMode?.({ mode: "fullscreen" })
48
- },
49
- [setWidgetState, api]
50
- )
51
-
52
- if (displayMode === "fullscreen" && selectedAlbum) {
53
- return (
54
- <FullscreenViewer
55
- ref={ref}
56
- album={selectedAlbum}
57
- />
58
- )
59
- }
60
-
61
- return (
62
- <div ref={ref} className={className}>
63
- <Carousel
64
- gap={20}
65
- showArrows={false}
66
- showEdgeGradients={false}
67
- cardWidth={272}
68
- >
69
- {albums.map((album) => (
70
- <AlbumCard key={album.id} album={album} onSelect={handleSelectAlbum} />
71
- ))}
72
- </Carousel>
73
- </div>
74
- )
27
+ className?: string;
28
+ };
29
+
30
+ export const Albums = React.forwardRef<HTMLDivElement, AlbumsProps>(({ className }, ref) => {
31
+ const data = useWidgetProps<AlbumsData>(() => ({ albums: [] }));
32
+ const [widgetState, setWidgetState] = useWidgetState<AlbumsState>(() => ({
33
+ selectedAlbumId: null,
34
+ }));
35
+ const displayMode = useDisplayMode();
36
+ const api = useWidgetAPI();
37
+
38
+ const albums = data.albums || [];
39
+ const selectedAlbum = albums.find((album) => album.id === widgetState?.selectedAlbumId);
40
+
41
+ const handleSelectAlbum = React.useCallback(
42
+ (album: Album) => {
43
+ setWidgetState({ selectedAlbumId: album.id });
44
+ api?.requestDisplayMode?.({ mode: 'fullscreen' });
45
+ },
46
+ [setWidgetState, api]
47
+ );
48
+
49
+ if (displayMode === 'fullscreen' && selectedAlbum) {
50
+ return <FullscreenViewer ref={ref} album={selectedAlbum} />;
75
51
  }
76
- )
77
- Albums.displayName = "Albums"
52
+
53
+ return (
54
+ <div ref={ref} className={className}>
55
+ <Carousel gap={20} showArrows={false} showEdgeGradients={false} cardWidth={272}>
56
+ {albums.map((album) => (
57
+ <AlbumCard key={album.id} album={album} onSelect={handleSelectAlbum} />
58
+ ))}
59
+ </Carousel>
60
+ </div>
61
+ );
62
+ });
63
+ Albums.displayName = 'Albums';
@@ -0,0 +1,64 @@
1
+ import { render, screen, fireEvent } from '@testing-library/react';
2
+ import { describe, it, expect, vi } from 'vitest';
3
+ import { FilmStrip } from './film-strip';
4
+ import type { Album } from './albums';
5
+
6
+ describe('FilmStrip', () => {
7
+ const mockAlbum: Album = {
8
+ id: 'test-album',
9
+ title: 'Test Album',
10
+ cover: 'https://example.com/cover.jpg',
11
+ photos: [
12
+ { id: '1', title: 'Sunset', url: 'https://example.com/1.jpg' },
13
+ { id: '2', title: '', url: 'https://example.com/2.jpg' },
14
+ { id: '3', title: 'Mountains', url: 'https://example.com/3.jpg' },
15
+ ],
16
+ };
17
+
18
+ it('applies correct styling to selected photo', () => {
19
+ const { container } = render(<FilmStrip album={mockAlbum} selectedIndex={1} />);
20
+
21
+ const buttons = container.querySelectorAll('button');
22
+
23
+ // Selected photo (index 1) should have border-primary
24
+ expect(buttons[1].className).toContain('border-primary');
25
+ expect(buttons[1].className).toContain('shadow-md');
26
+
27
+ // Non-selected photos should have border-transparent
28
+ expect(buttons[0].className).toContain('border-transparent');
29
+ expect(buttons[0].className).toContain('opacity-60');
30
+ expect(buttons[2].className).toContain('border-transparent');
31
+ });
32
+
33
+ it('calls onSelect with correct index when photo is clicked', () => {
34
+ const onSelect = vi.fn();
35
+ render(<FilmStrip album={mockAlbum} selectedIndex={0} onSelect={onSelect} />);
36
+
37
+ const images = screen.getAllByRole('img');
38
+
39
+ // Click on the second photo (index 1)
40
+ fireEvent.click(images[1].closest('button')!);
41
+ expect(onSelect).toHaveBeenCalledTimes(1);
42
+ expect(onSelect).toHaveBeenCalledWith(1);
43
+
44
+ // Click on the third photo (index 2)
45
+ fireEvent.click(images[2].closest('button')!);
46
+ expect(onSelect).toHaveBeenCalledTimes(2);
47
+ expect(onSelect).toHaveBeenCalledWith(2);
48
+ });
49
+
50
+ it('renders alt text with fallback for photos without titles', () => {
51
+ render(<FilmStrip album={mockAlbum} selectedIndex={0} />);
52
+
53
+ const images = screen.getAllByRole('img');
54
+
55
+ // Photo with title
56
+ expect(images[0]).toHaveAttribute('alt', 'Sunset');
57
+
58
+ // Photo without title should fall back to "Photo N"
59
+ expect(images[1]).toHaveAttribute('alt', 'Photo 2');
60
+
61
+ // Photo with title
62
+ expect(images[2]).toHaveAttribute('alt', 'Mountains');
63
+ });
64
+ });
@@ -1,14 +1,14 @@
1
- import * as React from "react"
2
- import { Button } from "@openai/apps-sdk-ui/components/Button"
3
- import { cn } from "../../lib/index"
4
- import type { Album } from "./albums"
1
+ import * as React from 'react';
2
+ import { Button } from '@openai/apps-sdk-ui/components/Button';
3
+ import { cn } from '../../lib/index';
4
+ import type { Album } from './albums';
5
5
 
6
6
  export type FilmStripProps = {
7
- album: Album
8
- selectedIndex: number
9
- onSelect?: (index: number) => void
10
- className?: string
11
- }
7
+ album: Album;
8
+ selectedIndex: number;
9
+ onSelect?: (index: number) => void;
10
+ className?: string;
11
+ };
12
12
 
13
13
  export const FilmStrip = React.forwardRef<HTMLDivElement, FilmStripProps>(
14
14
  ({ album, selectedIndex, onSelect, className }, ref) => {
@@ -16,7 +16,7 @@ export const FilmStrip = React.forwardRef<HTMLDivElement, FilmStripProps>(
16
16
  <div
17
17
  ref={ref}
18
18
  className={cn(
19
- "h-full w-full overflow-auto flex flex-col items-center justify-center p-5 space-y-5",
19
+ 'h-full w-full overflow-auto flex flex-col items-center justify-center p-5 space-y-5',
20
20
  className
21
21
  )}
22
22
  >
@@ -27,10 +27,10 @@ export const FilmStrip = React.forwardRef<HTMLDivElement, FilmStripProps>(
27
27
  color="secondary"
28
28
  onClick={() => onSelect?.(idx)}
29
29
  className={cn(
30
- "block w-full h-auto p-[1px] pointer-events-auto rounded-[10px] border transition-all",
30
+ 'block w-full h-auto p-[1px] pointer-events-auto rounded-[10px] border transition-all',
31
31
  idx === selectedIndex
32
- ? "border-primary shadow-md"
33
- : "border-transparent hover:border-primary/30 opacity-60 hover:opacity-100"
32
+ ? 'border-primary shadow-md'
33
+ : 'border-transparent hover:border-primary/30 opacity-60 hover:opacity-100'
34
34
  )}
35
35
  >
36
36
  <div className="aspect-[5/3] rounded-lg overflow-hidden w-full">
@@ -44,7 +44,7 @@ export const FilmStrip = React.forwardRef<HTMLDivElement, FilmStripProps>(
44
44
  </Button>
45
45
  ))}
46
46
  </div>
47
- )
47
+ );
48
48
  }
49
- )
50
- FilmStrip.displayName = "FilmStrip"
49
+ );
50
+ FilmStrip.displayName = 'FilmStrip';