# Handoff Report: Editorial Carousel split_screen Layout Investigation This handoff report summarizes findings, analysis, and implementation proposals for introducing the `split_screen` layout configuration to the `EditorialCarousel` page section. --- ## 1. Observation We directly investigated five code locations: ### 1.1 Types Definition - **File**: `packages/types/index.ts` - **Lines**: 375-388 - **Current schema code**: ```typescript export const EditorialCarouselSectionPayloadSchema = z.object({ title: LocalizedStringSchema, backgroundColor: z.string().optional(), textColor: z.string().optional(), slides: z.array(z.object({ category: LocalizedStringSchema, title: LocalizedStringSchema, description: LocalizedStringSchema, imageUrl: z.string().min(1), buttonText: LocalizedStringSchema.optional(), buttonLink: z.string().optional(), })).min(1) }); export type EditorialCarouselSectionPayload = z.infer; ``` ### 1.2 MCP Server Tool - **File**: `apps/mcp-server/section-toolkit.ts` - **Lines**: 265-292 - **Current properties structure**: ```typescript name: "update_editorial_carousel", sectionType: "EDITORIAL_CAROUSEL", description: `Update an EDITORIAL_CAROUSEL section. This tool performs a partial update/merge (PATCH). Omitted fields are preserved. The 'sectionId' argument correlates to the 'id' field in the fetched payload. title, description, and slides are supported. Example payload: { "sectionId": 12, "title": ${localizedStringExample}, "description": ${localizedStringExample} }`, properties: { sectionId: { type: "number", description: "The numeric ID of the section to update" }, title: { ...localizedStringSchema, description: `Localized section title, for example ${localizedStringExample}` }, description: { ...localizedStringSchema, description: `Localized section description, for example ${localizedStringExample}` }, backgroundColor: { type: "string", description: "Plain string background color in hex or rgba" }, textColor: { type: "string", description: "Plain string text color in hex or rgba" }, slides: { ... } } ``` - **Execution (Lines 550-578)**: The tool retrieves the payload properties defined in the config, extracts them from incoming `params`, fetches the existing section from backend (`GET /pages/sections/:id?hydrate=false`), merges the two payloads (`mergedPayload = { ...existingRes.data.payload, ...payload }`), and calls `PUT /pages/sections/:id`. ### 1.3 Backend Section Validation - **File**: `apps/backend/src/routes/pages.ts` - **Lines**: 451-465 (under `PUT /sections/:sectionId`), 536-578 (inside `validateSectionPayload`) - **Validation logic**: ```typescript function validateSectionPayload( type: string, payload: any, ): { success: boolean; issues?: z.ZodIssue[]; data?: any } { // ... switch (type.toUpperCase()) { // ... case "EDITORIAL_CAROUSEL": schema = EditorialCarouselSectionPayloadSchema; break; // ... } const res = schema.safeParse(payload); if (!res.success) { return { success: false, issues: res.error.issues }; } return { success: true, data: res.data }; } ``` Validation output is saved directly into the database: `payload: finalPayload = validationResult.data`. ### 1.4 Admin Panel Page Builder Form - **File**: `apps/admin/src/components/page-builder/EditorialCarouselForm.tsx` - **Lines**: 43-86 - **Current properties updating pattern**: Uses `setEditingSection` by merging state: ```tsx onChange={(e) => setEditingSection({ ...editingSection, payload: { ...(editingSection.payload || {}), backgroundColor: e.target.value } })} ``` ### 1.5 Client Renderers (SPA and Website) - **SPA Component**: `apps/spa/src/components/DynamicEditorialCarousel.tsx` (Lines 77-145) - Uses `Swiper` with standard slides-per-view breakpoints (slidesPerView: 1, 2, or 3). - Uses theme token-mapped utility classes: `bg-pop-bg` (resolved via `bgMap`), `text-pop-black` (resolved via `textMap`). - **Website Renderer**: `apps/website/src/components/dynamic/DynamicPageRenderer.tsx` (Lines 415-481) - Uses a horizontal flex scroll container with `scrollContainerRef`. - Buttons call `scrollBy(400)` to scroll horizontally. - Render card style uses `.ui-card` with `w-[85vw] md:w-[350px]`. --- ## 2. Logic Chain 1. **Schema Extension (Types)**: To allow the layout properties (`layout` and `imagePosition`) to be saved, we must add them to `EditorialCarouselSectionPayloadSchema` in `packages/types/index.ts`. They must be `.optional()` so that existing sections in the database remain valid. 2. **Backend Validation Validation & Tool Exposure**: Since `pages.ts` validates payload records using `safeParse` against the types schema, extending the schema will automatically allow the new parameters to pass backend validation. In the MCP server, we must also update the properties structure of `update_editorial_carousel` in `apps/mcp-server/section-toolkit.ts` to expose `layout` and `imagePosition` to AI agents. 3. **Form Fields (Admin)**: To enable administrative control over these layout configurations, we can add two select fields (`Layout` and `Image Position`) to the styling settings section in `EditorialCarouselForm.tsx` using the same merging style as `backgroundColor`. To enhance UX, the `Image Position` field can be disabled unless `layout === 'split_screen'`. 4. **Renderer Behavior (SPA & Website)**: - For `layout === 'split_screen'`, we render exactly 1 slide at a time. - For the SPA component, Swiper configuration must adapt (`slidesPerView={1}` and no desktop breakpoints). - For the Next.js website component, slide width must stretch to 100% (`w-full`), and `scrollBy` must scroll by the full width of the container `scrollContainerRef.current.clientWidth`. - On desktop, CSS Grid will split the slide `35%` text / `65%` image. CSS `order` property will arrange them so that `imagePosition: 'right'` renders text-left/image-right, and `imagePosition: 'left'` renders image-left/text-right. - On mobile, CSS `order` will force the image to stack on top and text on the bottom. --- ## 3. Caveats - **Existing Data Migrations**: Since no database migrations are needed (fields are optional and schema validates them safely as optional), existing databases do not need prisma migrations. - **Swiper Navigation Controls**: In `split_screen` layout, standard Swiper arrows overlap the image/text on the far edges. Swiper handles pagination bullet styling, which has been configured to match `bg-pop-pink`. --- ## 4. Conclusion The layout configuration changes are backward-compatible and require updates to: 1. `packages/types/index.ts` 2. `apps/mcp-server/section-toolkit.ts` 3. `apps/admin/src/components/page-builder/EditorialCarouselForm.tsx` 4. `apps/spa/src/components/DynamicEditorialCarousel.tsx` 5. `apps/website/src/components/dynamic/DynamicPageRenderer.tsx` ### Proposed Code Diffs #### 1. packages/types/index.ts ```diff export const EditorialCarouselSectionPayloadSchema = z.object({ title: LocalizedStringSchema, backgroundColor: z.string().optional(), textColor: z.string().optional(), + layout: z.enum(['standard', 'split_screen']).optional(), + imagePosition: z.enum(['left', 'right']).optional(), slides: z.array(z.object({ category: LocalizedStringSchema, title: LocalizedStringSchema, ``` #### 2. apps/mcp-server/section-toolkit.ts ```diff { name: "update_editorial_carousel", sectionType: "EDITORIAL_CAROUSEL", - description: `Update an EDITORIAL_CAROUSEL section. This tool performs a partial update/merge (PATCH). Omitted fields are preserved. The 'sectionId' argument correlates to the 'id' field in the fetched payload. title, description, and slides are supported. Example payload: { "sectionId": 12, "title": ${localizedStringExample}, "description": ${localizedStringExample} }`, + description: `Update an EDITORIAL_CAROUSEL section. This tool performs a partial update/merge (PATCH). Omitted fields are preserved. The 'sectionId' argument correlates to the 'id' field in the fetched payload. title, description, layout, imagePosition, and slides are supported. Example payload: { "sectionId": 12, "title": ${localizedStringExample}, "layout": "split_screen", "imagePosition": "right" }`, properties: { sectionId: { type: "number", description: "The numeric ID of the section to update" }, title: { ...localizedStringSchema, description: `Localized section title, for example ${localizedStringExample}` }, description: { ...localizedStringSchema, description: `Localized section description, for example ${localizedStringExample}` }, backgroundColor: { type: "string", description: "Plain string background color in hex or rgba" }, textColor: { type: "string", description: "Plain string text color in hex or rgba" }, + layout: { type: "string", enum: ["standard", "split_screen"], description: "Layout style of the carousel (standard or split_screen)" }, + imagePosition: { type: "string", enum: ["left", "right"], description: "Position of the image relative to text when layout is split_screen (left or right)" }, slides: { ``` #### 3. apps/admin/src/components/page-builder/EditorialCarouselForm.tsx ```diff
...
...
+
+
+ + +
+
+ + +
+
+
``` #### 4. apps/spa/src/components/DynamicEditorialCarousel.tsx ```diff export default function DynamicEditorialCarousel({ payload }: DynamicEditorialCarouselProps) { const { i18n } = useTranslation(); const lang = i18n.language; + const layout = payload.layout || 'standard'; + const imagePosition = payload.imagePosition || 'right'; + const isSplitScreen = layout === 'split_screen'; + const title = getLocalizedField(payload.title, lang); const slides = (payload.slides || []).map((slide) => ({ ...slide, @@ -86,13 +90,13 @@ export default function DynamicEditorialCarousel({ payload }: DynamicEditorialCa
{slides.map((slide, idx) => ( -
-
- {slide.title} { - (e.target as HTMLImageElement).style.display = 'none'; - }} - /> -
- -
-
- - {slide.category} - -

- {renderTitle(slide.title) || slide.title} -

-

- {slide.description} -

-
- - {slide.buttonText && slide.buttonLink && ( -
- - {slide.buttonText} - → - -
- )} -
-
+ {isSplitScreen ? ( +
+ {/* Text Column */} +
+ + {slide.category} + +

+ {renderTitle(slide.title) || slide.title} +

+

+ {slide.description} +

+ {slide.buttonText && slide.buttonLink && ( +
+ + {slide.buttonText} + → + +
+ )} +
+ {/* Image Column */} +
+ {slide.title} { + (e.target as HTMLImageElement).style.display = 'none'; + }} + /> +
+
+ ) : ( +
+
+ {slide.title} { + (e.target as HTMLImageElement).style.display = 'none'; + }} + /> +
+
+
+ + {slide.category} + +

+ {renderTitle(slide.title) || slide.title} +

+

+ {slide.description} +

+
+ {slide.buttonText && slide.buttonLink && ( +
+ + {slide.buttonText} + → + +
+ )} +
+
+ )}
))} ``` #### 5. apps/website/src/components/dynamic/DynamicPageRenderer.tsx ```diff function EditorialCarouselSection({ payload, lang }: { payload: Record, lang: string }) { const i18nLang = useLang() const resolvedLang = lang === 'all' ? i18nLang : lang const title = localizedFromRecord(payload, 'title', resolvedLang) const slides = Array.isArray(payload.slides) ? payload.slides.map(toRecord) : [] const backgroundColor = typeof payload.backgroundColor === 'string' ? payload.backgroundColor : undefined const textColor = typeof payload.textColor === 'string' ? payload.textColor : undefined const bgClass = backgroundColor ? `bg-pop-${backgroundColor}` : 'bg-surface-page' const textClass = textColor ? `text-pop-${textColor}` : 'text-text-main' + const layout = typeof payload.layout === 'string' ? payload.layout : 'standard' + const imagePosition = typeof payload.imagePosition === 'string' ? payload.imagePosition : 'right' + const isSplitScreen = layout === 'split_screen' + const scrollContainerRef = useRef(null) const scrollBy = (offset: number) => { if (scrollContainerRef.current) { - scrollContainerRef.current.scrollBy({ left: offset, behavior: 'smooth' }) + if (isSplitScreen) { + const width = scrollContainerRef.current.clientWidth + scrollContainerRef.current.scrollBy({ left: offset > 0 ? width : -width, behavior: 'smooth' }) + } else { + scrollContainerRef.current.scrollBy({ left: offset, behavior: 'smooth' }) + } } } if (!slides.length) return null @@ -432,38 +441,755 {slides.map((slide, index) => { const slideTitle = localizedFromRecord(slide, 'title', resolvedLang) const description = localizedFromRecord(slide, 'description', resolvedLang) const category = localizedFromRecord(slide, 'category', resolvedLang) const buttonText = localizedFromRecord(slide, 'buttonText', resolvedLang) const buttonLink = typeof slide.buttonLink === 'string' ? slide.buttonLink : '' const imageUrl = typeof slide.imageUrl === 'string' ? slide.imageUrl : '' - return ( -
- {imageUrl ? ( -
- {slideTitle -
- ) : null} -
-
- {category && {category}} - {slideTitle &&

{renderAccentTitle(slideTitle)}

} - {description &&

{description}

} -
- {buttonText && buttonLink && ( -
- - {buttonText} → - -
- )} -
-
- ) + return isSplitScreen ? ( +
+ {/* Text Column */} +
+ {category && {category}} + {slideTitle &&

{renderAccentTitle(slideTitle)}

} + {description &&

{description}

} + {buttonText && buttonLink && ( +
+ + {buttonText} → + +
+ )} +
+ {/* Image Column */} + {imageUrl ? ( +
+ {slideTitle +
+ ) : null} +
+ ) : ( +
+ {imageUrl ? ( +
+ {slideTitle +
+ ) : null} +
+
+ {category && {category}} + {slideTitle &&

{renderAccentTitle(slideTitle)}

} + {description &&

{description}

} +
+ {buttonText && buttonLink && ( +
+ + {buttonText} → + +
+ )} +
+
+ ) })}
``` --- ## 5. Verification Method To verify these changes after implementation: ### 5.1 Compilation and Type Check Run the build command for the monorepo from the root directory to ensure type safety: ```bash npm run build ``` ### 5.2 Dynamic Form Verification 1. Access the Admin Panel and navigate to the Page Builder. 2. Select or create an `Editorial Carousel` section. 3. Verify that the `Layout` option displays `Standard Carousel` and `Split Screen`. 4. Select `Split Screen` and confirm that `Image Position` is enabled (it should be disabled when `Standard Carousel` is chosen). 5. Switch `Image Position` between `Left` and `Right` and save the page. ### 5.3 Frontend Verification (Desktop and Mobile) 1. Verify the layout renders correctly on desktop: - For `split_screen`, check that exactly 1 slide occupies the width. - Text takes up `35%` of the width, and the Image takes up `65%`. - When `imagePosition` is `right`, text is on the left, image on the right. - When `imagePosition` is `left`, image is on the left, text on the right. 2. Verify layout on mobile: - Viewport width < 768px should stack the image on top and the text on the bottom for both left and right positions. 3. Test compatibility by validating that sections with standard layouts remain untouched (3 columns on desktop, 1.5/2 cards on tablet/mobile).