# LayoutContainer-Ex: Rows with Adjustable Columns ## Investigation Summary The goal is to support a **row-based layout** where each row can have independently adjustable column widths (e.g. 30/70, 50/50, 33/33/34). This document analyses the current architecture, identifies every surface that would need changes, and assesses a "build new + keep old" strategy. --- ## Current Architecture ### Data Model (`LayoutManager.ts`) ```typescript interface LayoutContainer { id: string; type: 'container'; columns: number; // ← single integer, drives CSS grid-cols-N gap: number; // ← pixel gap between cells widgets: WidgetInstance[]; children: LayoutContainer[]; order?: number; settings?: { collapsible?: boolean; collapsed?: boolean; title?: string; showTitle?: boolean; customClassName?: string; enabled?: boolean; }; } ``` **Key observations:** | Aspect | Current behaviour | |--------|-------------------| | **Column model** | `columns: number` — equal-width columns only (1–12). Rendered via Tailwind `grid-cols-N` classes. | | **Widget placement** | Widgets have an `order` index. The CSS grid auto-flows them left-to-right, top-to-bottom. There is no "widget belongs to column X" field. | | **Column targeting** | `addWidgetToContainer()` accepts `targetColumn?: number` but only uses it to calculate an insertion _index_ — it is a positional hint derived from `index % columns`, not a persistent assignment. | | **Row concept** | None. Rows are an implicit side-effect of CSS grid wrapping. | | **Nesting** | Containers can nest up to 3 levels deep. Nested containers span `col-span-full`. | | **Container children** | `children: LayoutContainer[]` — nested containers always appear _after_ all widgets. | ### Rendering (`LayoutContainer.tsx`) The component builds grid classes via `getGridClasses(columns)` → `grid-cols-1 md:grid-cols-N`. All widgets and children are rendered inside one `
`. There is no per-row logic. ### Command System (`commands.ts`, `LayoutContext.tsx`) 15+ command classes (Add/Remove/Move Widget, Add/Remove/Move Container, Update Columns, Update Settings, etc.) all operate on the flat `widgets[]` array and the `containers[]` / `children[]` tree. All are undo/redo-capable via `HistoryManager`. ### Settings UI (`ContainerSettingsManager.tsx`) Only supports: title, showTitle, collapsible, collapsed. No column-width controls. --- ## What "Rows with Adjustable Columns" Means A new `RowLayoutContainer` (or equivalent) would treat layout as: ``` Container └─ Row 0: [Col 40%] [Col 60%] ← 2 columns, custom widths └─ Row 1: [Col 33%] [Col 33%] [Col 34%] ← 3 columns └─ Row 2: [Col 100%] ← full width ``` Each row is an independent unit with its own column count and width distribution. Widgets are placed in a specific **row + column** cell, not just by order. ### Proposed Data Model ```typescript interface RowDef { id: string; columnWidths: number[]; // e.g. [40, 60] or [1, 2, 1] (fr units) gap?: number; // row-level gap override } interface RowLayoutContainer { id: string; type: 'row-layout'; // discriminator vs 'container' rows: RowDef[]; widgets: WidgetInstance[]; // each widget needs row + column assignment gap: number; // vertical gap between rows order?: number; settings?: { /* same as current + potential new fields */ }; } ``` Each `WidgetInstance` would need extended placement: ```typescript interface WidgetInstance { id: string; widgetId: string; props?: Record; order?: number; // NEW — only for row-layout containers: rowId?: string; column?: number; } ``` --- ## UI Manipulation: Rows & Columns ### Row Bar (edit mode only) Each row gets a thin header bar (like the existing container header), visible only in edit mode: ``` ┌─ Row 1 ──────────────────────── [+ Col] [Split] [↑] [↓] [×] ─┐ │ [ Widget A ] │ [ Widget B ] │ [ Widget C ] │ └───────────────────────────────────────────────────────────────-┘ ┌─ Row 2 ──────────────────────── [+ Col] [Split] [↑] [↓] [×] ─┐ │ [ Widget D ] │ [ Widget E ] │ └───────────────────────────────────────────────────────────────-┘ [ + Add Row ] ``` **Row-level actions:** | Button | Action | |--------|--------| | **+ Col** | Appends a new equal-width column to this row. E.g. `[1fr, 1fr]` → `[1fr, 1fr, 1fr]` | | **Split** | Splits the last column into two. E.g. `[2fr, 1fr]` → `[2fr, 0.5fr, 0.5fr]` | | **↑ / ↓** | Move row up / down within the container | | **×** | Remove row (widgets in it get orphaned → prompt: delete widgets or move to adjacent row?) | | **+ Add Row** | Appears below the last row. Creates a new single-column row at the bottom. | ### Adding / Removing Columns per Row Columns exist per-row, not per-container. Each row stores its own `columnWidths: number[]`. **Adding a column:** - Click **+ Col** on the row bar → appends `1fr` to the row's `columnWidths`. - Or right-click a column divider → "Insert column left / right". - New column starts empty. **Removing a column:** - Right-click a column or its divider → "Remove column". - If the column contains widgets: prompt to move them to the adjacent column (left preference) or delete them. - If it's the last column: row becomes single-column, not removed. - Removing the last column of the last row does NOT auto-remove the row (explicit delete required). **Merging columns:** - Select two adjacent columns (shift-click column headers?) → "Merge" action. - Merges `columnWidths` entries by summing them: `[1fr, 2fr, 1fr]` → merge cols 1+2 → `[3fr, 1fr]`. - Widgets from both columns are stacked vertically in the merged column in their original order. ### Column Width Adjustment **Drag handles** between columns: ``` │ Col 1 (2fr) ┃↔┃ Col 2 (1fr) │ Col 3 (1fr) │ ↑ drag handle ``` - A vertical drag handle between each pair of adjacent columns. - Dragging redistributes width between the two neighbors only (the rest stay fixed). - Minimum column width: `0.5fr` or `50px` — prevents collapse to zero. - The handle shows a subtle resize cursor on hover. - During drag: live preview with a ghost overlay showing the new widths. - On release: `columnWidths` array is updated, triggers `ResizeRowColumnsCommand` (undoable). **Presets** (accessible from row bar dropdown or right-click): | Preset | `columnWidths` | |--------|---------------| | Equal halves | `[1, 1]` | | Equal thirds | `[1, 1, 1]` | | Sidebar left | `[1, 3]` | | Sidebar right | `[3, 1]` | | Golden ratio | `[1.618, 1]` | | Wide center | `[1, 2, 1]` | | Custom... | Opens a text input for arbitrary `fr` values | ### Row Reordering - **↑ / ↓ buttons** on the row bar for keyboard-friendly reordering. - Potential: **drag-to-reorder** via a grip handle on the row bar's left edge (lower priority, buttons first). --- ## Sizing Modes Each column cell can operate in one of two sizing modes. This controls the **height behaviour** of the cell and therefore the row. ### Constrained Mode (default) ``` columnWidths: [1, 2, 1] sizing: 'constrained' // or simply: no override ``` - The row's height is dictated by the **tallest cell** in the row (CSS grid default `align-items: stretch`). - Each cell stretches to match the row height. - Widgets inside a cell fill available space or scroll if they overflow. - **Use case:** Traditional grid layouts, dashboards, side-by-side cards of equal height. CSS implementation: ```css .row { display: grid; grid-template-columns: 1fr 2fr 1fr; /* from columnWidths */ align-items: stretch; /* all cells same height */ } ``` ### Unconstrained Mode (content-driven) ``` sizing: 'unconstrained' ``` - Each cell's height is determined by its own content (**intrinsic sizing**). - Cells in the same row can have different heights — the row shrinks to fit. - The row height equals the tallest cell, but shorter cells do **not** stretch (they align to top). - **Use case:** Content blocks where one column has a short heading and the other has a long article — you don't want the short column to have a huge empty gap. CSS implementation: ```css .row { display: grid; grid-template-columns: 1fr 2fr 1fr; align-items: start; /* cells don't stretch */ } ``` ### Per-Cell Override (stretch / start / center / end) For finer control, each cell could also have its own vertical alignment: | Value | Behaviour | |-------|-----------| | `stretch` | Cell fills row height (default in constrained mode) | | `start` | Cell sticks to top, height = content | | `center` | Cell vertically centered within row | | `end` | Cell sticks to bottom | This maps directly to CSS `align-self` on the grid cell. ### Width Sizing: `fr` vs Fixed Column widths can mix `fr` (fractional) and fixed (px / rem) units: ```typescript interface ColumnDef { width: number; unit: 'fr' | 'px' | 'rem' | '%'; } ``` | Scenario | `columnWidths` | CSS | |----------|---------------|-----| | All flexible | `[1fr, 2fr, 1fr]` | `grid-template-columns: 1fr 2fr 1fr` | | Fixed sidebar | `[250px, 1fr]` | `grid-template-columns: 250px 1fr` | | Mixed | `[200px, 1fr, 300px]` | `grid-template-columns: 200px 1fr 300px` | Fixed columns **don't resize** on drag — the drag handle only redistributes the `fr`-based columns. Fixed columns can be resized via the settings panel or by double-clicking the drag handle to convert to `fr`. ### Mobile Responsive Collapse All sizing modes collapse to `grid-template-columns: 1fr` on mobile (`< md` breakpoint), matching the existing container behaviour. Per-row mobile overrides (e.g. "keep 2 columns on tablet") are a possible future extension but not required for v1. ### Data Model Update for Sizing ```typescript interface RowDef { id: string; columns: ColumnDef[]; // replaces simple columnWidths: number[] gap?: number; sizing?: 'constrained' | 'unconstrained'; // row-level default cellAlignments?: ('stretch' | 'start' | 'center' | 'end')[]; // per-cell override } interface ColumnDef { width: number; unit: 'fr' | 'px' | 'rem' | '%'; minWidth?: number; // collapse protection, in px } ``` ### Settings UI for Sizing In the row settings (accessible from the row bar ⚙ or the ContainerSettingsManager): - **Row sizing mode** toggle: Constrained / Unconstrained - **Per-cell alignment** dropdown (only visible in unconstrained mode) - **Column width type** per column: `fr` / `px` / `%` with numeric input - **Column min-width** (optional, safety net) --- ## CSS Implementation: Grid vs Flexbox ### Why the Current Container Uses Tailwind Grid The existing `LayoutContainer` builds classes like `grid grid-cols-1 md:grid-cols-3 gap-4`. This works because: - **Equal-width columns only** → Tailwind's `grid-cols-N` maps directly to `grid-template-columns: repeat(N, minmax(0, 1fr))`. - **Auto-flow** → Widgets land in the next available cell. No explicit row/column assignment needed. - **Responsive** → `md:grid-cols-3` collapses to `grid-cols-1` on mobile for free. But Tailwind grid classes **cannot** express arbitrary column widths like `1fr 2fr 1fr` or `250px 1fr`. There's no `grid-cols-[1fr_2fr_1fr]` utility without JIT arbitrary values, and even then it gets messy with responsive variants. ### Row-Layout: Inline `style` for Column Widths For arbitrary column widths, we **must** use inline `style` on the row div. Tailwind remains useful for everything else (gap, alignment, responsive collapse, padding). ```tsx // Row renderer — one per RowDef const RowRenderer: React.FC<{ row: RowDef; children: React.ReactNode }> = ({ row, children }) => { // Build grid-template-columns from ColumnDef[] const gridTemplateColumns = row.columns .map(col => `${col.width}${col.unit}`) .join(' '); // e.g. "1fr 2fr 1fr" or "250px 1fr 300px" return (
{children}
); }; ``` **Key pattern:** Tailwind for behaviour/layout mode + inline `style` for dynamic values that can't be known at build time. ### Where Tailwind Still Works | Concern | Tailwind class | Notes | |---------|---------------|-------| | Grid mode | `grid` | Always grid, never flex for the row itself | | Gap | `gap-2`, `gap-4`, etc | For fixed gap values; use inline `style.gap` for custom px | | Alignment | `items-start`, `items-stretch`, `items-center`, `items-end` | Row-level vertical alignment | | Per-cell align | `self-start`, `self-center`, `self-end`, `self-stretch` | On the cell wrapper div | | Responsive collapse | `max-md:grid-cols-1` or `grid-cols-1 md:grid-cols-none` | Override inline columns on mobile | | Min heights | `min-h-[80px]` | Empty cell placeholders in edit mode | | Overflow | `min-w-0 overflow-hidden` | Prevent blowoff from wide content | | Edit mode borders | `border-2 border-blue-500` | Selection highlighting | ### Where Tailwind Does NOT Work (Must Use Inline Style) | Concern | Why | Solution | |---------|-----|----------| | `grid-template-columns` | Arbitrary `fr`/`px`/`%` combos unknown at build time | `style={{ gridTemplateColumns }}` | | Row gap (custom px) | Dynamic per-row value | `style={{ gap: '${row.gap}px' }}` | | Drag-resize preview | Changes every frame during drag | `style={{ gridTemplateColumns }}` updated via `useState` or ref | ### Why Flexbox is Worse for the Row Grid Flexbox _could_ theoretically work for column layout, but it has hard disadvantages for this use case: | Aspect | CSS Grid | Flexbox | |--------|----------|---------| | **Arbitrary column widths** | `grid-template-columns: 1fr 2fr 1fr` — one property | Must set `flex-basis` + `flex-grow` on each child. `flex: 2 1 0%` for 2fr. Fragile. | | **Equal-height cells** | `align-items: stretch` works by default | Works too (`align-items: stretch`), but only for single-row flex. Multi-line flex (`flex-wrap`) breaks this. | | **Cell alignment overrides** | `align-self: start` on cell | Works the same way ✓ | | **Gap** | `gap: 16px` — native grid gap | `gap` works in modern browsers, but older flex shims needed `margin` hacks | | **Mixed units** | `250px 1fr 300px` — trivial | Need calc: `flex: 0 0 250px` for fixed, `flex: 1 1 0%` for fr. No native mixing. | | **Content overflow** | `minmax(0, 1fr)` prevents blowout | `min-width: 0` on each child, plus `flex-shrink` tuning | | **Responsive collapse** | Override `grid-template-columns: 1fr` | Must flip `flex-direction: column` AND reset all flex-basis values | | **Drag-resize** | Update one `style.gridTemplateColumns` string | Must update N child styles simultaneously | **Verdict:** CSS Grid is clearly superior for the row-based approach. The only thing flex is better at is natural content-flow wrapping, which we don't want — we want explicit column control. ### Flexbox: Where It IS Useful Flex is still the right tool **inside cells**, not for the row grid itself: ```tsx // Cell wrapper — uses flex for vertical widget stacking within a cell const CellRenderer: React.FC<{ alignment?: string; children: React.ReactNode }> = ({ alignment, children }) => (
{children}
); ``` Each cell is a **flex column** that stacks multiple widgets vertically. The grid is only for the row itself. ### Full Row-Layout Renderer Skeleton ```tsx const RowLayoutRenderer: React.FC<{ container: RowLayoutContainer; isEditMode: boolean }> = ({ container, isEditMode, }) => { return (
{container.rows.map((row, rowIndex) => { // Build grid-template-columns const gtc = row.columns.map(c => `${c.width}${c.unit}`).join(' '); return (
{/* Row header bar (edit mode only) */} {isEditMode && ( )} {/* Row grid */}
{row.columns.map((col, colIndex) => { // Find widgets assigned to this row + column const cellWidgets = container.widgets .filter(w => w.rowId === row.id && w.column === colIndex) .sort((a, b) => (a.order ?? 0) - (b.order ?? 0)); const cellAlign = row.cellAlignments?.[colIndex]; return (
{cellWidgets.map(widget => ( ))}
); })} {/* Drag handles between columns (edit mode only) */} {isEditMode && row.columns.length > 1 && ( )}
); })} {/* Add Row button */} {isEditMode && ( )}
); }; ``` ### Drag Handle Implementation Notes The column drag handles sit **on top of** the grid as absolutely positioned elements, not as grid children (they'd break the column count): ```tsx const ColumnDragHandles: React.FC<{ row: RowDef; onResize: (rowId: string, colIndex: number, delta: number) => void }> = ({ row, onResize }) => { // Positioned absolutely over the row. One handle between each pair of columns. // Each handle is a thin vertical bar at the column boundary. return (
{row.columns.slice(0, -1).map((_, i) => { // Calculate left offset: sum of column widths up to (i+1) // For fr units, convert to percentages based on total fr const totalFr = row.columns.reduce((sum, c) => sum + (c.unit === 'fr' ? c.width : 0), 0); const leftFr = row.columns.slice(0, i + 1).reduce((sum, c) => sum + (c.unit === 'fr' ? c.width : 0), 0); const leftPercent = (leftFr / totalFr) * 100; return (
handleDragStart(e, row.id, i)} /> ); })}
); }; ``` > [!NOTE] > The `left` percentage calculation above is simplified for all-`fr` rows. Mixed `fr`/`px` rows need the browser's computed column positions via `getComputedStyle()` or `getBoundingClientRect()` on the cell elements. ### Responsive Collapse Pattern ```css /* Row grid: arbitrary columns on desktop, single column on mobile */ .row-grid { display: grid; /* grid-template-columns set via inline style */ } @media (max-width: 768px) { .row-grid { grid-template-columns: 1fr !important; /* Override inline style */ } } ``` In Tailwind, this is `max-md:!grid-cols-1` (with `!` for `!important` to override inline styles). This is the **one place** where `!important` is justified — inline styles normally win specificity, but responsive collapse must override them. --- ## Email Rendering Compatibility ### How Email Rendering Currently Works `pages-email.ts` renders page layouts to email HTML server-side. The pipeline: 1. **Load HTML templates** — `image_xl.html`, `image_col_2.html`, `image_col_3.html`, `section_text.html`, etc. 2. **Walk containers** — `renderContainer()` iterates widgets sorted by order. 3. **Group by columns** — If `container.columns === 2` or `3`, widgets are chunked and rendered via the matching column template. Everything else → single-column (`image_xl.html`). 4. **Inline CSS** — `juice` inlines all CSS for email client compatibility. ### Current Column Templates The 2-column template (`image_col_2.html`): ```html
``` **Pattern:** Outer `` wrapper → `
` elements with `inline-block` styling (via CSS) for modern clients + MSO conditional `
` for Outlook. Fixed `width="50%"` / `width="33%"` — **no arbitrary widths**. ### What Email Clients Support | Feature | Gmail | Outlook (MSO) | Apple Mail | Yahoo | |---------|-------|---------------|------------|-------| | `
` layout | ✅ | ✅ | ✅ | ✅ | | `display: inline-block` | ✅ | ❌ (needs MSO table) | ✅ | ✅ | | `display: grid` | ❌ | ❌ | ❌ | ❌ | | `display: flex` | ❌ | ❌ | Partial | ❌ | | Arbitrary `width` on `
` | ✅ (%) | ✅ (%) | ✅ (%) | ✅ (%) | | `fr` units | ❌ | ❌ | ❌ | ❌ | | `px` widths on `` | ✅ | ✅ | ✅ | ✅ | | `max-width` media queries | ✅ | ❌ | ✅ | ✅ | | `align-items`, `align-self` | ❌ | ❌ | ❌ | ❌ | > [!CAUTION] > **CSS Grid and Flexbox are NOT usable in email.** The row-layout's frontend CSS Grid rendering has zero overlap with email rendering. Email must use `` layout exclusively. ### Strategy: Dynamic Table Generation for Row-Layouts Instead of pre-built templates, row-layouts need **dynamically generated table HTML** on the server. The `fr` → `%` conversion is straightforward: ```typescript // Convert ColumnDef[] to percentage widths for email tables function columnsToPercentages(columns: ColumnDef[]): number[] { const totalFr = columns .filter(c => c.unit === 'fr') .reduce((sum, c) => sum + c.width, 0); // Fixed columns (px) get a fixed width, remaining space split by fr // For email simplicity: convert everything to percentages const total = columns.reduce((sum, c) => { if (c.unit === '%') return sum + c.width; if (c.unit === 'fr') return sum + (c.width / totalFr) * 100; // px: approximate against 600px email width return sum + (c.width / 600) * 100; }, 0); return columns.map(c => { if (c.unit === '%') return c.width; if (c.unit === 'fr') return (c.width / totalFr) * 100; return (c.width / 600) * 100; }).map(p => Math.round(p * 100) / 100); // round to 2dp } ``` ### Generating Row HTML Each row becomes a `
` with `
` elements at computed percentages: ```typescript function renderRowToEmailHtml(row: RowDef, cellContents: string[]): string { const widths = columnsToPercentages(row.columns); // MSO conditional table for Outlook const msoStart = ``; let html = `
`; html += ``; widths.forEach((w, i) => { if (i > 0) { html += ``; } html += ``; html += `
`; html += cellContents[i] || ''; html += `
`; }); html += ``; html += `
`; return html; } ``` ### What Changes in `pages-email.ts` The existing `renderContainer()` handles `type: 'container'`. A new `renderRowLayout()` function handles `type: 'row-layout'`: ```typescript async function renderRowLayout(container: RowLayoutContainer) { let html = ''; for (const row of container.rows) { // Get widgets for each cell const cellContents: string[] = []; for (let colIdx = 0; colIdx < row.columns.length; colIdx++) { const cellWidgets = container.widgets .filter(w => w.rowId === row.id && w.column === colIdx) .sort((a, b) => (a.order ?? 0) - (b.order ?? 0)); let cellHtml = ''; for (const widget of cellWidgets) { cellHtml += await renderWidget(widget); } cellContents.push(cellHtml); } html += renderRowToEmailHtml(row, cellContents); } return html; } ``` Then in the main rendering loop: ```typescript for (const container of sortedRootContainers) { if (container.type === 'row-layout') { contentHtml += await renderRowLayout(container); } else { contentHtml += await renderContainer(container); // existing path } } ``` ### Sizing Mode Mapping in Email | Frontend sizing | Email equivalent | Notes | |----------------|-----------------|-------| | **Constrained** (`items-stretch`) | `valign="top"` + equal row height | Email doesn't support equal-height columns natively. Closest: `valign="top"` (cells align to top, height varies). True equal-height requires background tricks or is simply accepted as a limitation. | | **Unconstrained** (`items-start`) | `valign="top"` | Natural in email — cells are always content-height. | | **Per-cell alignment** | `valign="top"` / `"middle"` / `"bottom"` | Maps directly to `
`. Works everywhere including MSO. | | **`fr` widths** | Converted to `%` | `[1fr, 2fr, 1fr]` → `[25%, 50%, 25%]` | | **`px` widths** | Converted to `%` against 600px | `250px` → `41.67%`. Or use fixed `width="250"` on `` and let remaining cells flex. | | **Mobile collapse** | `@media (max-width: 480px)` | Supported by Gmail, Apple Mail, Yahoo. NOT Outlook — Outlook always shows desktop layout. | ### Limitations & Tradeoffs | Issue | Severity | Mitigation | |-------|----------|-----------| | **No equal-height columns** in email | Low | Most content renders fine with `valign="top"`. Background-color tricks exist but add complexity. | | **Outlook ignores media queries** | Medium | Outlook always renders at desktop width. Acceptable — newsletter emails typically have a 600px fixed width anyway. | | **Mixed `px`/`fr` approximation** | Low | Converting to `%` is imperfect but good enough for email. The 600px assumption is standard for email templates. | | **Widget rendering fallback** | Low | Any widget not supported by `renderWidget()` already falls through silently. Row-layout just needs the column wrapper logic. | | **New templates NOT needed** | Nice | Unlike the current approach with pre-built `image_col_2.html` / `image_col_3.html`, the dynamic table generator handles any column count. The pre-built templates can remain for the old `LayoutContainer` path. | ### Summary ✅ **Yes, row-layouts can be rendered to email.** The approach is: 1. Convert `ColumnDef[]` widths to `%` values 2. Generate `
` dynamically per row 3. MSO conditional comments for Outlook compatibility 4. `valign` for vertical alignment 5. Reuse existing `renderWidget()` for cell content 6. ~80 lines of new code in `pages-email.ts` --- ## Impact Analysis ### 🟢 No-impact (if we keep old `LayoutContainer` as-is) | Component | Reason | |-----------|--------| | All existing saved page data | Old pages use `type: 'container'`, unaffected | | `LayoutContainerWidget.tsx` | Embeds `GenericCanvas`, which dispatches per container type | | Existing undo/redo commands for old containers | Untouched | ### 🟡 Needs extension (dual support) | Component | What changes | |-----------|-------------| | **`LayoutManager.ts`** | New `RowLayoutContainer` interface, new `type` discriminator. `UnifiedLayoutManager` needs: `addRowToContainer()`, `removeRow()`, `resizeRowColumns()`, `findContainerOrRowLayout()`. `addWidgetToContainer` needs a row-layout branch. | | **`GenericCanvas.tsx`** | Must render `RowLayoutContainer` alongside `LayoutContainer`. Fork rendering by `container.type`. | | **`PageLayout`** | `containers: (LayoutContainer \| RowLayoutContainer)[]` — union type. | | **`commands.ts`** | New commands: `AddRowCommand`, `RemoveRowCommand`, `ResizeRowColumnsCommand`, `MoveWidgetToRowCellCommand`. Existing `MoveWidgetCommand` needs row-awareness. | | **`LayoutContext.tsx`** | New context methods: `addRow`, `removeRow`, `resizeRowColumns`, `moveWidgetToCell`. Wire to new commands. | | **Widget Palette / Add Widget flow** | Must know if targeting a row-layout and prompt for row + column. | | **`ContainerSettingsManager.tsx`** | Row-layout variant needs row editor (add/remove rows, drag column dividers). | | **`ContainerPropertyPanel.tsx`** | Show row structure for selected row-layout container. | ### 🔴 High-complexity areas | Area | Complexity | Notes | |------|-----------|-------| | **Column width drag UI** | High | Need a drag-handle between columns per row, with live preview. Could use CSS `resize` or custom drag logic. | | **Widget move across rows** | Medium | `MoveWidgetCommand` currently moves by `order ± 1`. In row-layout, moving down/up means crossing row boundaries, moving left/right means changing column. Completely different semantics. | | **Export / Import** | Low-Medium | `exportPageLayout` / `importPageLayout` already serialise the full tree. A new `type` field will survive JSON round-trip, but import from old format needs migration. | | **AI Layout Wizard** | Low | `BatchAddContainersCommand` generates `LayoutContainer[]`. Would need an equivalent for row-layouts or teach the AI about the new format. | | **Clipboard (copy/paste)** | Medium | `SelectionContext` and clipboard logic need to handle the new type. | | **SSR / Email / PDF rendering** | Medium | These use `iterateWidgets()` from `@polymech/shared`. That utility would need row-layout traversal support. | --- ## Strategy: Build New, Keep Old **Recommended.** The cleanest path: 1. **Add discriminated union** on `type: 'container' | 'row-layout'` in the data model. 2. **Build `RowLayoutContainer.tsx`** as a new renderer alongside the existing one. 3. **Fork in `GenericCanvas.tsx`** based on container type. 4. **New commands** — keep all existing commands untouched. 5. **Add "Row Layout" to Widget Palette** as a new container type option. 6. **ContainerSettingsManager** gets a second mode for row-layout configuration. ### What we DON'T have to change (back-compat) - Existing `LayoutContainer` type, rendering, and commands remain untouched. - Existing page data (`type: 'container'`) loads and renders as before. - Old `getGridClasses()` stays as-is. ### What we MUST build (new) | # | Deliverable | Est. LoC | |---|-------------|----------| | 1 | `RowDef` / `RowLayoutContainer` types in `LayoutManager.ts` | ~30 | | 2 | `RowLayoutContainer.tsx` — rendering component | ~300 | | 3 | `GenericCanvas.tsx` — type fork for rendering | ~20 | | 4 | Row-specific commands (Add/Remove/Resize Row, Move Widget to Cell) | ~300 | | 5 | `LayoutContext.tsx` — new context methods + wiring | ~100 | | 6 | Row editor UI (column width dragger per row) | ~250 | | 7 | `ContainerSettingsManager` row-layout mode or separate component | ~150 | | 8 | `iterateWidgets` update in `@polymech/shared` | ~20 | | 9 | Widget Palette "Add Row Layout" option | ~20 | | **Total** | | **~1200** | --- ## Alternative: Extend Current Container Instead of a new type, we could add `columnWidths?: number[]` to existing `LayoutContainer` and make each row an implicit `LayoutContainer` child. Pros: less new code. Cons: - Pollutes existing model with optional fields that only matter for row mode. - Every existing command and renderer gains branches. - Risk of subtle bugs in existing pages if `columnWidths` accidentally gets set. - Harder to reason about; "container" means two very different things. **Verdict:** Not recommended. The discriminated union approach is safer and cleaner. --- ## Open Questions 1. **Column width units** — pixels, percentages, or CSS `fr` units? `fr` is most flexible for responsive design (`grid-template-columns: 1fr 2fr 1fr`). 2. **Max rows per container?** — Unbounded or capped? 3. **Should rows be re-orderable?** — Drag-to-reorder rows, or just add/remove? 4. **Mobile collapse** — Do multi-column rows stack to 1-column on mobile (like current), or allow per-row mobile config? 5. **Can a row-layout contain nested containers?** — Probably yes (in a cell), but adds complexity. 6. **Migration path** — Should there be a "Convert container → row-layout" action? --- ## Conclusion Building a new `RowLayoutContainer` alongside the existing `LayoutContainer` is the safest and cleanest approach. It avoids touching ~2000 lines of working container code + commands, while adding ~1200 lines of new purpose-built code. The main complexity is in the **column-width drag UI** and **widget movement semantics** across row/column cells. Everything else is mechanical wiring.