Presentation Grid Architecture - Working Baseline
> Terminology note, 2026-11-02. This is a historical implementation baseline outside the governed authority map (PRR-048). "Principal" and principal_id below mean the User (the platform identity); principal_id is a legacy persisted identifier retained until a separately authorised migration (legacy identifiers). Current evidence is in implementation status.
Status: Generic Presentation layout implementation, focused-verified locally against a refreshed STAGING snapshot. The six-column page layout, layout persistence, drag/drop and resizing remain the outer Presentation Object contract. PO-internal content layout is derived from the effective outer span; protected STAGING release and browser acceptance remain release evidence.
Previous known-good commit: f349711 (Shared body grid and direct manipulation, 2026-10-22). The independent-content correction is the current focused-verified implementation pending protected release.
Recent improvements (post-75531aa):
- `92f97c7` - Clean drag-drop UX, remove visual indicators (2026-10-09)
- `c8f2e9c` - Stable resize, prevent grid reflow during drag (2026-10-09)
- `f349711` - Apply the fixed six-column body grid to all authenticated main-body Presentation surfaces and remove per-PO action buttons (2026-10-22)
Previous milestone commits:
- `2be9f72` - Render Dashboard summary cards in grid
- `b97fee8` - Restore Dashboard summary surfaces
- `45f10b6` - Restore generic presentation object surfaces
Overview
The generic Presentation renderer uses the fixed Idiosyncrasy outer body-layout mechanism: authenticated page-level Presentation Objects use a six-column grid, with drag-and-drop and resizing on each non-repeated Presentation Object. Each grid-level PO has a generic surface. Recursive content is rendered inside that surface through an independent content grid; the outer packing path is not applied to descendants.
Key working features:
- All generic Dashboard, capability and Object page-level surfaces use the same six-column outer grid with persisted spans restricted to 2, 3, 4 and 6; responsive spanning remains available at narrow viewports
- Each grid-level PO owns an independent content surface
- Compact/Regular/Expanded and one-, two- or three-column internal delivery are derived from outer span
- Drag-and-drop reordering through the top-right handle with live visual feedback
- Resizable surfaces through the bottom-right handle with persistent width and height preferences
- Layout persistence per user and Business
- Generic presentation renderer (no domain-specific renderers)
- Full inspection and Object identity tracking
Architecture Layers
1. Application Entry Point
Canonical route: GET /objects/{dashboard_object_id}
GET / resolves the Business's exact presentation.dashboard_ref and redirects to this canonical Object route. There is no separate /dashboard route.
File: app/Runtime/Presentation/TargetHtmlBoundary.php
Contract: 1. Require authenticated User (legacy identifier principal_id) and Business context 2. Load Business object and resolve presentation.dashboard_ref (exact revision) 3. Read Dashboard Presentation Object at resolved revision 4. Extract Dashboard's data-defined children[] PO references 5. Project children through generic projector 6. Apply persisted user layout preferences 7. Pack elements into six-column grid 8. Render through generic presentation.php template
Verified flow:
Business object
↓ presentation.dashboard_ref (exact revision)
Dashboard PO (id: 96000000-0000-4000-8000-000000000201, revision: 1)
↓ children[] (data-defined child POs)
[Capability and content POs selected by the Business presentation]
↓ authorized projection
RenderablePresentation.elements[]
↓ layout preferences + packing engine
Effective grid layout
↓ generic HTML renderer
six-column grid on page
2. Presentation Object Configuration
The working Dashboard uses four top-level summary surfaces, each a persisted Presentation Object with the following structure:
Dashboard Root PO
{
"id": "96000000-0000-4000-8000-000000000201",
"revision": 1,
"value": {
"definition_kind": "presentation",
"kind": "container",
"element_id": "dashboard",
"order": 0,
"children": [
{ "kind": "object_reference", "id": "96000000-0000-4000-8000-000000000237", "revision": 1 },
{ "kind": "object_reference", "id": "96000000-0000-4000-8000-000000000238", "revision": 1 },
{ "kind": "object_reference", "id": "96000000-0000-4000-8000-000000000239", "revision": 1 },
{ "kind": "object_reference", "id": "96000000-0000-4000-8000-000000000240", "revision": 1 }
]
}
}
Dashboard child POs (illustrative historical profile)
The following four-card examples describe the earlier demonstrator profile and remain useful as a shape example only. They do not limit the current generic profile, which may expose all eight foundational capability POs.
People Summary (ID: 96000000-0000-4000-8000-000000000237, revision 1)
{
"kind": "container",
"element_id": "people-summary",
"order": 0,
"layout": { "preferred_span": 3 },
"children": [
{ "kind": "object_reference", "id": "96000000-0000-4000-8000-000000000241", "revision": 1 }
]
}
Content: Text element displaying "People (6)"Marketing Summary (ID: 96000000-0000-4000-8000-000000000238, revision 1)
{
"kind": "container",
"element_id": "marketing-summary",
"order": 1,
"layout": { "preferred_span": 3 },
"children": [
{ "kind": "object_reference", "id": "96000000-0000-4000-8000-000000000242", "revision": 1 }
]
}
Content: Text element displaying "Marketing (2)"Sales Summary (ID: 96000000-0000-4000-8000-000000000239, revision 1)
{
"kind": "container",
"element_id": "sales-summary",
"order": 2,
"layout": { "preferred_span": 3 },
"children": [
{ "kind": "object_reference", "id": "96000000-0000-4000-8000-000000000243", "revision": 1 }
]
}
Content: Text element displaying "Sales (1 enquiry, 1 qualification)"Projects Summary (ID: 96000000-0000-4000-8000-000000000240, revision 1)
{
"kind": "container",
"element_id": "projects-summary",
"order": 3,
"layout": { "preferred_span": 3 },
"children": [
{ "kind": "object_reference", "id": "96000000-0000-4000-8000-000000000244", "revision": 1 }
]
}
Content: Text element displaying "Projects (3 projects, 3 milestones)"Configuration source: database/configuration/idy-008/spearmint-demonstrator-profile.json (lines 79–95 for Dashboard, lines 700–784 for summary POs)
3. Generic Projection and Rendering
Projection Pipeline — verified implementation
File: app/Runtime/Presentation/PersistedPresentationObjectProjector.php
Verified committed path:
Persisted PO references
↓ authorization check (AuthorizedObjectReader)
↓ recursive child resolution
↓ content binding resolution (logic, fields, literals)
↓ apply user layout preferences (PresentationPreferenceService)
↓ derive mode/content metadata from effective outer span
↓ six-column packing for the current page-surface list only (LayoutPackingEngine)
↓ RenderablePresentation.elements[]
Only page-surface elements receive outer effective_span, row and position metadata. Descendant POs retain identity, parent containment, content and independent content-column metadata, but do not receive the parent outer-grid coordinates.
Layout Packing Engine
File: app/Runtime/Presentation/LayoutPackingEngine.php
Contract:
- Grid width: 6 columns
- Valid element spans: 1, 2, 3, 4, or 6 columns
- New elements default to 1 column
- Packing strategy: Fill rows left-to-right, prefer user-preferred spans where they fit
- Never produce gaps or unused columns
- Compute effective span when preferred span cannot fit in remaining row space
- Applies only to the current page-surface list; it is not an internal content
layout engine
Example: Four cards with preferred_span=[3, 3, 3, 3]:
Row 1: Card A (span 3) | Card B (span 3) [6 columns filled]
Row 2: Card C (span 3) | Card D (span 3) [6 columns filled]
HTML Renderer
File: resources/views/target/presentation.php
Contract:
- Generic recursive renderer accepting `RenderablePresentation` objects
- No domain knowledge of Dashboard, Capability, People, Marketing, etc.
- CSS class application based on `kind`, `role`, and delivered `layout.delivery.effective_span`
- Size class mapping:
- `span: 2` → `size-33` (33% of grid, ~2 columns)
- `span: 3` → `size-50` (50% of grid, ~3 columns)
- `span: 4` → `size-66` (66% of grid, ~4 columns)
- `span: 6` → `size-100` (100% of grid, ~6 columns)
- Inline `style="min-height: Xpx"` applied from `layout.preferred_height` * 16px only when a PO or saved preference declares one (any integer of 1 or more; no system floor since 2026-10-28)
- Renders `data-presentation-object-id` and `data-presentation-object-revision` for inspection
4. Layout Persistence
File: app/Runtime/Presentation/PresentationPreferenceService.php
Storage: User Presentation Preference Object (generic Object model)
Contract:
- Keyed by (principal_id, business_id, presentation_context_reference)
- Sparse: only stores non-default preferences
- Persisted properties per summary PO:
- `preferred_span` (2|3|4|6) — user's chosen width
- `preferred_order` (integer) — user's chosen position
- `preferred_height` (integer units, 16px per unit) — user's chosen height
- Applied post-projection, before packing
- Existing Presentation Objects never mutated
The revision-2 generic preference model persists only outer order, span and height. Presentation mode and internal content columns are derived delivery metadata and are not user-selectable or persisted separately.
Persistence endpoint: POST /presentations/preference/order
Verified operation: 1. The shared Edit layout control is activated → layout-editing class added 2. Drag the top-right handle or resize from the bottom-right handle → DOM updates immediately with visual feedback 3. The shared Save layout control is activated → AJAX POST to /presentations/preference/order 4. The server updates the user's persisted position and size preferences 5. The page remains in edit mode until save completes 6. Refresh page → layout preferences reapplied from saved state
5. Front-End Interaction
Drag and Drop
File: resources/js/dashboard-drag-drop.js and target/js/dashboard-drag-drop.js
Known-good commits:
- `92f97c7` - Clean drag-drop UX, remove border indicators (2026-10-09)
- `a9d3d58` - Drag n drop fix (2026-10-09)
- `36bac03` - Restore Dashboard layout controls
Interaction flow:
1. User clicks "Edit layout"
→ container.classList.add('layout-editing')
→ drag handles visible on all cards (⠿ icon)
2. User grabs drag handle and drags over another card
→ dragstart event fires
→ dragged item marked with 'dragging' class (50% opacity)
→ DOM reorders in real-time using insertBefore()
→ layout updates immediately with natural item flow
→ NO visual border/background indicators (cleaner UX)
3. As user continues dragging over other items
→ dragover event fires on each target
→ DOM continues to reorder (preview is live and accurate)
→ items shift smoothly in the grid
4. User releases mouse
→ dragend event fires
→ dragging class removed
→ DOM retains new order (preview was accurate—no surprises)
→ order not yet persisted to server
5. User clicks "Save layout"
→ AJAX POST captures current order, spans, heights
→ Server persists layout preference
→ UI confirms save
Visual feedback mechanism:
- **Dragged item:** `.dragging` class applies opacity 0.5 so user knows which card they're moving
- **Other items:** Naturally flow and shift position due to DOM reordering (no additional styling)
- **Grid layout:** CSS Grid automatically reflows items as DOM order changes
- **No artificial indicators:** Removed `.drop-target` and `.drop-zone-before` classes for cleaner interface
Design principle: The actual layout preview IS the visual feedback. No need for decorative indicators.
Resizing
File: resources/js/dashboard-drag-drop.js (pointerdown handler)
Known-good commits:
- `c8f2e9c` - Stable resize, prevent grid reflow during drag (2026-10-09)
- `92f97c7` - Clean drag-drop UX (2026-10-09)
Interaction flow:
1. User clicks and holds resize handle (bottom-right corner)
→ pointerdown event fires on resize-handle element
→ resizing state captured
→ container.classList.add('resizing') ← Prevent grid reflow
→ pointer capture engaged
2. User drags to resize
→ pointermove event fires
→ calculate new span based on horizontal movement
→ calculate new height based on vertical movement
→ preferred_height = max(1rem, measured height after drag) (no 192px floor)
→ apply new size class immediately (visual update)
→ OTHER ITEMS REMAIN STABLE (no jumping to other rows)
→ CSS rule: .dashboard-grid.resizing { grid-auto-flow: row; }
3. User releases mouse
→ pointerup event fires
→ container.classList.remove('resizing') ← Allow reflow
→ persist resized span and height to data attribute
→ save preference via AJAX
→ layout settles to final arrangement
Size snap points: [2, 3, 4, 6] columns — nearest snap point chosen during drag
Minimum height: none imposed by the system (changed 2026-10-28; previously 192px / 12 units). preferred_height is an optional floor of 1 rem or more declared by a PO or saved preference; content always grows the surface.
- Render time: inline `min-height` only when a preferred height is declared
- Resize (pointermove handler): clamps to 1 rem; layout saves omit `preferred_height` for surfaces that never declared one
- Applied as CSS base rule for fallback
- Prevents cards from rendering too small
Grid stability during resize:
- CSS Grid normally uses `grid-auto-flow: row dense` for optimal packing
- During resize, we switch to `grid-auto-flow: row` (strict row placement, no backfilling)
- Result: Items stay in their current rows while card width changes
- Once resize ends, standard layout resumes and grid may reflow to optimal arrangement
Container Layout and Grid System
Container Element
HTML class: presentation-elements dashboard-grid
CSS grid:
- `display: grid`
- `grid-template-columns: repeat(6, 1fr)` — 6 equal columns
- Responsive breakpoints managed via media queries
- Gap between elements: `var(--gap, 1rem)`
Item Elements
HTML class: presentation-item widget + size class
Size classes:
.size-33 { grid-column: span 2; } /* 2 of 6 columns = 33% */
.size-50 { grid-column: span 3; } /* 3 of 6 columns = 50% */
.size-66 { grid-column: span 4; } /* 4 of 6 columns = 66% */
.size-100 { grid-column: span 6; } /* 6 of 6 columns = 100% */
Minimum height: no base CSS floor (the former min-height: 192px rule was removed 2026-10-28); inline style="min-height: Xpx" is emitted only for a declared preferred_height
Handles:
- Drag handle: `.layout-drag-handle` (top-right, visible in edit mode)
- Resize handle: `.resize-handle` (bottom-right, visible in edit mode)
PO Surface and Internal Content
The outer .dashboard-grid (or equivalent page-surface mount) is the only container to which the six-column packing contract applies. Each grid-level PO must render its own generic surface, and its child content must be contained inside that surface without inheriting outer size-*, row or position metadata. Internal content is one column for Compact span 1, two columns for Regular span 2 and three columns for Expanded spans 3, 4 and 6. A narrow viewport may reduce the effective count, but must not rewrite the persisted outer preference or alter the persisted PO graph.
Architectural Invariants (Regression Guardrails)
These invariants must be preserved in all future development:
1. Do not replace the generic renderer. The recursive persisted Presentation Object path with generic presentation.php is the only authoritative rendering path. Domain-specific Dashboard, Capability, People, Marketing, Sales or Projects renderers must not be introduced.
2. Do not equate recursive PO references with visibility. A Presentation Object that is reachable through recursive relationships is not automatically rendered on the Dashboard. Only POs explicitly referenced in Dashboard.children[] are Dashboard surfaces.
3. Do not turn data-resolution constituents into surfaces. Content resolved from a field binding, logic evaluation, or collection enumeration does not automatically become an independent grid surface. Only elements with presentation_surface: true and presentation_object_id are rendered as separate cards.
4. Do not disable recursive resolution. The generic projector must continue resolving child POs recursively. If a rendering defect appears, fix the specific PO configuration or projection logic, not the recursion itself.
5. Do not delete collection or record POs. Collection and record Presentation Objects (used for People, Marketing, Sales, Projects list and detail pages) must remain available even if they don't appear on the Dashboard. Deleting them breaks list and detail navigation.
6. Do not modify the grid engine for PO configuration problems. If the grid layout looks wrong, the issue is in persisted PO configuration (preferred spans, order, binding), not in LayoutPackingEngine. Grid algorithm changes must have specific evidence of defects in packing logic.
7. Do not assume demonstrator profile updates apply to existing Businesses. The spearmint-demonstrator-profile.json is a provisioning template. Changing it does not update already-provisioned Businesses. Layout changes must be made through the governed extension/mutation mechanism or database update.
8. Preserve drag-and-drop and resizing. Layout interaction (drag, resize, persistence, visual feedback) must remain functional when changing presentation projection, PO configuration or rendering. Verify the actual staging UI after deployment; tests alone do not establish visual correctness.
9. Verify staged layout. After deployment, visit the resolved /objects/{dashboard_object_id} route in staging and confirm:
- Main-body page-level Presentation surfaces use the shared six-column grid
- Each grid-level PO has a separate content container whose effective internal layout is independent of its outer span
- Each non-repeated PO has only its separate top-right drag handle and bottom-right resize handle (visible in edit mode)
- Dragging updates card order in real-time
- Resizing updates card size in real-time
- Edit mode can be toggled on/off
- The shared "Save layout" control persists position and size changes
- Refresh page retains saved layout
10. Keep outer and internal layout independent. The outer packing engine may run only for page-surface POs. An outer span of two, three, four or six columns select only the derived Compact/Regular/Expanded delivery mode; nested content remains inside its parent surface and never enters the outer packing engine.
11. Preserve semantic failure distinctions. Do not fabricate missing Presentation Objects, definitions, targets or latest revisions. Focused verification must distinguish genuine empty authorized data from branches suppressed because exact resolution, authorization, definition validation or cycle checks failed.
Known-Good Configuration
Business Presentation Root
Business
↓ presentation.dashboard_ref
Dashboard PO (96000000-0000-4000-8000-000000000201 @ revision 1)
├── People Summary PO (96000000-0000-4000-8000-000000000237 @ revision 1)
│ └── Text: "People (6)"
├── Marketing Summary PO (96000000-0000-4000-8000-000000000238 @ revision 1)
│ └── Text: "Marketing (2)"
├── Sales Summary PO (96000000-0000-4000-8000-000000000239 @ revision 1)
│ └── Text: "Sales (1 enquiry, 1 qualification)"
└── Projects Summary PO (96000000-0000-4000-8000-000000000240 @ revision 1)
└── Text: "Projects (3 projects, 3 milestones)"
Navigation Layer (Separate)
Each summary container PO (People, Marketing, Sales, Projects) has a corresponding navigation entry:
People Container PO (96000000-0000-4000-8000-000000000202)
↓ navigation: { navigable: true, label: "People", icon: "people", order: 0 }
└── Children: People list and detail POs
Marketing Container PO (96000000-0000-4000-8000-000000000205)
↓ navigation: { navigable: true, label: "Marketing", icon: "marketing", order: 1 }
└── Children: Campaign list and detail POs
Sales Container PO (96000000-0000-4000-8000-000000000207)
↓ navigation: { navigable: true, label: "Sales", icon: "sales", order: 2 }
└── Children: Enquiry and qualification list POs
Projects Container PO (96000000-0000-4000-8000-000000000210)
↓ navigation: { navigable: true, label: "Projects", icon: "projects", order: 3 }
└── Children: Project and milestone list POs
Important: The Dashboard uses summary POs (IDs 237–240). Navigation uses container POs (IDs 202, 205, 207, 210). They are separate POs and must not be conflated.
Implementation Status
| Component | Status | Evidence | |-----------|--------|----------| | Generic Presentation Renderer | ✓ Verified | resources/views/target/presentation.php renders correctly; no domain knowledge | | Presentation Object Projection | ✓ Verified | PersistedPresentationObjectProjector.php resolves children and applies preferences | | Layout Packing Engine | ✓ Verified | LayoutPackingEngine.php packs current page-surface children into the six-column grid without gaps | | Layout Persistence | ✓ Verified | User preferences saved and reapplied; verified on refresh | | PO surface/internal content separation | ✓ Focused-verified | The projector packs only the page surface; recursive content uses presentation-content-grid without outer positions or spans | | Derived internal content layout | ✓ Focused-verified | Effective outer spans derive Compact/Regular/Expanded mode and one-/two-/three-column content delivery; no separate preference is persisted | | Drag and Drop | ✓ Improved | Cards reorder smoothly with clean UX; no decorative borders (post-92f97c7) | | Resizing | ✓ Improved | Cards resize without item jumping; grid stable during drag (post-c8f2e9c) | | Minimum Height | ✓ Changed 2026-10-28 | No system floor; declared preferred_height (≥1rem) is honoured at render and resize, content never clipped | | Dashboard Configuration | ✓ Verified | Data-defined Dashboard children remain separate generic PO surfaces | | Edit Mode Toggle | ✓ Verified | Handles visible/hidden on toggle; save button appears | | Navigation | ✓ Verified | Data-defined navigation entries resolve through the authorized Presentation graph |
Related Documentation
- [Core Object Model and Platform Authority](../architecture/core-object-model-and-authority.md) — Presentation Object semantic role and contract
- [Views and Content](../architecture/views-and-content.md) — Projection and rendering contract
- [UI System](ui-system.md) — Grid layout and renderer boundaries
- [Objects and Types](../architecture/objects-and-types.md) — Object model and revision semantics
Commit Timeline
| Commit | Date | Purpose | |--------|------|---------| | c8f2e9c | 2026-10-09 | Stable resize, prevent grid reflow during drag | | 92f97c7 | 2026-10-09 | Clean drag-drop UX, remove visual indicators | | 75531aa | 2026-10-09 | Resize fix (baseline reference) | | a9d3d58 | 2026-10-09 | Drag n drop fix | | 232f2f7 | 2026-10-09 | Route Dashboard layout saves through HTML boundary | | f7ff5a9 | 2026-10-08 | Bust Dashboard layout script cache | | 2d8ec11 | 2026-10-08 | Fix Dashboard layout save CSRF | | a2cee89 | 2026-10-08 | Add explicit Dashboard layout save | | f80e399 | 2026-10-08 | Align responsive Dashboard breakpoints | | cbfa438 | 2026-10-08 | Restore vertical Dashboard resizing | | 2be9f72 | 2026-10-07 | Render Dashboard summary cards in grid | | b97fee8 | 2026-10-07 | Restore Dashboard summary surfaces | | 45f10b6 | 2026-10-07 | Restore generic presentation object surfaces |
Next Steps for Future Development
1. Verify this baseline. Run this checklist after any future deployment:
- Dashboard loads without errors
- Data-defined Dashboard child surfaces visible and independent
- Cards have independent drag and resize handles
- Edit mode toggles on/off
- Drag-and-drop reorders cards smoothly
- Resizing updates card height with minimum constraint
- "Save layout" persists changes
- Refresh page retains saved layout
2. When extending presentation. Consult Views and Content for the contract. Add data-defined child POs to Dashboard.children[], not as separate navigation pages or capability definitions.
3. When modifying the grid. Ensure changes preserve:
- Six-column span system
- Valid spans (2, 3, 4, 6)
- No gaps or orphaned columns
- Responsive behavior on smaller screens
- No system minimum card height; content never clipped
4. When updating demonstrator profile. Distinguish profile changes (provisioning template) from Business configuration (persisted state). Use the governed mutation mechanism to update existing Businesses.