36 KiB
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)
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 <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
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:
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
1frto the row'scolumnWidths. - 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
columnWidthsentries 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.5fror50px— 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:
columnWidthsarray is updated, triggersResizeRowColumnsCommand(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:
.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:
.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:
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
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-Nmaps directly togrid-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-3collapses togrid-cols-1on 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).
// 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:
// 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
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):
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
leftpercentage calculation above is simplified for all-frrows. Mixedfr/pxrows need the browser's computed column positions viagetComputedStyle()orgetBoundingClientRect()on the cell elements.
Responsive Collapse Pattern
/* 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:
- Load HTML templates —
image_xl.html,image_col_2.html,image_col_3.html,section_text.html, etc. - Walk containers —
renderContainer()iterates widgets sorted by order. - Group by columns — If
container.columns === 2or3, widgets are chunked and rendered via the matching column template. Everything else → single-column (image_xl.html). - Inline CSS —
juiceinlines all CSS for email client compatibility.
Current Column Templates
The 2-column template (image_col_2.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:
// 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:
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':
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:
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:
- Convert
ColumnDef[]widths to%values - Generate
<table><tr><td width="X%">dynamically per row - MSO conditional comments for Outlook compatibility
valignfor vertical alignment- Reuse existing
renderWidget()for cell content - ~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:
- Add discriminated union on
type: 'container' | 'row-layout'in the data model. - Build
RowLayoutContainer.tsxas a new renderer alongside the existing one. - Fork in
GenericCanvas.tsxbased on container type. - New commands — keep all existing commands untouched.
- Add "Row Layout" to Widget Palette as a new container type option.
- ContainerSettingsManager gets a second mode for row-layout configuration.
What we DON'T have to change (back-compat)
- Existing
LayoutContainertype, 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
columnWidthsaccidentally 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
- Column width units — pixels, percentages, or CSS
frunits?fris most flexible for responsive design (grid-template-columns: 1fr 2fr 1fr). - Max rows per container? — Unbounded or capped?
- Should rows be re-orderable? — Drag-to-reorder rows, or just add/remove?
- Mobile collapse — Do multi-column rows stack to 1-column on mobile (like current), or allow per-row mobile config?
- Can a row-layout contain nested containers? — Probably yes (in a cell), but adds complexity.
- 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.