` 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.
|
| |