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

36 KiB
Raw Blame History

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 (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

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 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:

.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-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.
  • Responsivemd: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).

// 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 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

/* 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 templatesimage_xl.html, image_col_2.html, image_col_3.html, section_text.html, etc.
  2. Walk containersrenderContainer() 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 CSSjuice inlines 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 250px41.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.