mono/packages/ui/docs/layoutcontainer-ex.md
2026-03-21 20:18:25 +01:00

831 lines
36 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 (112). 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 `<div className={gridClasses}>`. 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<string, any>;
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 (
<div
className={cn(
"grid min-w-0", // Tailwind: grid layout
"gap-4", // Tailwind: gap (or from row.gap)
row.sizing === 'unconstrained'
? "items-start" // Tailwind: align-items: start
: "items-stretch", // Tailwind: align-items: stretch
// Responsive: collapse to single column on mobile
"max-md:!grid-cols-1"
)}
style={{
gridTemplateColumns, // Inline: arbitrary widths
gap: row.gap !== undefined ? `${row.gap}px` : undefined,
}}
>
{children}
</div>
);
};
```
**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 }) => (
<div
className={cn(
"flex flex-col min-w-0 overflow-hidden", // Flex column for vertical widget stack
"gap-2", // Gap between stacked widgets
alignment === 'center' && "self-center",
alignment === 'end' && "self-end",
alignment === 'start' && "self-start",
// Default: self-stretch (from parent grid's items-stretch)
)}
>
{children}
</div>
);
```
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 (
<div className="space-y-0">
{container.rows.map((row, rowIndex) => {
// Build grid-template-columns
const gtc = row.columns.map(c => `${c.width}${c.unit}`).join(' ');
return (
<div key={row.id}>
{/* Row header bar (edit mode only) */}
{isEditMode && (
<RowHeaderBar row={row} rowIndex={rowIndex} />
)}
{/* Row grid */}
<div
className={cn(
"grid min-w-0",
row.sizing === 'unconstrained' ? "items-start" : "items-stretch",
// Mobile: force single column
"max-md:!grid-cols-1",
)}
style={{ gridTemplateColumns: gtc, gap: `${row.gap ?? container.gap}px` }}
>
{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 (
<div
key={`${row.id}-${colIndex}`}
className={cn(
"flex flex-col min-w-0 overflow-hidden gap-2",
cellAlign === 'start' && "self-start",
cellAlign === 'center' && "self-center",
cellAlign === 'end' && "self-end",
// edit mode: empty cell placeholder
isEditMode && cellWidgets.length === 0 && "min-h-[80px] border border-dashed border-slate-300",
)}
style={{ minWidth: col.minWidth ? `${col.minWidth}px` : undefined }}
>
{cellWidgets.map(widget => (
<WidgetItem key={widget.id} widget={widget} /* ...props */ />
))}
</div>
);
})}
{/* Drag handles between columns (edit mode only) */}
{isEditMode && row.columns.length > 1 && (
<ColumnDragHandles row={row} onResize={handleColumnResize} />
)}
</div>
</div>
);
})}
{/* Add Row button */}
{isEditMode && (
<button className="w-full py-2 text-sm text-blue-500 hover:bg-blue-50 dark:hover:bg-blue-900/20">
+ Add Row
</button>
)}
</div>
);
};
```
### 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 (
<div className="absolute inset-0 pointer-events-none">
{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 (
<div
key={i}
className="absolute top-0 bottom-0 w-1 cursor-col-resize pointer-events-auto
hover:bg-blue-400 active:bg-blue-500 transition-colors z-10"
style={{ left: `${leftPercent}%`, transform: 'translateX(-50%)' }}
onMouseDown={(e) => handleDragStart(e, row.id, i)}
/>
);
})}
</div>
);
};
```
> [!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
<table role="presentation" width="100%">
<tr>
<td>
<!--[if mso]><table width="100%"><tr><td width="50%" valign="top"><![endif]-->
<div class="image-col-item image-col-item-2">
<!-- widget 0 content -->
</div>
<!--[if mso]></td><td width="50%" valign="top"><![endif]-->
<div class="image-col-item image-col-item-2">
<!-- widget 1 content -->
</div>
<!--[if mso]></td></tr></table><![endif]-->
</td>
</tr>
</table>
```
**Pattern:** Outer `<table>` wrapper `<div>` elements with `inline-block` styling (via CSS) for modern clients + MSO conditional `<table>` for Outlook. Fixed `width="50%"` / `width="33%"` **no arbitrary widths**.
### What Email Clients Support
| Feature | Gmail | Outlook (MSO) | Apple Mail | Yahoo |
|---------|-------|---------------|------------|-------|
| `<table>` layout | | | | |
| `display: inline-block` | | (needs MSO table) | | |
| `display: grid` | | | | |
| `display: flex` | | | Partial | |
| Arbitrary `width` on `<td>` | (%) | (%) | (%) | (%) |
| `fr` units | | | | |
| `px` widths on `<td>` | | | | |
| `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 `<table>` 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 `<table>` with `<td>` elements at computed percentages:
```typescript
function renderRowToEmailHtml(row: RowDef, cellContents: string[]): string {
const widths = columnsToPercentages(row.columns);
// MSO conditional table for Outlook
const msoStart = `<!--[if mso]><table role="presentation" width="100%"><tr>` +
widths.map((w, i) => `${i > 0 ? '' : ''}`).join('') +
`<![endif]-->`;
let html = `<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td>`;
html += `<!--[if mso]><table role="presentation" width="100%"><tr><![endif]-->`;
widths.forEach((w, i) => {
if (i > 0) {
html += `<!--[if mso]></td><![endif]-->`;
}
html += `<!--[if mso]><td width="${w}%" valign="top"><![endif]-->`;
html += `<div style="display:inline-block; vertical-align:top; width:100%; max-width:${w}%;">`;
html += cellContents[i] || '';
html += `</div>`;
});
html += `<!--[if mso]></td></tr></table><![endif]-->`;
html += `</td></tr></table>`;
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 `<td valign>`. 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 `<td>` 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 `<table><tr><td width="X%">` 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.